geneseed 3.14.0 → 3.16.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 (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  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/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/web/api.mjs CHANGED
@@ -1,16 +1,14 @@
1
1
  /**
2
- * The web console's read endpoints — `overview`, `themes`, `doctor`, `diff`, `installs`,
3
- * `excludes`, `setup`, `profile`, `catalog`, `item` and `wiki_item` — and the `WebState`
4
- * all eleven read from.
2
+ * The web console's dashboard and settings reads — `overview`, `recent`, `themes`, `setup`,
3
+ * `doctor`, `diff`, `installs`, `excludes` and the two `loops` reads.
5
4
  *
6
- * `PREFIX_ROUTES` below are the first routes that parse a path, which brings three things
7
- * no state-keyed route needs: `percentDecode` (NOT `decodeURIComponent`, which throws on a
8
- * `%` that is not an escape, where this shell instead answers a 404 naming the literal
9
- * text), the `NotFound` → 404 convention, and `flatName`'s traversal refusal — a GET
10
- * carries no token, so before that check the item route was an arbitrary-file read.
5
+ * WHAT IS NOT HERE, AND WHERE IT WENT: the Library read model (catalog sections, items, wiki,
6
+ * memory, `NotFound`) is `catalog.mjs`; the `WebState` every endpoint reads from is `state.mjs`;
7
+ * the fingerprint-guarded rules/profile editors are `user-files.mjs`; and the GET tables that
8
+ * bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
9
+ * which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
11
10
  */
12
- import { createHash } from 'node:crypto';
13
- import { existsSync, readdirSync, statSync } from 'node:fs';
11
+ import { statSync } from 'node:fs';
14
12
  import path from 'node:path';
15
13
 
16
14
  import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
@@ -18,661 +16,31 @@ import { sourceReleaseVersion } from '../hosts/opencode.mjs';
18
16
  import { diffCollect } from '../inspect/diff.mjs';
19
17
  import { doctorCollect } from '../inspect/doctor.mjs';
20
18
  import { excludesSnapshot } from '../inspect/excludes.mjs';
21
- import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
19
+ import { CLAUDE_STYLE, HOSTS, resolvePath } from '../hosts/hosts.mjs';
22
20
  import {
23
- doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
24
- installedDefaults, modeOfDir, trustOfDir,
25
- postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
21
+ footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
22
+ postureOfDir, readJsonMaybe, themeOfDir,
26
23
  } from '../hosts/installs.mjs';
27
- import { frontmatter } from '../hosts/memory-files.mjs';
28
- import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
24
+ import { PRESETS } from '../loop/score.mjs';
29
25
  import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
30
26
  import { activeLoops } from '../loop/registry.mjs';
31
- import {
32
- SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
33
- } from '../inspect/inventory.mjs';
34
- import { firstBlockquote } from '../hosts/native.mjs';
35
27
  import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
36
28
  import { accentFor, statusData } from '../inspect/status.mjs';
37
- import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
38
- import { comparePaths, normcase, within } from '../lib/paths.mjs';
39
- import { stripWhitespace, percentDecode } from '../lib/text.mjs';
40
- import { readJsonc } from '../hosts/settings.mjs';
41
- import { PROFILE_STUB } from '../build/stubs.mjs';
42
- import { apiActivity, apiActivityDetail } from './activity.mjs';
43
- import { apiMcp, apiRules } from './actions.mjs';
44
-
45
- /** A requested catalog section or item that does not exist. */
46
- export class NotFound extends Error {}
47
-
48
- /** Two resolved paths, compared case-folded on Windows. */
49
- const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
50
-
51
- /** `??` would also swallow an explicit `null` the file declared; this does not. */
52
- const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
53
-
54
- /** SHA-256 hex, first 16 chars — `""` for an empty file. */
55
- export function fingerprint(text) {
56
- return text ? createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 16) : '';
57
- }
58
-
59
- /**
60
- * `YYYY-MM-DD HH:MM`, LOCAL time — `toISOString` would be UTC, which reads wrong in every
61
- * timezone but one for a user-facing timestamp.
62
- */
63
- export function stampMinute(ms) {
64
- const d = new Date(ms);
65
- const p2 = (n) => String(n).padStart(2, '0');
66
- return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} `
67
- + `${p2(d.getHours())}:${p2(d.getMinutes())}`;
68
- }
69
-
70
- // ---- WebState ---------------------------------------------------------------------------
71
-
72
- /**
73
- * The resolved view of the deployed harness a request reads from.
74
- *
75
- * A factory returning a plain object rather than a class, because the two cached
76
- * properties are the only behaviour and a getter pair expresses them without ceremony.
77
- * THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
78
- * and the dashboard GETs `/api/overview` on every navigation, so an uncached `doctor`
79
- * would put a full build on the request path. `refresh()` is what a mutation calls to
80
- * drop both.
81
- */
82
- export function webState(theme = null, target = null) {
83
- const st = {
84
- // NOT resolved: `target` is printed by three endpoints, so resolving it here would
85
- // change what they answer.
86
- target: target || opencodeConfigDir(),
87
- root: null,
88
- theme: null,
89
- emit: null,
90
- footprint: null,
91
- posture: null,
92
- mode: null,
93
- trust: null,
94
- _inv: null,
95
- _doctor: null,
96
- };
97
- // The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob
98
- // PROJECT install keeps its data under <repo>/.claude while the markers land at <repo>/.
99
- st.root = st.target;
100
- st.theme = theme || themeOfDir(st.target) || 'neutral';
101
- st.emit = installedDefaults().emit || 'opencode-global';
102
- // The install's sigils, each defaulted when its marker is absent (footprint to 'full').
103
- // Read from the ROOT, where every emit writes them.
104
- const readMarkers = () => {
105
- st.footprint = footprintOfDir(st.root);
106
- st.posture = postureOfDir(st.root) || 'peer';
107
- st.mode = modeOfDir(st.root) || 'direct';
108
- st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
109
- };
110
- readMarkers();
111
-
112
- Object.defineProperty(st, 'inventory', {
113
- get() {
114
- if (st._inv === null) st._inv = inventoryFor(st);
115
- return st._inv;
116
- },
117
- });
118
- Object.defineProperty(st, 'doctor', {
119
- get() {
120
- if (st._doctor === null) {
121
- const [, problems] = doctorCollect({ theme: st.theme });
122
- st.stampDoctor(problems);
123
- }
124
- return st._doctor;
125
- },
126
- });
127
- st.stampDoctor = (problems) => {
128
- st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
129
- };
130
- /**
131
- * The CURRENT install's mode, from its `.geneseed-emit` marker: the ROOT first (where
132
- * every emit writes it), then the data dir.
133
- *
134
- * `refresh()` below MUST call this: without it, `emit` goes stale after any write that
135
- * re-themes or re-points the install, since the constructor only reads it once via
136
- * `installedDefaults()`.
137
- */
138
- st.detectEmit = () => {
139
- for (const d of [st.root, st.target]) {
140
- try {
141
- const em = path.join(d, '.geneseed-emit');
142
- if (isFile(em)) {
143
- const v = stripWhitespace(readText(em));
144
- if (v) return v;
145
- }
146
- } catch { /* unreadable marker: try the next candidate */ }
147
- }
148
- return existsSync(path.join(st.root, 'CLAUDE.md')) ? 'claude-global' : 'opencode-global';
149
- };
150
- /**
151
- * Re-point the console at another detected install's data dir.
152
- *
153
- * `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
154
- * differs only for claude/bob/openclaude PROJECT installs, where the data sits under
155
- * `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
156
- * `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
157
- * and a Diff or a Restore would then overwrite it in the wrong dialect.
158
- */
159
- st.selectView = (target, root = null) => {
160
- st.target = target;
161
- st.root = root || target;
162
- st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
163
- st.emit = st.detectEmit();
164
- readMarkers();
165
- st._inv = null;
166
- st._doctor = null;
167
- };
168
- st.refresh = () => {
169
- st._inv = null;
170
- st._doctor = null;
171
- st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
172
- st.emit = st.detectEmit() || st.emit;
173
- readMarkers();
174
- };
175
- return st;
176
- }
177
-
178
- export function deployed(state) {
179
- return existsSync(path.join(state.target, GLOBAL_MANIFEST));
180
- }
181
-
182
- /**
183
- * A deployed spec's one-line purpose.
184
- *
185
- * The `> blockquote` convention every rendered skill and agent carries, then the
186
- * frontmatter `description`, then the first prose paragraph. The fallbacks are for the
187
- * VENDORED skill folders, which ride in verbatim with no blockquote and would otherwise
188
- * show a blank Purpose cell.
189
- */
190
- export function specDesc(fm, body) {
191
- const bq = firstBlockquote(body);
192
- if (bq) return bq;
193
- const desc = String(fm.has('description') ? fm.get('description') : '').trim();
194
- if (desc) return desc.split(/\s+/).filter(Boolean).join(' ');
195
- for (const para of body.split('\n\n')) {
196
- const s = para.split(/\s+/).filter(Boolean).join(' ');
197
- if (s && !(s.startsWith('#') || s.startsWith('---') || s.startsWith('<!--'))) return s;
198
- }
199
- return '';
200
- }
201
-
202
- /**
203
- * Agent/skill specs read straight off a DEPLOYED harness dir.
204
- *
205
- * Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
206
- * folder layout `<root>/<name>/SKILL.md`. Matched case-INSENSITIVE on Windows via
207
- * `normcase`, not a bare `endsWith`. The frontmatter is stripped because it is host
208
- * plumbing rather than prose, which is what makes a deployed entry the same shape as a
209
- * source-rendered one and every consumer indifferent to the origin.
210
- */
211
- function specEntries(root, nested) {
212
- const out = [];
213
- if (!isDir(root)) return out;
214
- let names;
215
- try { names = readdirSync(root); } catch { return out; }
216
- const files = nested
217
- ? names.sort(comparePaths).filter((n) => isDir(path.join(root, n)))
218
- .map((n) => path.join(root, n, 'SKILL.md'))
219
- : names.filter((n) => normcase(n).endsWith('.md') && !n.startsWith('_'))
220
- .sort(comparePaths).map((n) => path.join(root, n));
221
- for (const p of files) {
222
- if (!isFile(p)) continue;
223
- const [fm, body] = frontmatter(readMaybe(p) ?? '');
224
- const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
225
- out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
226
- }
227
- out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
228
- return out;
229
- }
230
-
231
- /**
232
- * The agents and skills actually installed at `state.target`, not a fresh render of `src/`.
233
- *
234
- * All three constitutional tiers still come from the render: once deployed they live inside
235
- * AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
236
- * and none of the constitution. A deployed spec file carries neither a category nor a
237
- * lifecycle status, so each is tagged BY NAME from the same two sources the source render
238
- * uses — and an entity the registry does not know reads "personal" for both, which is the
239
- * honest answer rather than a borrowed one: filing it under the `build` fallback would claim
240
- * a Geneseed taxonomy slot it was never given.
241
- *
242
- * ⚠ THE PACK SELECTION IS THE ONE THING THE RENDER CANNOT KNOW. `renderAll` emits all four
243
- * pack files whatever `cfg.doctrines` says — measured: `makeCfg({doctrines:['craft']})` yields
244
- * the same 105 items — because the filtering happens when AGENT.md's §2 is assembled, not in
245
- * the walk. So the catalogue is always whole, and which packs this INSTALL built in comes from
246
- * its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
247
- */
248
- function deployedInventory(state) {
249
- const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
250
- excludedRulesOfDir(state.target));
251
- const registry = loadRegistry();
252
- const skills = specEntries(path.join(state.target, 'skills'), true);
253
- for (const e of skills) {
254
- e.status = entityStatus(registry, `skills/${e.name}`);
255
- e.klass = e.status === 'personal' ? 'personal'
256
- : (Object.hasOwn(SKILL_CLASS, e.name) ? SKILL_CLASS[e.name] : 'build');
257
- }
258
- const agents = specEntries(path.join(state.target, 'agents'), false);
259
- for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
260
- return { agents,
261
- skills,
262
- laws: render.laws,
263
- ontology: render.ontology,
264
- doctrines: render.doctrines,
265
- theme: state.theme };
266
- }
267
-
268
- /** The deployed record when there is one, else the source render. */
269
- function inventoryFor(state) {
270
- return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
271
- }
272
-
273
- // ---- the catalog stores overview counts ---------------------------------------------------
274
-
275
- /** Always `<target>/memory` — never the CWD-scanning resolver. */
276
- const memoryDir = (state) => path.join(state.target, 'memory');
277
- const notebookDir = (state) => path.join(state.target, 'notebook');
278
-
279
- /**
280
- * Matched case-insensitively on Windows.
281
- *
282
- * `comparePaths` and NOT a bare `.sort()`: on Windows, filenames sort case-insensitively, so
283
- * `MEMORY.md` sorts under `m` and lands after `a-fact.md`; JS's default comparator is UTF-16
284
- * code units, so `M` (0x4D) sorts before `a` (0x61) and the whole catalog comes back in a
285
- * different order. The `normcase` in the filter beside it only fixes matching, not
286
- * ordering — this is the other half.
287
- */
288
- function globMd(dir) {
289
- if (!isDir(dir)) return [];
290
- let names;
291
- try { names = readdirSync(dir); } catch { return []; }
292
- return names.filter((n) => normcase(n).endsWith('.md') && isFile(path.join(dir, n)))
293
- .sort(comparePaths);
294
- }
295
-
296
- /** Filename without its extension — single owner, imported by `js/web/activity.mjs` too. */
297
- export const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
298
-
299
- export function memoryItems(state) {
300
- const d = memoryDir(state);
301
- if (!isDir(d)) return [];
302
- return globMd(d).map((n) => {
303
- const p = path.join(d, n);
304
- const [fm] = frontmatter(readMaybe(p) ?? '');
305
- const stem = stemOf(n);
306
- return {
307
- name: stem,
308
- title: fm.has('name') ? fm.get('name') : stem,
309
- desc: fm.has('description') ? fm.get('description') : '',
310
- source: resolvePath(p),
311
- };
312
- });
313
- }
314
-
315
- function notebookItems(state) {
316
- const d = notebookDir(state);
317
- if (!isDir(d)) return [];
318
- return globMd(d).map((n) => ({
319
- name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
320
- }));
321
- }
322
-
323
- /** The two setup manifests, in listing order. */
324
- const CONFIG_META = {
325
- 'context.json': ['Project context', 'what the agent loads for this project'],
326
- 'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
327
- };
328
-
329
- function configItems(state) {
330
- const out = [];
331
- for (const fname of ['context.json', 'wiki.jsonc']) {
332
- const p = path.join(state.target, fname);
333
- if (!isFile(p)) continue;
334
- const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
335
- out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
336
- source: resolvePath(p) });
337
- }
338
- return out;
339
- }
340
-
341
- const WIKI_FILE_CAP = 5000;
342
-
343
- /**
344
- * `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
345
- *
346
- * The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
347
- * request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
348
- * (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
349
- * wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
350
- * ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
351
- * argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
352
- * and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
353
- * `continue`-into-404.
354
- *
355
- * `withDiscardableStderr` because `expanduser` prints its refusal at the RAISE SITE, and
356
- * this caller wants that swallowed along with the exception rather than logged.
357
- *
358
- * DELIBERATE PRODUCT DECISION: a `~otherUser` path is refused rather than resolved, even
359
- * where the account happens to exist — reading another account's files through a
360
- * hand-edited wiki manifest is not a feature.
361
- */
362
- function wikiPath(p) {
363
- try {
364
- return withDiscardableStderr(() => resolvePath(p));
365
- } catch {
366
- return null;
367
- }
368
- }
369
-
370
- /**
371
- * `$GENESEED_WIKI` first, else `wiki.jsonc` beside the deployed bundle, read with the
372
- * harness's generic JSONC loader.
373
- *
374
- * `resolvePath`, not merely `expanduser`: harmless here because `p` is consumed by `isFile`
375
- * and `mcpLoad` and never reaches a response body, and both of those follow symlinks and
376
- * resolve a relative path against the same cwd anyway.
377
- */
378
- function wikiManifest(state) {
379
- const cand = process.env.GENESEED_WIKI;
380
- const p = cand ? wikiPath(cand) : path.join(state.target, 'wiki.jsonc');
381
- if (!p || !isFile(p)) return [];
382
- const cfg = mcpLoad(p);
383
- const wikis = cfg.wikis;
384
- return Array.isArray(wikis) ? wikis : [];
385
- }
386
-
387
- /**
388
- * The COMMENT-TOLERANT dict loader, `{}` for a file that is missing, unreadable,
389
- * unparseable, or not an object.
390
- *
391
- * Comment-tolerant is the whole point, and getting this wrong is silent: the two files it
392
- * reads are `wiki.jsonc` and `context.json`, hand-maintained, so a `//` line is expected.
393
- * Plain `JSON.parse` would throw on it and this loader would then answer `{}` — the wiki
394
- * section listing nothing and the config item's `manifest` coming back empty, with no
395
- * error surfaced anywhere.
396
- */
397
- function mcpLoad(p) {
398
- if (!isFile(p)) return {};
399
- let text;
400
- try { text = readText(p); } catch { return {}; }
401
- const [data] = readJsonc(text);
402
- return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
403
- }
404
-
405
- /**
406
- * Every `.md` under `dir`, recursively.
407
- *
408
- * `recursive: true` replaces a hand-rolled walk; its one caller re-sorts the result with
409
- * `comparePaths` before using it, so the order this returns is never observed.
410
- */
411
- function rglobMd(dir) {
412
- let entries;
413
- try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; }
414
- return entries
415
- .filter((e) => e.isFile() && normcase(e.name).endsWith('.md'))
416
- .map((e) => path.join(e.parentPath, e.name));
417
- }
418
-
419
- export function wikiItems(state) {
420
- const items = [];
421
- const seen = new Set();
422
- for (const w of wikiManifest(state)) {
423
- if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
424
- const wname = String(dget(w, 'name', null) || 'wiki');
425
- // PER ENTRY, and the loop continues — one unusable `path` in the manifest must not
426
- // blank every OTHER vault in it.
427
- const root = wikiPath(String(dget(w, 'path', null) || ''));
428
- if (!root || !isDir(root)) continue;
429
- // A hand-edited `entries` that is not a list (`{}`, a string) is a malformed vault, not a
430
- // crash: `/api/status` counts these, and one bad manifest must not fail the dashboard.
431
- const rawEntries = dget(w, 'entries', null);
432
- const entries = (Array.isArray(rawEntries) ? rawEntries : [])
433
- .filter((e) => e && typeof e === 'object' && !Array.isArray(e));
434
- const excludes = entries.filter((e) => dget(e, 'load', null) === 'exclude')
435
- .map((e) => String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
436
- const excluded = (rel) => excludes.some((x) => x && (rel === x || rel.startsWith(`${x}/`)));
437
-
438
- for (const e of entries) {
439
- if (dget(e, 'load', null) === 'exclude') continue;
440
- const rel = String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
441
- const desc = String(dget(e, 'description', null) || '');
442
- const fp = path.join(root, rel);
443
- let mds;
444
- if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
445
- // Capped and sorted via `comparePaths`, for the reason `globMd` carries.
446
- else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
447
- else continue;
448
- for (const md of mds) {
449
- const r = path.relative(root, md).split(path.sep).join('/');
450
- const key = `${wname}:${r}`;
451
- if (seen.has(key) || excluded(r)) continue;
452
- seen.add(key);
453
- items.push({ name: key, title: path.basename(md, '.md'),
454
- desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
455
- source: resolvePath(md) });
456
- }
457
- }
458
- }
459
- return items;
460
- }
461
-
462
- /** One page by `<wiki>:<relpath>`, never outside the vault. */
463
- function apiWikiItem(state, name) {
464
- const at = name.indexOf(':');
465
- const wname = at < 0 ? name : name.slice(0, at);
466
- const rel = (at < 0 ? '' : name.slice(at + 1))
467
- .replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
468
- for (const w of wikiManifest(state)) {
469
- if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
470
- if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
471
- // An unresolvable or refused root falls through to the `NotFound` below, the same as
472
- // one that simply does not exist.
473
- const root = wikiPath(String(dget(w, 'path', null) || ''));
474
- if (!root) continue;
475
- const p = resolvePath(path.join(root, rel));
476
- if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
477
- const body = readMaybe(p) ?? '';
478
- return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
479
- body, links: resolveLinks(state, body), source: p };
480
- }
481
- }
482
- throw new NotFound(name);
483
- }
484
-
485
- /**
486
- * Re-exported from `js/lib/paths.mjs`, where the lib split put it.
487
- *
488
- * ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
489
- * walks. A change to the containment rule has to land on both, and nothing compares them.
490
- *
491
- * Re-exported here (rather than imported directly by callers) so `js/web/actions.mjs`'s
492
- * import path stays unchanged.
493
- */
494
- export { within };
495
-
496
- /**
497
- * Catalog names are flat basenames.
498
- *
499
- * A separator, a `..` or a drive colon in the URL segment is someone steering the join
500
- * outside the catalog dir. A GET carries no token, so before this check the endpoint was
501
- * an arbitrary-file read; it raises rather than resolving.
502
- */
503
- function flatName(name) {
504
- if (!name || name.includes('/') || name.includes('\\') || name.includes('..')
505
- || name.includes(':')) {
506
- throw new NotFound(name);
507
- }
508
- }
509
-
510
- const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
511
-
512
- /**
513
- * `[[name]]` matched against known agent and skill names.
514
- *
515
- * AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
516
- * This ordering is deliberate and must not be swapped.
517
- */
518
- export function resolveLinks(state, body) {
519
- const inv = state.inventory;
520
- const known = new Map();
521
- for (const e of inv.agents) known.set(e.name, 'agent');
522
- for (const e of inv.skills) known.set(e.name, 'skill');
523
- const links = [];
524
- const seen = new Set();
525
- for (const m of body.matchAll(WIKILINK_RE)) {
526
- // `stripWhitespace`, not `trim()` — the two whitespace classes differ, and both call
527
- // sites on this regex must answer with the same rule.
528
- const label = stripWhitespace(m[1]);
529
- if (known.has(label) && !seen.has(label)) {
530
- seen.add(label);
531
- links.push({ label, type: known.get(label), name: label });
532
- }
533
- }
534
- return links;
535
- }
536
-
537
- /**
538
- * A setup file parsed into the shape the detail pane renders as cards, or `null` so the
539
- * caller falls back to the raw body.
540
- */
541
- function configManifest(name, p) {
542
- const cfg = mcpLoad(p);
543
- if (name === 'wiki.jsonc') {
544
- const wikis = cfg.wikis;
545
- return { kind: 'wiki', wikis: Array.isArray(wikis) ? wikis : [] };
546
- }
547
- if (name === 'context.json') {
548
- const ctx = cfg.context;
549
- return { kind: 'context', context: Array.isArray(ctx) ? ctx : [] };
550
- }
551
- return null;
552
- }
553
-
554
- /**
555
- * A closed list; anything else is a 404.
556
- *
557
- * ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
558
- * oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
559
- * top to bottom" property; the id stays `laws` because `tests/helpers/cli_golden.mjs`
560
- * hard-requires `web/src/pages/Laws.jsx` and because a route rename buys nothing and costs a
561
- * redirect. The tier lives on the ITEM (`tier: 'ontology' | 'invariant' | 'doctrine'`), which
562
- * is where a consumer can act on it.
563
- */
564
- export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
29
+ import { isFile } from '../lib/fs.mjs';
30
+ import { comparePaths, normcase } from '../lib/paths.mjs';
31
+ import {
32
+ configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
33
+ } from './catalog.mjs';
34
+ import { stampMinute } from './state.mjs';
565
35
 
566
36
  /**
567
- * The `laws` section's items — the whole constitution, in constitutional order, one flat list.
568
- *
569
- * FLAT AND NOT NESTED, because every other section is `{section, items}` and a second shape
570
- * for one section is a second client path for one roster. The pack's grouping metadata rides
571
- * on each doctrine row (`pack`, `packTitle`, `packDesc`, `active`), so a consumer groups by
572
- * `pack` and has everything a header needs without a second fetch.
573
- *
574
- * Names are ADDRESSES and the three shapes cannot collide: `ont:<slug>` carries a colon no
575
- * numeral has, an invariant is `[IVXLCDM]+`, a doctrine rule is `<pack>.<n>`. `apiItem`
576
- * resolves all three off the same list.
37
+ * `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
38
+ * they are re-exported rather than moved out from under it. New code imports `catalog.mjs`.
577
39
  */
578
- function constitutionItems(inv) {
579
- return [
580
- ...(inv.ontology ?? []).map((e) => ({ name: `ont:${e.id}`, title: e.title, desc: '',
581
- tier: 'ontology' })),
582
- ...inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`, desc: '',
583
- klass: e.klass ?? 'craft', tier: 'invariant' })),
584
- ...(inv.doctrines ?? []).flatMap((p) => p.rules.map((r) => ({
585
- name: `${r.pack}.${r.n}`,
586
- title: `Doctrine ${r.pack} ${r.n} — ${r.title}`,
587
- desc: '',
588
- klass: r.klass ?? p.pack,
589
- tier: 'doctrine',
590
- pack: p.pack,
591
- packTitle: p.title,
592
- packDesc: p.desc,
593
- // ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
594
- // — a pack can be on with one rule excluded. A row that vanished from the payload would
595
- // be indistinguishable from one that never shipped, and the console could not then
596
- // offer the switch that turns it back on.
597
- active: r.active !== false,
598
- packActive: p.active,
599
- }))),
600
- ];
601
- }
40
+ export { NotFound, deployed, resolveLinks } from './catalog.mjs';
602
41
 
603
- export function apiCatalog(state, section) {
604
- if (!SECTIONS.includes(section)) throw new NotFound(section);
605
- const inv = state.inventory;
606
- let items;
607
- if (section === 'agents') {
608
- items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
609
- source: e.source ?? null, status: e.status ?? 'unknown' }));
610
- } else if (section === 'skills') {
611
- items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
612
- source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
613
- } else if (section === 'laws') {
614
- items = constitutionItems(inv);
615
- } else if (section === 'memory') {
616
- items = memoryItems(state);
617
- } else if (section === 'notebook') {
618
- items = notebookItems(state);
619
- } else if (section === 'wiki') {
620
- items = wikiItems(state);
621
- } else {
622
- items = configItems(state);
623
- }
624
- return { section, items };
625
- }
626
-
627
- export function apiItem(state, type, name) {
628
- const inv = state.inventory;
629
- if (type === 'agent' || type === 'skill') {
630
- const e = (type === 'agent' ? inv.agents : inv.skills).find((x) => x.name === name);
631
- if (!e) throw new NotFound(name);
632
- const out = { type, name, title: name, desc: e.desc, body: e.body,
633
- links: resolveLinks(state, e.body), source: e.source ?? null };
634
- if (type === 'skill') out.klass = e.klass ?? 'build';
635
- out.status = e.status ?? 'unknown';
636
- return out;
637
- }
638
- if (type === 'law') {
639
- // ONE ARM FOR THREE TIERS, resolved off the same list the catalogue publishes — so a name
640
- // that appears in `/api/catalog/laws` always opens, and one that does not always 404s.
641
- // Building the list twice is what would let the two drift.
642
- const e = constitutionItems(inv).find((x) => x.name === name);
643
- if (!e) throw new NotFound(name);
644
- const body = e.tier === 'ontology'
645
- ? (inv.ontology.find((s) => `ont:${s.id}` === name)?.body ?? '')
646
- : (e.tier === 'invariant'
647
- ? (inv.laws.find((x) => x.num === name)?.body ?? '')
648
- : (inv.doctrines.flatMap((p) => p.rules)
649
- .find((r) => `${r.pack}.${r.n}` === name)?.body ?? ''));
650
- // `links: []` — a constitution body is not link-resolved. It cites its siblings by
651
- // ADDRESS (`Rule IV`, `Doctrine ops 1`, `Ontology: Telos`), not by path, and there is no
652
- // file behind those to resolve to.
653
- return { ...e, type, name, body, links: [] };
654
- }
655
- if (type === 'memory' || type === 'notebook') {
656
- flatName(name);
657
- const d = type === 'notebook' ? notebookDir(state) : memoryDir(state);
658
- const p = path.join(d, `${name}.md`);
659
- if (!isFile(p)) throw new NotFound(name);
660
- const body = readMaybe(p) ?? '';
661
- return { type, name, title: name, desc: '', body,
662
- links: resolveLinks(state, body), source: resolvePath(p) };
663
- }
664
- if (type === 'wiki') return apiWikiItem(state, name);
665
- if (type === 'config') {
666
- flatName(name);
667
- const p = path.join(state.target, name);
668
- if (!isFile(p)) throw new NotFound(name);
669
- const raw = readMaybe(p) ?? '';
670
- const [title, desc] = CONFIG_META[name] ?? [name, ''];
671
- return { type, name, title, desc, manifest: configManifest(name, p),
672
- body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
673
- }
674
- throw new NotFound(type);
675
- }
42
+ /** Two resolved paths, compared case-folded on Windows. */
43
+ const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
676
44
 
677
45
  // ---- the eight endpoints -------------------------------------------------------------
678
46
 
@@ -717,10 +85,18 @@ export function apiDoctor(state) {
717
85
  checked_at: state.doctor.checked_at };
718
86
  }
719
87
 
88
+ /**
89
+ * `deployed` and `diffable` are TWO FACTS, and they used to be one field. `diffCollect` answers
90
+ * `files: null` for every PROJECT install (it only knows how to re-render a global emit), so
91
+ * `deployed: files !== null` told the Diff page a perfectly real project install was "No deployed
92
+ * harness". `deployed` is the manifest's answer, the same one every other endpoint gives;
93
+ * `diffable` is whether a drift check exists for this install at all.
94
+ */
720
95
  export function apiDiff(state) {
721
96
  const { target, theme, files } = diffCollect({
722
97
  target: state.target, theme: state.theme, emit: state.emit });
723
- return { deployed: files !== null, target, theme, files: files || [] };
98
+ return { deployed: deployed(state), diffable: files !== null, target, theme,
99
+ files: files || [] };
724
100
  }
725
101
 
726
102
  /** The union across every global install, verbatim. */
@@ -747,7 +123,7 @@ export function apiInstalls(state) {
747
123
  footprint: footprintOfDir(root),
748
124
  posture: postureOfDir(root),
749
125
  mode: modeOfDir(root),
750
- trust: trustOfDir(root),
126
+ trust: trustOfDir(root, host),
751
127
  selected: samePath(viewCfg(host, scope, root), state.target),
752
128
  });
753
129
  }
@@ -755,20 +131,6 @@ export function apiInstalls(state) {
755
131
  modes: discoverNames('modes', 'direct'), trusts: Object.keys(PRESETS) };
756
132
  }
757
133
 
758
- /** Beside the deployed AGENT.md. */
759
- export const PROFILE_FILE = 'PROFILE.md';
760
- const profilePath = (state) => path.join(state.target, PROFILE_FILE);
761
-
762
- function apiProfile(state) {
763
- const p = profilePath(state);
764
- if (!isFile(p)) return { exists: false, path: p, text: '', fingerprint: '' };
765
- const text = readMaybe(p) ?? '';
766
- // `seeded`: the file is still the template the build wrote once and never touched since.
767
- // `readMaybe` normalises line endings, so a CRLF copy (the emit writes the platform's, and
768
- // an editor may re-save either way) still compares equal to the LF literal.
769
- const seeded = text === PROFILE_STUB;
770
- return { exists: true, path: p, text, fingerprint: fingerprint(text), seeded };
771
- }
772
134
 
773
135
  /** The dashboard aggregate. */
774
136
  export function apiOverview(state) {
@@ -986,49 +348,3 @@ function releaseLabel() {
986
348
  return v && v !== '0.0.0' ? v : null;
987
349
  }
988
350
 
989
- /**
990
- * The prefix routes — path in, response out.
991
- *
992
- * `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
993
- * that is not an escape, where this shell instead answers a 404 naming the literal text.
994
- */
995
- export const PREFIX_ROUTES = [
996
- ['/api/catalog/', (state, p) => apiCatalog(state, p.split('/').pop())],
997
- // /api/item/<type>/<name> — TYPE has no slash, the NAME keeps its slashes so a wiki
998
- // page's relpath survives. A missing name is a 404 here rather than a 500 two frames down.
999
- ['/api/item/', (state, p) => {
1000
- const m = /^\/api\/item\/([^/]+)\/(.+)$/.exec(p);
1001
- if (!m) throw new NotFound(p);
1002
- return apiItem(state, m[1], percentDecode(m[2]));
1003
- }],
1004
- // The sid is passed through unquoted — `apiActivityDetail`'s safe-name scheme is what
1005
- // makes the lookup safe, rather than a check out here.
1006
- ['/api/activity/', (state, p) => apiActivityDetail(
1007
- state, percentDecode(p.slice('/api/activity/'.length)))],
1008
- ];
1009
-
1010
- /**
1011
- * A literal path to `api_X(state)`, table-keyed rather than a chain of `if`s — the reason
1012
- * it is a table is `tests/unit/web_server.test.mjs`'s cross-check, which requires this
1013
- * table plus `PREFIX_ROUTES` and `GET_INLINE` to equal a surface list written out in the
1014
- * test, so a route cannot appear or vanish unenumerated.
1015
- */
1016
- export const STATE_ROUTES = {
1017
- '/api/overview': apiOverview,
1018
- '/api/recent': apiRecent,
1019
- '/api/themes': apiThemes,
1020
- '/api/setup': apiSetup,
1021
- '/api/doctor': apiDoctor,
1022
- '/api/installs': apiInstalls,
1023
- '/api/excludes': apiExcludes,
1024
- '/api/profile': apiProfile,
1025
- '/api/diff': apiDiff,
1026
- '/api/activity': apiActivity,
1027
- // `/api/rules` must exist as a PAIR with its POST: `apiRulesMutate` splices `parseRules`'
1028
- // line indices, and the fingerprint every mutation must send back is what this GET
1029
- // answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
1030
- '/api/rules': apiRules,
1031
- '/api/mcp': apiMcp,
1032
- '/api/loops': apiLoops,
1033
- '/api/loops/active': apiLoopsActive,
1034
- };