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