geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/web/api.mjs CHANGED
@@ -1,55 +1,13 @@
1
1
  /**
2
- * The web console's read endpoints — P6b's `overview`, `themes`, `doctor`, `diff`,
3
- * `installs`, `excludes`, `setup` and `profile`, P6c's `catalog`, `item` and `wiki_item`,
4
- * and the `WebState` all eleven read from.
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.
5
5
  *
6
- * WHAT WAS ACTUALLY NEW HERE, MEASURED BY READING `js/` RATHER THAN BY GREPPING A NAME.
7
- * The P6 handoff scored this group at "~430 LOC new against ~1,600 already ported" and
8
- * the order was right, but the shape is worth stating because it is the same discovery
9
- * every P5 sub-phase made: six of the eight are a JSON face over a function `js/` already
10
- * owns. `apiSetup` is `statusData()` plus four fields. `apiDiff` is `diffCollect()`
11
- * reshaped. `apiExcludes` is `excludesSnapshot()`, unmodified. `apiInstalls` is a row per
12
- * `installTargets()` entry through five detectors P5d and P5f ported. `apiDoctor` is
13
- * `doctorCollect()` — with the one parameter it did not have. Only `apiThemes` and
14
- * `apiOverview` compute anything of their own, and most of what `apiOverview` computes is
15
- * counting.
16
- *
17
- * THE ONE PARAMETER: `doctorCollect({groups})`. `js/inspect/doctor.mjs` carried a `ran(check,
18
- * label, probs)` that returned its third argument and threw the first two away, with a
19
- * docblock saying the label stays "because it is the one place each check is NAMED, and
20
- * what P6's accumulator will key on". This is that phase. `on_progress=` — the TUI's — is
21
- * still absent, still P7's: nothing here passes it and adding it would be a claim no cell
22
- * can check.
23
- *
24
- * THE INVENTORY IS THE FULL RECORD SINCE P6c, AND THERE IS STILL ONE CLASSIFIER. P6b
25
- * shipped `inventoryFor` as three integers, because `apiOverview` reads three `len()`s off
26
- * `state.inventory` and the badges and taxonomy classes belonged to a phase that had not
27
- * arrived. `api_catalog` consumes exactly those fields, so this is the phase that would
28
- * have made the counting half and the reading half two classifiers. It did not:
29
- * `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, `js/inspect/status.mjs`'s `inventoryCounts`
30
- * is three `length`s off it, and `specEntries` grew out of `specNames` by adding the READ
31
- * rather than by being written beside it.
32
- *
33
- * THE TAXONOMY WAS ALREADY PORTED, which the measurement found and the handoff did not.
34
- * `js/inspect/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
35
- * `SKILL_CLASS`, `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g inside
36
- * `js/inspect/checks-authoring.mjs`, because doctor's authoring gates are what validate them. P6c moved the
37
- * four to `js/inspect/inventory.mjs` — where Python keeps them — rather than copying them, and
38
- * doctor imports them back. Only `parse_laws`, `load_registry` and `entity_status` were
39
- * genuinely new.
40
- *
41
- * THE SNAPSHOT REPORTS NO INTERPRETER VERSION, and the absence is deliberate. The server
42
- * this one replaced ran on an interpreter and published its version under a key named for
43
- * it; nothing here answers that question, and answering it with this runtime's version
44
- * under that name would have been a lie the UI printed. So the field is gone rather than
45
- * present-and-empty. The recorded web corpus was taken from the old server and still
46
- * carries the key, which is why its replay harness erases it before comparing.
47
- *
48
- * P6c's ROUTES ARE THE FIRST THAT PARSE A PATH, which brings three things no earlier
49
- * endpoint could reach: `percentDecode` (NOT `decodeURIComponent`, which throws where the
50
- * reference leaves a stray `%` alone), the `NotFound` → 404 convention, and `flatName`'s
51
- * traversal refusal — a GET carries no token, so before that check the item route was an
52
- * arbitrary-file read.
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.
53
11
  */
54
12
  import { createHash } from 'node:crypto';
55
13
  import { existsSync, readdirSync, statSync } from 'node:fs';
@@ -73,35 +31,30 @@ import {
73
31
  import { firstBlockquote } from '../hosts/native.mjs';
74
32
  import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
75
33
  import { accentFor, statusData } from '../inspect/status.mjs';
76
- import { readText, withDiscardableStderr } from '../lib/fs.mjs';
34
+ import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
77
35
  import { comparePaths, normcase, within } from '../lib/paths.mjs';
78
36
  import { stripWhitespace, percentDecode } from '../lib/text.mjs';
79
37
  import { readJsonc } from '../hosts/settings.mjs';
80
38
  import { apiActivity, apiActivityDetail } from './activity.mjs';
81
39
  import { apiMcp, apiRules } from './actions.mjs';
82
- import { apiGraph } from './graph.mjs';
83
40
 
84
- /** `_web_core.NotFound` — a requested catalog section or item that does not exist. */
41
+ /** A requested catalog section or item that does not exist. */
85
42
  export class NotFound extends Error {}
86
43
 
87
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
88
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
89
-
90
- /** Two resolved paths, compared as `PurePath.__eq__` does — case-folded on Windows. */
44
+ /** Two resolved paths, compared case-folded on Windows. */
91
45
  const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
92
46
 
93
- /** `dict.get(key, default)` — `??` would also swallow an explicit null the file declared. */
47
+ /** `??` would also swallow an explicit `null` the file declared; this does not. */
94
48
  const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
95
49
 
96
- /** `hashlib.sha256(text.encode()).hexdigest()[:16]`, and "" for an empty file. */
50
+ /** SHA-256 hex, first 16 chars — `""` for an empty file. */
97
51
  export function fingerprint(text) {
98
52
  return text ? createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 16) : '';
99
53
  }
100
54
 
101
55
  /**
102
- * `datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")` — LOCAL time, as the reference's
103
- * naive `fromtimestamp` is. `toISOString` would be UTC and would read as a port bug in
104
- * every timezone but one.
56
+ * `YYYY-MM-DD HH:MM`, LOCAL time — `toISOString` would be UTC, which reads wrong in every
57
+ * timezone but one for a user-facing timestamp.
105
58
  */
106
59
  export function stampMinute(ms) {
107
60
  const d = new Date(ms);
@@ -113,19 +66,19 @@ export function stampMinute(ms) {
113
66
  // ---- WebState ---------------------------------------------------------------------------
114
67
 
115
68
  /**
116
- * `_web_core.WebState` — the resolved view of the deployed harness a request reads from.
69
+ * The resolved view of the deployed harness a request reads from.
117
70
  *
118
71
  * A factory returning a plain object rather than a class, because the two cached
119
72
  * properties are the only behaviour and a getter pair expresses them without ceremony.
120
73
  * THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
121
74
  * and the dashboard GETs `/api/overview` on every navigation, so an uncached `doctor`
122
75
  * would put a full build on the request path. `refresh()` is what a mutation calls to
123
- * drop both (P6f's).
76
+ * drop both.
124
77
  */
125
78
  export function webState(theme = null, target = null) {
126
79
  const st = {
127
- // `Path(target) if target else _opencode_config_dir()` — NOT resolved. `target` is
128
- // printed by three endpoints, so resolving it here would change what they answer.
80
+ // NOT resolved: `target` is printed by three endpoints, so resolving it here would
81
+ // change what they answer.
129
82
  target: target || opencodeConfigDir(),
130
83
  root: null,
131
84
  theme: null,
@@ -164,14 +117,12 @@ export function webState(theme = null, target = null) {
164
117
  st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
165
118
  };
166
119
  /**
167
- * `WebState._detect_emit` — the CURRENT install's mode, from its `.geneseed-emit` marker:
168
- * the ROOT first (where every emit writes it), then the data dir.
120
+ * The CURRENT install's mode, from its `.geneseed-emit` marker: the ROOT first (where
121
+ * every emit writes it), then the data dir.
169
122
  *
170
- * P6b ported `refresh()` WITHOUT this, because nothing called `refresh()` yet and the
171
- * constructor reads `installedDefaults()` instead. P6f is the phase with mutations, so
172
- * `refresh()` finally runs — and the reference's `self.emit = self._detect_emit() or
173
- * self.emit` line was missing here, which would have left `emit` stale after any write
174
- * that re-themed or re-pointed the install. Restored with its caller.
123
+ * `refresh()` below MUST call this: without it, `emit` goes stale after any write that
124
+ * re-themes or re-points the install, since the constructor only reads it once via
125
+ * `installedDefaults()`.
175
126
  */
176
127
  st.detectEmit = () => {
177
128
  for (const d of [st.root, st.target]) {
@@ -181,12 +132,12 @@ export function webState(theme = null, target = null) {
181
132
  const v = stripWhitespace(readText(em));
182
133
  if (v) return v;
183
134
  }
184
- } catch { /* as the Python's `except OSError: pass` */ }
135
+ } catch { /* unreadable marker: try the next candidate */ }
185
136
  }
186
137
  return existsSync(path.join(st.root, 'CLAUDE.md')) ? 'claude-global' : 'opencode-global';
187
138
  };
188
139
  /**
189
- * `WebState.select_view` — re-point the console at another detected install's data dir.
140
+ * Re-point the console at another detected install's data dir.
190
141
  *
191
142
  * `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
192
143
  * differs only for claude/bob/copilot PROJECT installs, where the data sits under
@@ -217,13 +168,12 @@ export function webState(theme = null, target = null) {
217
168
  return st;
218
169
  }
219
170
 
220
- /** `_web_core._deployed`. */
221
171
  export function deployed(state) {
222
172
  return existsSync(path.join(state.target, GLOBAL_MANIFEST));
223
173
  }
224
174
 
225
175
  /**
226
- * `_web_core._spec_desc` — a deployed spec's one-line purpose.
176
+ * A deployed spec's one-line purpose.
227
177
  *
228
178
  * The `> blockquote` convention every rendered skill and agent carries, then the
229
179
  * frontmatter `description`, then the first prose paragraph. The fallbacks are for the
@@ -243,13 +193,13 @@ export function specDesc(fm, body) {
243
193
  }
244
194
 
245
195
  /**
246
- * `_web_core._spec_entries` — agent/skill specs read straight off a DEPLOYED harness dir.
196
+ * Agent/skill specs read straight off a DEPLOYED harness dir.
247
197
  *
248
198
  * Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
249
- * folder layout `<root>/<name>/SKILL.md`. `glob("*.md")` is case-INSENSITIVE on Windows,
250
- * as pathlib's is — `normcase` rather than a bare `endsWith`. The frontmatter is stripped
251
- * because it is host plumbing rather than prose, which is what makes a deployed entry the
252
- * same shape as a source-rendered one and every consumer indifferent to the origin.
199
+ * folder layout `<root>/<name>/SKILL.md`. Matched case-INSENSITIVE on Windows via
200
+ * `normcase`, not a bare `endsWith`. The frontmatter is stripped because it is host
201
+ * plumbing rather than prose, which is what makes a deployed entry the same shape as a
202
+ * source-rendered one and every consumer indifferent to the origin.
253
203
  */
254
204
  export function specEntries(root, nested) {
255
205
  const out = [];
@@ -272,8 +222,7 @@ export function specEntries(root, nested) {
272
222
  }
273
223
 
274
224
  /**
275
- * `_web_core._deployed_inventory` — the agents and skills actually installed at
276
- * `state.target`, not a fresh render of `src/`.
225
+ * The agents and skills actually installed at `state.target`, not a fresh render of `src/`.
277
226
  *
278
227
  * All three constitutional tiers still come from the render: once deployed they live inside
279
228
  * AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
@@ -309,28 +258,25 @@ export function deployedInventory(state) {
309
258
  theme: state.theme };
310
259
  }
311
260
 
312
- /** `WebState.inventory` — the deployed record when there is one, else the source render. */
261
+ /** The deployed record when there is one, else the source render. */
313
262
  export function inventoryFor(state) {
314
263
  return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
315
264
  }
316
265
 
317
266
  // ---- the catalog stores overview counts ---------------------------------------------------
318
267
 
319
- /** `_web_catalog._memory_dir` — always `<target>/memory`, never the CWD-scanning resolver. */
268
+ /** Always `<target>/memory` — never the CWD-scanning resolver. */
320
269
  const memoryDir = (state) => path.join(state.target, 'memory');
321
270
  const notebookDir = (state) => path.join(state.target, 'notebook');
322
271
 
323
272
  /**
324
- * `sorted(pathlib.Path.glob("*.md"))` — matched, case-insensitive on Windows.
273
+ * Matched case-insensitively on Windows.
325
274
  *
326
- * `comparePaths` AND NOT A BARE `.sort()`, and this was a LIVE BUG until P6f's memory cells
327
- * put an uppercase filename in the directory. `sorted()` over `Path` objects compares
328
- * `_str_normcase`, so on Windows `MEMORY.md` sorts under `m` and lands after `a-fact.md`;
329
- * JS's default comparator is UTF-16 code units, so `M` (0x4D) sorts before `a` (0x61) and
330
- * the whole catalog came back in a different order. Every seeded memory and notebook file in
331
- * P6b and P6c was lower-case, which is why nothing said so for three phases. The `normcase`
332
- * in the filter beside it was already here for exactly this reason — the sort was the half
333
- * that got missed.
275
+ * `comparePaths` and NOT a bare `.sort()`: on Windows, filenames sort case-insensitively, so
276
+ * `MEMORY.md` sorts under `m` and lands after `a-fact.md`; JS's default comparator is UTF-16
277
+ * code units, so `M` (0x4D) sorts before `a` (0x61) and the whole catalog comes back in a
278
+ * different order. The `normcase` in the filter beside it only fixes matching, not
279
+ * ordering — this is the other half.
334
280
  */
335
281
  function globMd(dir) {
336
282
  if (!isDir(dir)) return [];
@@ -340,9 +286,9 @@ function globMd(dir) {
340
286
  .sort(comparePaths);
341
287
  }
342
288
 
343
- const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
289
+ /** Filename without its extension — single owner, imported by `js/web/activity.mjs` too. */
290
+ export const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
344
291
 
345
- /** `_web_catalog._memory_items`. */
346
292
  export function memoryItems(state) {
347
293
  const d = memoryDir(state);
348
294
  if (!isDir(d)) return [];
@@ -359,7 +305,6 @@ export function memoryItems(state) {
359
305
  });
360
306
  }
361
307
 
362
- /** `_web_catalog._notebook_items`. */
363
308
  export function notebookItems(state) {
364
309
  const d = notebookDir(state);
365
310
  if (!isDir(d)) return [];
@@ -368,13 +313,12 @@ export function notebookItems(state) {
368
313
  }));
369
314
  }
370
315
 
371
- /** `_web_catalog._CONFIG_META` — the two setup manifests, in listing order. */
316
+ /** The two setup manifests, in listing order. */
372
317
  const CONFIG_META = {
373
318
  'context.json': ['Project context', 'what the agent loads for this project'],
374
319
  'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
375
320
  };
376
321
 
377
- /** `_web_catalog._config_items`. */
378
322
  export function configItems(state) {
379
323
  const out = [];
380
324
  for (const fname of ['context.json', 'wiki.jsonc']) {
@@ -395,23 +339,18 @@ export const WIKI_FILE_CAP = 5000;
395
339
  * The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
396
340
  * request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
397
341
  * (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
398
- * wraps the whole GET in a blanket `catch` → a JSON
399
- * 500, so an unguarded refusal would turn ONE bad line in a file the user hand-edits into a
400
- * dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hosts/hooks.mjs`
401
- * — contain the refusal per entry and take the site's OWN degrade — and each of the three
402
- * already has one: `[]`, `continue`, `continue`-into-404. The reference reaches those same
403
- * three degrades for a `~user` path, from the other direction: `ntpath.expanduser` expands
404
- * `~nosuchuser` to `C:\Users\nosuchuser\…`, which does not exist, so `is_file()`/`is_dir()`
405
- * answer False. `_wiki_path` in `_web_catalog.py` is the guard that makes POSIX agree, where
406
- * the same call raises RuntimeError instead.
342
+ * wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
343
+ * ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
344
+ * argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
345
+ * and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
346
+ * `continue`-into-404.
407
347
  *
408
- * `withDiscardableStderr` because `expanduser` writes its refusal at the RAISE SITE and this
409
- * caller catches — the reference prints nothing here, and `<server stderr>` is compared as
410
- * bytes by `tests/web_golden.py`, so replaying it would be a divergence on every cell.
348
+ * `withDiscardableStderr` because `expanduser` prints its refusal at the RAISE SITE, and
349
+ * this caller wants that swallowed along with the exception rather than logged.
411
350
  *
412
- * DELIBERATE, ADJUDICATED RESIDUE: if `C:\Users\<other-user>\…` really exists, the reference
413
- * on Windows READS ANOTHER ACCOUNT'S FILE where this refuses. Refusing is the settled product
414
- * decision — the reference is the side that is wrong — and the reference is being deleted.
351
+ * DELIBERATE PRODUCT DECISION: a `~otherUser` path is refused rather than resolved, even
352
+ * where the account happens to exist — reading another account's files through a
353
+ * hand-edited wiki manifest is not a feature.
415
354
  */
416
355
  function wikiPath(p) {
417
356
  try {
@@ -422,12 +361,12 @@ function wikiPath(p) {
422
361
  }
423
362
 
424
363
  /**
425
- * `_web_catalog._wiki_manifest` — `$GENESEED_WIKI` first, else `wiki.jsonc` beside the
426
- * deployed bundle, read with the harness's generic JSONC loader.
364
+ * `$GENESEED_WIKI` first, else `wiki.jsonc` beside the deployed bundle, read with the
365
+ * harness's generic JSONC loader.
427
366
  *
428
- * `resolvePath` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
429
- * consumed by `isFile` and `mcpLoad` and never reaches a response body, and both of those
430
- * follow symlinks and resolve a relative path against the same cwd anyway.
367
+ * `resolvePath`, not merely `expanduser`: harmless here because `p` is consumed by `isFile`
368
+ * and `mcpLoad` and never reaches a response body, and both of those follow symlinks and
369
+ * resolve a relative path against the same cwd anyway.
431
370
  */
432
371
  function wikiManifest(state) {
433
372
  const cand = process.env.GENESEED_WIKI;
@@ -439,16 +378,14 @@ function wikiManifest(state) {
439
378
  }
440
379
 
441
380
  /**
442
- * `harness._mcp_load(path)` with no host — the COMMENT-TOLERANT dict loader, `{}` for a
443
- * file that is missing, unreadable, unparseable, or not an object.
381
+ * The COMMENT-TOLERANT dict loader, `{}` for a file that is missing, unreadable,
382
+ * unparseable, or not an object.
444
383
  *
445
- * Comment-tolerant is the whole point and the first draft of this got it wrong. The two
446
- * files it reads are `wiki.jsonc` and `context.json`, and the first is `.jsonc` because it
447
- * is HAND-MAINTAINED: a `//` in it parses on the reference and would have made
448
- * `JSON.parse` return null here, so the wiki section would have silently listed nothing
449
- * and the config item's `manifest` would have come back empty. The seeded manifests in
450
- * `tests/web_golden.py` are plain JSON, so no cell had one — `catalog/…-tolerates-comments`
451
- * is the cell that does now.
384
+ * Comment-tolerant is the whole point, and getting this wrong is silent: the two files it
385
+ * reads are `wiki.jsonc` and `context.json`, hand-maintained, so a `//` line is expected.
386
+ * Plain `JSON.parse` would throw on it and this loader would then answer `{}` — the wiki
387
+ * section listing nothing and the config item's `manifest` coming back empty, with no
388
+ * error surfaced anywhere.
452
389
  */
453
390
  function mcpLoad(p) {
454
391
  if (!isFile(p)) return {};
@@ -458,29 +395,28 @@ function mcpLoad(p) {
458
395
  return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
459
396
  }
460
397
 
461
- /** Every `.md` under `dir`, recursively, POSIX-relative to it and sorted — `rglob("*.md")`. */
462
- function rglobMd(dir, base = dir, out = []) {
398
+ /**
399
+ * Every `.md` under `dir`, recursively.
400
+ *
401
+ * `recursive: true` replaces a hand-rolled walk; its one caller re-sorts the result with
402
+ * `comparePaths` before using it, so the order this returns is never observed.
403
+ */
404
+ function rglobMd(dir) {
463
405
  let entries;
464
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
465
- for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
466
- const full = path.join(dir, e.name);
467
- if (e.isDirectory()) rglobMd(full, base, out);
468
- else if (normcase(e.name).endsWith('.md')) out.push(full);
469
- }
470
- return out;
406
+ try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; }
407
+ return entries
408
+ .filter((e) => e.isFile() && normcase(e.name).endsWith('.md'))
409
+ .map((e) => path.join(e.parentPath, e.name));
471
410
  }
472
411
 
473
- /** `_web_catalog._wiki_items`. */
474
412
  export function wikiItems(state) {
475
413
  const items = [];
476
414
  const seen = new Set();
477
415
  for (const w of wikiManifest(state)) {
478
416
  if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
479
417
  const wname = String(dget(w, 'name', null) || 'wiki');
480
- // PER ENTRY, and the loop continues — one unusable `path` in the manifest must not blank
481
- // every OTHER vault in it. `resolvePath` where the reference only `expanduser()`s is again
482
- // unobservable: `root` is only ever joined against or used as the base of a `relative()`
483
- // whose other operand was built FROM it, and `source` is `.resolve()`d on both sides.
418
+ // PER ENTRY, and the loop continues — one unusable `path` in the manifest must not
419
+ // blank every OTHER vault in it.
484
420
  const root = wikiPath(String(dget(w, 'path', null) || ''));
485
421
  if (!root || !isDir(root)) continue;
486
422
  const entries = (dget(w, 'entries', null) || [])
@@ -496,7 +432,7 @@ export function wikiItems(state) {
496
432
  const fp = path.join(root, rel);
497
433
  let mds;
498
434
  if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
499
- // `sorted(fp.rglob("*.md"))[:cap]` — `comparePaths`, for the reason `globMd` carries.
435
+ // Capped and sorted via `comparePaths`, for the reason `globMd` carries.
500
436
  else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
501
437
  else continue;
502
438
  for (const md of mds) {
@@ -513,7 +449,7 @@ export function wikiItems(state) {
513
449
  return items;
514
450
  }
515
451
 
516
- /** `_web_catalog.api_wiki_item` — one page by `<wiki>:<relpath>`, never outside the vault. */
452
+ /** One page by `<wiki>:<relpath>`, never outside the vault. */
517
453
  export function apiWikiItem(state, name) {
518
454
  const at = name.indexOf(':');
519
455
  const wname = at < 0 ? name : name.slice(0, at);
@@ -522,9 +458,8 @@ export function apiWikiItem(state, name) {
522
458
  for (const w of wikiManifest(state)) {
523
459
  if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
524
460
  if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
525
- // The reference is `.expanduser().resolve()` here, so this site matches on resolve and
526
- // differs only on the refusal — which falls through to the `NotFound` below, exactly
527
- // where the reference lands when the expanded root turns out not to exist.
461
+ // An unresolvable or refused root falls through to the `NotFound` below, the same as
462
+ // one that simply does not exist.
528
463
  const root = wikiPath(String(dget(w, 'path', null) || ''));
529
464
  if (!root) continue;
530
465
  const p = resolvePath(path.join(root, rel));
@@ -538,19 +473,18 @@ export function apiWikiItem(state, name) {
538
473
  }
539
474
 
540
475
  /**
541
- * `harness._within` — re-exported from `js/lib/paths.mjs`, where the lib split put it.
476
+ * Re-exported from `js/lib/paths.mjs`, where the lib split put it.
542
477
  *
543
478
  * ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
544
479
  * walks. A change to the containment rule has to land on both, and nothing compares them.
545
480
  *
546
- * It lived here from P6b (the restore's containment check) until `_install_move_list` needed
547
- * the same predicate from `js/maintain/uninstall.mjs`, which must not import the web tree. The
548
- * re-export keeps `js/web/actions.mjs`'s import path unchanged.
481
+ * Re-exported here (rather than imported directly by callers) so `js/web/actions.mjs`'s
482
+ * import path stays unchanged.
549
483
  */
550
484
  export { within };
551
485
 
552
486
  /**
553
- * `_web_catalog._flat_name` — catalog names are flat basenames.
487
+ * Catalog names are flat basenames.
554
488
  *
555
489
  * A separator, a `..` or a drive colon in the URL segment is someone steering the join
556
490
  * outside the catalog dir. A GET carries no token, so before this check the endpoint was
@@ -563,16 +497,13 @@ function flatName(name) {
563
497
  }
564
498
  }
565
499
 
566
- /** `_web_core.WIKILINK_RE`. Exported since P6e — `js/web/graph.mjs` is its second reader. */
567
500
  export const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
568
501
 
569
502
  /**
570
- * `_web_catalog._resolve_links` — `[[name]]` matched against known agent and skill names.
503
+ * `[[name]]` matched against known agent and skill names.
571
504
  *
572
505
  * AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
573
- * That is not a preference, it is what the reference's two loops do in that order, and
574
- * `item/an-agent-carries-its-body-and-its-resolved-links` is the cell that would catch the
575
- * other order.
506
+ * This ordering is deliberate and must not be swapped.
576
507
  */
577
508
  export function resolveLinks(state, body) {
578
509
  const inv = state.inventory;
@@ -582,8 +513,8 @@ export function resolveLinks(state, body) {
582
513
  const links = [];
583
514
  const seen = new Set();
584
515
  for (const m of body.matchAll(WIKILINK_RE)) {
585
- // `stripWhitespace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
586
- // gave this regex a second reader, so the two call sites answer with one rule.
516
+ // `stripWhitespace`, not `trim()` — the two whitespace classes differ, and both call
517
+ // sites on this regex must answer with the same rule.
587
518
  const label = stripWhitespace(m[1]);
588
519
  if (known.has(label) && !seen.has(label)) {
589
520
  seen.add(label);
@@ -594,8 +525,8 @@ export function resolveLinks(state, body) {
594
525
  }
595
526
 
596
527
  /**
597
- * `_web_catalog._config_manifest` — a setup file parsed into the shape the detail pane
598
- * renders as cards, or `null` so the caller falls back to the raw body.
528
+ * A setup file parsed into the shape the detail pane renders as cards, or `null` so the
529
+ * caller falls back to the raw body.
599
530
  */
600
531
  function configManifest(name, p) {
601
532
  const cfg = mcpLoad(p);
@@ -611,7 +542,7 @@ function configManifest(name, p) {
611
542
  }
612
543
 
613
544
  /**
614
- * `_web_core.SECTIONS` — a closed list; anything else is a 404.
545
+ * A closed list; anything else is a 404.
615
546
  *
616
547
  * ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
617
548
  * oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
@@ -659,7 +590,6 @@ function constitutionItems(inv) {
659
590
  ];
660
591
  }
661
592
 
662
- /** `_web_catalog.api_catalog`. */
663
593
  export function apiCatalog(state, section) {
664
594
  if (!SECTIONS.includes(section)) throw new NotFound(section);
665
595
  const inv = state.inventory;
@@ -684,7 +614,6 @@ export function apiCatalog(state, section) {
684
614
  return { section, items };
685
615
  }
686
616
 
687
- /** `_web_catalog.api_item`. */
688
617
  export function apiItem(state, type, name) {
689
618
  const inv = state.inventory;
690
619
  if (type === 'agent' || type === 'skill') {
@@ -737,7 +666,7 @@ export function apiItem(state, type, name) {
737
666
 
738
667
  // ---- the eight endpoints -------------------------------------------------------------
739
668
 
740
- /** `_web_actions._theme_choices`. Exported since P6g — `_build_override` is its third reader. */
669
+ /** `buildOverride` in `actions.mjs` is a third reader of this. */
741
670
  export function themeChoices() {
742
671
  return themeOptions().map(([name, blurb]) => {
743
672
  const data = readJsonMaybe(path.join(THEMES, `${name}.json`));
@@ -747,16 +676,19 @@ export function themeChoices() {
747
676
  });
748
677
  }
749
678
 
750
- /** `_web_actions._emit_choices`. */
751
679
  export const emitChoices = () => EMIT_OPTIONS.map(([name, desc]) => ({ name, desc }));
752
680
 
753
- /** `_web_actions.api_themes`. */
754
681
  export function apiThemes(state) {
755
682
  return { themes: themeChoices(), emits: emitChoices(),
756
683
  current: { theme: state.theme, emit: state.emit } };
757
684
  }
758
685
 
759
- /** `_web_actions.api_setup`. */
686
+ /**
687
+ * No interpreter/runtime version field, deliberately: this server has no interpreter
688
+ * version worth reporting under that name, and reporting this runtime's own version there
689
+ * would misrepresent what actually changed. The field is absent rather than
690
+ * present-and-empty.
691
+ */
760
692
  export function apiSetup(state) {
761
693
  return {
762
694
  ...statusData(),
@@ -766,7 +698,7 @@ export function apiSetup(state) {
766
698
  };
767
699
  }
768
700
 
769
- /** `_web_actions.api_doctor` — the same engine as the `doctor` verb, grouped per check. */
701
+ /** The same engine as the `doctor` verb, grouped per check. */
770
702
  export function apiDoctor(state) {
771
703
  const groups = [];
772
704
  const [themes, problems] = doctorCollect({ theme: state.theme, groups });
@@ -775,19 +707,18 @@ export function apiDoctor(state) {
775
707
  checked_at: state.doctor.checked_at };
776
708
  }
777
709
 
778
- /** `_web_actions.api_diff`. */
779
710
  export function apiDiff(state) {
780
711
  const { target, theme, files } = diffCollect({
781
712
  target: state.target, theme: state.theme, emit: state.emit });
782
713
  return { deployed: files !== null, target, theme, files: files || [] };
783
714
  }
784
715
 
785
- /** `_web_actions.api_excludes` — the union across every global install, verbatim. */
716
+ /** The union across every global install, verbatim. */
786
717
  export const apiExcludes = () => excludesSnapshot();
787
718
 
788
719
  /**
789
- * `_web_actions._view_cfg` — the data dir an install's inventory/memory/diff is read from.
790
- * Host-driven, so a new nested-marker host cannot silently read the bare root.
720
+ * The data dir an install's inventory/memory/diff is read from. Host-driven, so a new
721
+ * nested-marker host cannot silently read the bare root.
791
722
  */
792
723
  export function viewCfg(host, scope, root) {
793
724
  if (scope === 'project' && ['claude', 'bob', 'copilot'].includes(host)) {
@@ -796,7 +727,6 @@ export function viewCfg(host, scope, root) {
796
727
  return root;
797
728
  }
798
729
 
799
- /** `_web_actions.api_installs`. */
800
730
  export function apiInstalls(state) {
801
731
  const out = [];
802
732
  for (const [host, scope, root] of installTargets()) {
@@ -814,11 +744,10 @@ export function apiInstalls(state) {
814
744
  modes: discoverNames('modes', 'direct') };
815
745
  }
816
746
 
817
- /** `_web_catalog._profile_path` — `build.PROFILE_FILE` beside the deployed AGENT.md. */
747
+ /** Beside the deployed AGENT.md. */
818
748
  export const PROFILE_FILE = 'PROFILE.md';
819
749
  export const profilePath = (state) => path.join(state.target, PROFILE_FILE);
820
750
 
821
- /** `_web_catalog.api_profile`. */
822
751
  export function apiProfile(state) {
823
752
  const p = profilePath(state);
824
753
  if (!isFile(p)) return { exists: false, path: p, text: '', fingerprint: '' };
@@ -826,7 +755,7 @@ export function apiProfile(state) {
826
755
  return { exists: true, path: p, text, fingerprint: fingerprint(text) };
827
756
  }
828
757
 
829
- /** `_web_overview.api_overview` — the dashboard aggregate. */
758
+ /** The dashboard aggregate. */
830
759
  export function apiOverview(state) {
831
760
  const inv = state.inventory;
832
761
  let diff = null;
@@ -855,8 +784,7 @@ export function apiOverview(state) {
855
784
  buildEpoch = Math.floor(ms / 1000);
856
785
  }
857
786
  // Which detected install the current view points at, so the dashboard's footprint hero
858
- // can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out as the reference spells
859
- // it out — the two are separate code in the Python too.
787
+ // can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out separately here.
860
788
  let install = null;
861
789
  for (const [host, scope, root] of installTargets()) {
862
790
  const data = (scope === 'project' && ['claude', 'bob', 'copilot'].includes(host))
@@ -866,7 +794,7 @@ export function apiOverview(state) {
866
794
  install = { host, scope, path: root, footprint: footprintOfDir(root) };
867
795
  break;
868
796
  }
869
- } catch { /* as the Python's `except OSError: pass` */ }
797
+ } catch { /* an unreadable candidate just isn't a match */ }
870
798
  }
871
799
  return {
872
800
  theme: state.theme,
@@ -944,9 +872,9 @@ const RECENT_LIMIT = 8;
944
872
  /**
945
873
  * THE NEWEST FILE-BACKED ENTRIES ACROSS THE HARNESS — the "freshly grown" card's whole payload.
946
874
  *
947
- * A GET BEYOND THE REFERENCE (`GET_BEYOND_REF` in `routes.mjs`), and the first one. It exists
948
- * because NOTHING else in this API carries a per-entry date: every catalog row is
949
- * `{name, title, desc, source}` and the only two timestamps on the whole surface are
875
+ * The first GET added after the Python→Node port froze its reference surface. It exists
876
+ * because NOTHING else in this API carries a per-entry date: every
877
+ * catalog row is `{name, title, desc, source}` and the only two timestamps on the whole surface are
950
878
  * `overview.build_time` and `doctor.checked_at`. A client cannot stat a path, so "what changed
951
879
  * lately" was unanswerable without a server that looks.
952
880
  *
@@ -1001,57 +929,34 @@ function releaseLabel() {
1001
929
  }
1002
930
 
1003
931
  /**
1004
- * `_web_server.Handler.STATE_ROUTES`, the ported half.
1005
- *
1006
- * A literal path to `api_X(state)`, exactly as the reference's table is — and the reason
1007
- * it is a table on this side too is `tests/unit/web_server.test.mjs`'s cross-check, which
1008
- * requires this table plus `PREFIX_ROUTES`, `PORTED_INLINE` and `NOT_PORTED` to equal
1009
- * `REF_GET` — a FROZEN LITERAL of what the reference daemon answered, since the source it
1010
- * was once read from with `ast` is deleted. On the second instance of anything, the gate
1011
- * becomes a table cross-checked against a declaration.
1012
- */
1013
- /**
1014
- * `_web_server.do_GET`'s prefix routes, the ported ones — path in, response out.
932
+ * The prefix routes — path in, response out.
1015
933
  *
1016
934
  * `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
1017
- * that is not an escape, which the shell would answer as a 500 where the reference answers
1018
- * a 404 naming the literal text.
935
+ * that is not an escape, where this shell instead answers a 404 naming the literal text.
1019
936
  */
1020
937
  export const PREFIX_ROUTES = [
1021
938
  ['/api/catalog/', (state, p) => apiCatalog(state, p.split('/').pop())],
1022
- // `path.split("/", 4)` — /api/item/<type>/<name>, and the NAME keeps its slashes so a
1023
- // wiki page's relpath survives. A missing name is a 404 here rather than an IndexError
1024
- // and a 500 two frames down.
939
+ // /api/item/<type>/<name> — TYPE has no slash, the NAME keeps its slashes so a wiki
940
+ // page's relpath survives. A missing name is a 404 here rather than a 500 two frames down.
1025
941
  ['/api/item/', (state, p) => {
1026
- const parts = splitN(p, '/', 4);
1027
- if (parts.length < 5 || !parts[4]) throw new NotFound(p);
1028
- return apiItem(state, parts[3], percentDecode(parts[4]));
942
+ const m = /^\/api\/item\/([^/]+)\/(.+)$/.exec(p);
943
+ if (!m) throw new NotFound(p);
944
+ return apiItem(state, m[1], percentDecode(m[2]));
1029
945
  }],
1030
- // P6e. The reference's own `path[len("/api/activity/"):]`, unquoted — the sid keeps
1031
- // whatever it carries and `api_activity_detail`'s safe-name scheme is what makes the
1032
- // lookup safe, rather than a check out here.
946
+ // The sid is passed through unquoted — `apiActivityDetail`'s safe-name scheme is what
947
+ // makes the lookup safe, rather than a check out here.
1033
948
  ['/api/activity/', (state, p) => apiActivityDetail(
1034
949
  state, percentDecode(p.slice('/api/activity/'.length)))],
1035
950
  ];
1036
951
 
1037
- /** `str.split(sep, maxsplit)` — at most `n` splits, the remainder kept whole. */
1038
- function splitN(s, sep, n) {
1039
- const out = [];
1040
- let rest = s;
1041
- for (let i = 0; i < n; i += 1) {
1042
- const at = rest.indexOf(sep);
1043
- if (at < 0) break;
1044
- out.push(rest.slice(0, at));
1045
- rest = rest.slice(at + sep.length);
1046
- }
1047
- out.push(rest);
1048
- return out;
1049
- }
1050
-
952
+ /**
953
+ * A literal path to `api_X(state)`, table-keyed rather than a chain of `if`s — the reason
954
+ * it is a table is `tests/unit/web_server.test.mjs`'s cross-check, which requires this
955
+ * table plus `PREFIX_ROUTES` and `GET_INLINE` to equal a surface list written out in the
956
+ * test, so a route cannot appear or vanish unenumerated.
957
+ */
1051
958
  export const STATE_ROUTES = {
1052
959
  '/api/overview': apiOverview,
1053
- // Beyond the reference — declared in `GET_BEYOND_REF`, which is what keeps the partition
1054
- // gate's set equality honest instead of forging `REF_GET`.
1055
960
  '/api/recent': apiRecent,
1056
961
  '/api/themes': apiThemes,
1057
962
  '/api/setup': apiSetup,
@@ -1060,11 +965,10 @@ export const STATE_ROUTES = {
1060
965
  '/api/excludes': apiExcludes,
1061
966
  '/api/profile': apiProfile,
1062
967
  '/api/diff': apiDiff,
1063
- '/api/graph': apiGraph,
1064
968
  '/api/activity': apiActivity,
1065
- // P6f. `/api/rules` had to cross as a PAIR: `api_rules_mutate` splices `parse_rules`' line
1066
- // indices, and the fingerprint every mutation must send back is what this GET answers —
1067
- // so a Node daemon whose POST worked and whose GET 501'd would have no way to obtain one.
969
+ // `/api/rules` must exist as a PAIR with its POST: `apiRulesMutate` splices `parseRules`'
970
+ // line indices, and the fingerprint every mutation must send back is what this GET
971
+ // answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
1068
972
  '/api/rules': apiRules,
1069
973
  '/api/mcp': apiMcp,
1070
974
  };