geneseed 3.1.1 → 3.1.2

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 (98) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/harness.config.json +1 -1
  12. package/js/README.md +203 -0
  13. package/js/build/bundle.mjs +231 -0
  14. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  15. package/js/build/emit-claude.mjs +287 -0
  16. package/js/build/emit-common.mjs +255 -0
  17. package/js/build/emit-opencode.mjs +214 -0
  18. package/js/{generate.mjs → build/generate.mjs} +27 -28
  19. package/js/{render.mjs → build/render.mjs} +13 -9
  20. package/js/{checkout.mjs → build/source.mjs} +9 -9
  21. package/js/build/stubs.mjs +256 -0
  22. package/js/{themes.mjs → build/themes.mjs} +24 -24
  23. package/js/build/version.mjs +155 -0
  24. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  25. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  26. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  27. package/js/{link.mjs → hosts/link.mjs} +29 -28
  28. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  29. package/js/{native.mjs → hosts/native.mjs} +24 -22
  30. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  31. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  32. package/js/inspect/checks-authoring.mjs +761 -0
  33. package/js/inspect/checks-build.mjs +231 -0
  34. package/js/inspect/checks-repo.mjs +308 -0
  35. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  36. package/js/inspect/doctor.mjs +286 -0
  37. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  38. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  39. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  40. package/js/inspect/scan.mjs +125 -0
  41. package/js/{status.mjs → inspect/status.mjs} +27 -25
  42. package/js/inspect/validate.mjs +199 -0
  43. package/js/lib/fs.mjs +37 -759
  44. package/js/lib/json.mjs +344 -0
  45. package/js/lib/paths.mjs +242 -0
  46. package/js/lib/proc.mjs +3 -3
  47. package/js/lib/text.mjs +181 -0
  48. package/js/lib/udiff.mjs +1 -1
  49. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  50. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  51. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  52. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  53. package/js/{update.mjs → maintain/update.mjs} +51 -51
  54. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  55. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  56. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  57. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  58. package/js/web/actions.mjs +84 -85
  59. package/js/web/activity.mjs +22 -21
  60. package/js/web/api.mjs +72 -50
  61. package/js/web/daemon.mjs +319 -0
  62. package/js/web/docs.mjs +24 -21
  63. package/js/web/graph.mjs +0 -0
  64. package/js/web/handler.mjs +442 -0
  65. package/js/web/jobs.mjs +21 -19
  66. package/js/web/routes.mjs +223 -0
  67. package/js/web/server.mjs +56 -987
  68. package/package.json +11 -3
  69. package/src/doctrines/README.md +1 -1
  70. package/themes/opencode/README.md +1 -1
  71. package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
  72. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
  73. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
  74. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
  75. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
  76. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
  77. package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
  78. package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
  79. package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
  80. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
  81. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
  82. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
  83. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
  84. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
  85. package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
  86. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
  87. package/web/dist/assets/index-BE226Gv6.css +1 -0
  88. package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
  89. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  90. package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
  91. package/web/dist/index.html +2 -2
  92. package/web/src/pages/Laws.jsx +1 -1
  93. package/docs/declined.md +0 -200
  94. package/docs/limits.md +0 -329
  95. package/js/doctor.mjs +0 -1750
  96. package/js/emit.mjs +0 -1411
  97. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  98. package/web/dist/assets/index-CH_Z725C.js +0 -12
package/js/web/api.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * `apiOverview` compute anything of their own, and most of what `apiOverview` computes is
15
15
  * counting.
16
16
  *
17
- * THE ONE PARAMETER: `doctorCollect({groups})`. `js/doctor.mjs` carried a `ran(check,
17
+ * THE ONE PARAMETER: `doctorCollect({groups})`. `js/inspect/doctor.mjs` carried a `ran(check,
18
18
  * label, probs)` that returned its third argument and threw the first two away, with a
19
19
  * docblock saying the label stays "because it is the one place each check is NAMED, and
20
20
  * what P6's accumulator will key on". This is that phase. `on_progress=` — the TUI's — is
@@ -26,15 +26,15 @@
26
26
  * `state.inventory` and the badges and taxonomy classes belonged to a phase that had not
27
27
  * arrived. `api_catalog` consumes exactly those fields, so this is the phase that would
28
28
  * have made the counting half and the reading half two classifiers. It did not:
29
- * `tuiInventory` in `js/inventory.mjs` is the one walk, `js/status.mjs`'s `inventoryCounts`
29
+ * `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, `js/inspect/status.mjs`'s `inventoryCounts`
30
30
  * is three `length`s off it, and `specEntries` grew out of `specNames` by adding the READ
31
31
  * rather than by being written beside it.
32
32
  *
33
33
  * THE TAXONOMY WAS ALREADY PORTED, which the measurement found and the handoff did not.
34
- * `js/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
34
+ * `js/inspect/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
35
35
  * `SKILL_CLASS`, `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g inside
36
- * `js/doctor.mjs`, because doctor's authoring gates are what validate them. P6c moved the
37
- * four to `js/inventory.mjs` — where Python keeps them — rather than copying them, and
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
38
  * doctor imports them back. Only `parse_laws`, `load_registry` and `entity_status` were
39
39
  * genuinely new.
40
40
  *
@@ -46,7 +46,7 @@
46
46
  * carries the key, which is why its replay harness erases it before comparing.
47
47
  *
48
48
  * P6c's ROUTES ARE THE FIRST THAT PARSE A PATH, which brings three things no earlier
49
- * endpoint could reach: `pyUnquote` (NOT `decodeURIComponent`, which throws where the
49
+ * endpoint could reach: `percentDecode` (NOT `decodeURIComponent`, which throws where the
50
50
  * reference leaves a stray `%` alone), the `NotFound` → 404 convention, and `flatName`'s
51
51
  * traversal refusal — a GET carries no token, so before that check the item route was an
52
52
  * arbitrary-file read.
@@ -55,27 +55,28 @@ import { createHash } from 'node:crypto';
55
55
  import { existsSync, readdirSync, statSync } from 'node:fs';
56
56
  import path from 'node:path';
57
57
 
58
- import { ROOT, THEMES, discoverNames } from '../checkout.mjs';
59
- import { diffCollect } from '../diff.mjs';
60
- import { doctorCollect } from '../doctor.mjs';
61
- import { excludesSnapshot } from '../excludes.mjs';
62
- import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, pyResolve } from '../hosts.mjs';
58
+ import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
59
+ import { sourceReleaseVersion } from '../hosts/opencode.mjs';
60
+ import { diffCollect } from '../inspect/diff.mjs';
61
+ import { doctorCollect } from '../inspect/doctor.mjs';
62
+ import { excludesSnapshot } from '../inspect/excludes.mjs';
63
+ import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
63
64
  import {
64
65
  doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
65
66
  installedDefaults, modeOfDir,
66
67
  postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
67
- } from '../installs.mjs';
68
- import { frontmatter } from '../hooks.mjs';
68
+ } from '../hosts/installs.mjs';
69
+ import { frontmatter } from '../hosts/hooks.mjs';
69
70
  import {
70
71
  SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
71
- } from '../inventory.mjs';
72
- import { firstBlockquote } from '../native.mjs';
73
- import { EMIT_OPTIONS, themeOptions } from '../setup.mjs';
74
- import { accentFor, statusData } from '../status.mjs';
75
- import {
76
- comparePaths, normcase, pyStripSpace, pyUnquote, readText, withDiscardableStderr, within,
77
- } from '../lib/fs.mjs';
78
- import { readJsonc } from '../settings.mjs';
72
+ } from '../inspect/inventory.mjs';
73
+ import { firstBlockquote } from '../hosts/native.mjs';
74
+ import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
75
+ import { accentFor, statusData } from '../inspect/status.mjs';
76
+ import { readText, withDiscardableStderr } from '../lib/fs.mjs';
77
+ import { comparePaths, normcase, within } from '../lib/paths.mjs';
78
+ import { stripWhitespace, percentDecode } from '../lib/text.mjs';
79
+ import { readJsonc } from '../hosts/settings.mjs';
79
80
  import { apiActivity, apiActivityDetail } from './activity.mjs';
80
81
  import { apiMcp, apiRules } from './actions.mjs';
81
82
  import { apiGraph } from './graph.mjs';
@@ -87,7 +88,7 @@ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return
87
88
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
88
89
 
89
90
  /** Two resolved paths, compared as `PurePath.__eq__` does — case-folded on Windows. */
90
- const samePath = (a, b) => normcase(pyResolve(a)) === normcase(pyResolve(b));
91
+ const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
91
92
 
92
93
  /** `dict.get(key, default)` — `??` would also swallow an explicit null the file declared. */
93
94
  const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
@@ -177,7 +178,7 @@ export function webState(theme = null, target = null) {
177
178
  try {
178
179
  const em = path.join(d, '.geneseed-emit');
179
180
  if (isFile(em)) {
180
- const v = pyStripSpace(readText(em));
181
+ const v = stripWhitespace(readText(em));
181
182
  if (v) return v;
182
183
  }
183
184
  } catch { /* as the Python's `except OSError: pass` */ }
@@ -264,7 +265,7 @@ export function specEntries(root, nested) {
264
265
  if (!isFile(p)) continue;
265
266
  const [fm, body] = frontmatter(readMaybe(p) ?? '');
266
267
  const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
267
- out.push({ name, desc: specDesc(fm, body), body, source: pyResolve(p) });
268
+ out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
268
269
  }
269
270
  out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
270
271
  return out;
@@ -353,7 +354,7 @@ export function memoryItems(state) {
353
354
  name: stem,
354
355
  title: fm.has('name') ? fm.get('name') : stem,
355
356
  desc: fm.has('description') ? fm.get('description') : '',
356
- source: pyResolve(p),
357
+ source: resolvePath(p),
357
358
  };
358
359
  });
359
360
  }
@@ -363,7 +364,7 @@ export function notebookItems(state) {
363
364
  const d = notebookDir(state);
364
365
  if (!isDir(d)) return [];
365
366
  return globMd(d).map((n) => ({
366
- name: stemOf(n), title: stemOf(n), desc: '', source: pyResolve(path.join(d, n)),
367
+ name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
367
368
  }));
368
369
  }
369
370
 
@@ -381,7 +382,7 @@ export function configItems(state) {
381
382
  if (!isFile(p)) continue;
382
383
  const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
383
384
  out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
384
- source: pyResolve(p) });
385
+ source: resolvePath(p) });
385
386
  }
386
387
  return out;
387
388
  }
@@ -389,13 +390,14 @@ export function configItems(state) {
389
390
  export const WIKI_FILE_CAP = 5000;
390
391
 
391
392
  /**
392
- * `pyResolve` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
393
+ * `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
393
394
  *
394
- * The three wiki sites below are the only request-path callers of `expanduser`, and it now
395
- * REFUSES a `~user` form by printing and throwing (`js/hosts.mjs`'s docblock). None of them
396
- * may let that escape: `js/web/server.mjs` wraps the whole GET in a blanket `catch` → a JSON
395
+ * The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
396
+ * request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
397
+ * (`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
397
399
  * 500, so an unguarded refusal would turn ONE bad line in a file the user hand-edits into a
398
- * dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hooks.mjs`
400
+ * dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hosts/hooks.mjs`
399
401
  * — contain the refusal per entry and take the site's OWN degrade — and each of the three
400
402
  * already has one: `[]`, `continue`, `continue`-into-404. The reference reaches those same
401
403
  * three degrades for a `~user` path, from the other direction: `ntpath.expanduser` expands
@@ -413,7 +415,7 @@ export const WIKI_FILE_CAP = 5000;
413
415
  */
414
416
  function wikiPath(p) {
415
417
  try {
416
- return withDiscardableStderr(() => pyResolve(p));
418
+ return withDiscardableStderr(() => resolvePath(p));
417
419
  } catch {
418
420
  return null;
419
421
  }
@@ -423,7 +425,7 @@ function wikiPath(p) {
423
425
  * `_web_catalog._wiki_manifest` — `$GENESEED_WIKI` first, else `wiki.jsonc` beside the
424
426
  * deployed bundle, read with the harness's generic JSONC loader.
425
427
  *
426
- * `pyResolve` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
428
+ * `resolvePath` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
427
429
  * consumed by `isFile` and `mcpLoad` and never reaches a response body, and both of those
428
430
  * follow symlinks and resolve a relative path against the same cwd anyway.
429
431
  */
@@ -476,7 +478,7 @@ export function wikiItems(state) {
476
478
  if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
477
479
  const wname = String(dget(w, 'name', null) || 'wiki');
478
480
  // PER ENTRY, and the loop continues — one unusable `path` in the manifest must not blank
479
- // every OTHER vault in it. `pyResolve` where the reference only `expanduser()`s is again
481
+ // every OTHER vault in it. `resolvePath` where the reference only `expanduser()`s is again
480
482
  // unobservable: `root` is only ever joined against or used as the base of a `relative()`
481
483
  // whose other operand was built FROM it, and `source` is `.resolve()`d on both sides.
482
484
  const root = wikiPath(String(dget(w, 'path', null) || ''));
@@ -504,7 +506,7 @@ export function wikiItems(state) {
504
506
  seen.add(key);
505
507
  items.push({ name: key, title: path.basename(md, '.md'),
506
508
  desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
507
- source: pyResolve(md) });
509
+ source: resolvePath(md) });
508
510
  }
509
511
  }
510
512
  }
@@ -525,7 +527,7 @@ export function apiWikiItem(state, name) {
525
527
  // where the reference lands when the expanded root turns out not to exist.
526
528
  const root = wikiPath(String(dget(w, 'path', null) || ''));
527
529
  if (!root) continue;
528
- const p = pyResolve(path.join(root, rel));
530
+ const p = resolvePath(path.join(root, rel));
529
531
  if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
530
532
  const body = readMaybe(p) ?? '';
531
533
  return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
@@ -536,10 +538,13 @@ export function apiWikiItem(state, name) {
536
538
  }
537
539
 
538
540
  /**
539
- * `harness._within` — re-exported from `js/lib/fs.mjs`, where P6i moved it.
541
+ * `harness._within` — re-exported from `js/lib/paths.mjs`, where the lib split put it.
542
+ *
543
+ * ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
544
+ * walks. A change to the containment rule has to land on both, and nothing compares them.
540
545
  *
541
546
  * It lived here from P6b (the restore's containment check) until `_install_move_list` needed
542
- * the same predicate from `js/uninstall.mjs`, which must not import the web tree. The
547
+ * the same predicate from `js/maintain/uninstall.mjs`, which must not import the web tree. The
543
548
  * re-export keeps `js/web/actions.mjs`'s import path unchanged.
544
549
  */
545
550
  export { within };
@@ -577,9 +582,9 @@ export function resolveLinks(state, body) {
577
582
  const links = [];
578
583
  const seen = new Set();
579
584
  for (const m of body.matchAll(WIKILINK_RE)) {
580
- // `pyStripSpace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
585
+ // `stripWhitespace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
581
586
  // gave this regex a second reader, so the two call sites answer with one rule.
582
- const label = pyStripSpace(m[1]);
587
+ const label = stripWhitespace(m[1]);
583
588
  if (known.has(label) && !seen.has(label)) {
584
589
  seen.add(label);
585
590
  links.push({ label, type: known.get(label), name: label });
@@ -715,7 +720,7 @@ export function apiItem(state, type, name) {
715
720
  if (!isFile(p)) throw new NotFound(name);
716
721
  const body = readMaybe(p) ?? '';
717
722
  return { type, name, title: name, desc: '', body,
718
- links: resolveLinks(state, body), source: pyResolve(p) };
723
+ links: resolveLinks(state, body), source: resolvePath(p) };
719
724
  }
720
725
  if (type === 'wiki') return apiWikiItem(state, name);
721
726
  if (type === 'config') {
@@ -725,7 +730,7 @@ export function apiItem(state, type, name) {
725
730
  const raw = readMaybe(p) ?? '';
726
731
  const [title, desc] = CONFIG_META[name] ?? [name, ''];
727
732
  return { type, name, title, desc, manifest: configManifest(name, p),
728
- body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: pyResolve(p) };
733
+ body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
729
734
  }
730
735
  throw new NotFound(type);
731
736
  }
@@ -898,22 +903,39 @@ export function apiOverview(state) {
898
903
  doctor: state.doctor,
899
904
  diff,
900
905
  build_time: buildTime,
906
+ // The RELEASE LABEL OF THE SOURCE THIS CONSOLE IS SERVED FROM, not of the deployed install —
907
+ // the two differ whenever something has changed since the last emit, which is exactly when
908
+ // knowing which one you are looking at matters. `.geneseed-version` carries the install's own
909
+ // label and the fingerprint comparison already reports the drift; this answers the plainer
910
+ // question the topbar asks, "what is running".
911
+ //
912
+ // Degrades to null rather than to a wrong number: `sourceReleaseVersion` answers '0.0.0' for
913
+ // an unreadable config, and a console confidently displaying 0.0.0 is worse than one that
914
+ // shows nothing.
915
+ version: releaseLabel(),
901
916
  };
902
917
  }
903
918
 
919
+ /** The source's release label, or null when it cannot be read. */
920
+ function releaseLabel() {
921
+ const v = sourceReleaseVersion({ config: CONFIG });
922
+ return v && v !== '0.0.0' ? v : null;
923
+ }
924
+
904
925
  /**
905
926
  * `_web_server.Handler.STATE_ROUTES`, the ported half.
906
927
  *
907
928
  * A literal path to `api_X(state)`, exactly as the reference's table is — and the reason
908
- * it is a table on this side too is `tests/test_web_server.py`'s cross-check, which reads
909
- * the reference's table out of `rituals/_web_server.py` with `ast` and requires this one
910
- * plus `NOT_PORTED` to equal it. On the second instance of anything, the gate becomes a
911
- * table cross-checked against the source of truth.
929
+ * it is a table on this side too is `tests/unit/web_server.test.mjs`'s cross-check, which
930
+ * requires this table plus `PREFIX_ROUTES`, `PORTED_INLINE` and `NOT_PORTED` to equal
931
+ * `REF_GET` — a FROZEN LITERAL of what the reference daemon answered, since the source it
932
+ * was once read from with `ast` is deleted. On the second instance of anything, the gate
933
+ * becomes a table cross-checked against a declaration.
912
934
  */
913
935
  /**
914
936
  * `_web_server.do_GET`'s prefix routes, the ported ones — path in, response out.
915
937
  *
916
- * `pyUnquote` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
938
+ * `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
917
939
  * that is not an escape, which the shell would answer as a 500 where the reference answers
918
940
  * a 404 naming the literal text.
919
941
  */
@@ -925,13 +947,13 @@ export const PREFIX_ROUTES = [
925
947
  ['/api/item/', (state, p) => {
926
948
  const parts = splitN(p, '/', 4);
927
949
  if (parts.length < 5 || !parts[4]) throw new NotFound(p);
928
- return apiItem(state, parts[3], pyUnquote(parts[4]));
950
+ return apiItem(state, parts[3], percentDecode(parts[4]));
929
951
  }],
930
952
  // P6e. The reference's own `path[len("/api/activity/"):]`, unquoted — the sid keeps
931
953
  // whatever it carries and `api_activity_detail`'s safe-name scheme is what makes the
932
954
  // lookup safe, rather than a check out here.
933
955
  ['/api/activity/', (state, p) => apiActivityDetail(
934
- state, pyUnquote(p.slice('/api/activity/'.length)))],
956
+ state, percentDecode(p.slice('/api/activity/'.length)))],
935
957
  ];
936
958
 
937
959
  /** `str.split(sep, maxsplit)` — at most `n` splits, the remainder kept whole. */
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The daemon — its on-disk record, and the lifecycle that starts, probes, stops and restarts it.
3
+ *
4
+ * `geneseed web` can run in the foreground or detached. Detached is the interesting half: the
5
+ * record at `.geneseed-web.json` is how a later `web stop` or `web status` finds a server nobody
6
+ * kept a handle to, and `probe` is the liveness question every one of those verbs asks first.
7
+ *
8
+ * TWO OF THIS PACKAGE'S THREE SPAWNS ARE HERE — the detached daemon and the desktop URL opener —
9
+ * and both are declared by name in `tests/unit/hook_cli.test.mjs`'s spawn allow-list. A new spawn
10
+ * in this file is a deliberate act that reddens that gate until it is declared.
11
+ */
12
+ import { printOut, writeText } from '../lib/fs.mjs';
13
+ import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
14
+ import { webState } from './api.mjs';
15
+ import { spawn } from 'node:child_process';
16
+ import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
17
+ import { request as httpRequest } from 'node:http';
18
+ import { join, resolve as pathResolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
22
+
23
+ // ---- the daemon record -----------------------------------------------------
24
+
25
+ const statePath = (target) => join(target, '.geneseed-web.json');
26
+
27
+ export function readDaemon(target) {
28
+ try {
29
+ return JSON.parse(readFileSync(statePath(target), 'utf-8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export function writeDaemon(target, data) {
36
+ try {
37
+ mkdirSync(target, { recursive: true });
38
+ const p = statePath(target);
39
+ writeText(p, jsonDumpsCompact(data, { bareInts: true }));
40
+ // Owner-only: the record carries the API token. A no-op on Windows, where the
41
+ // mode only maps to the read-only attribute — same as the reference's os.chmod.
42
+ chmodSync(p, 0o600);
43
+ } catch { /* the reference swallows OSError here too */ }
44
+ }
45
+
46
+ export function clearDaemon(target) {
47
+ try {
48
+ unlinkSync(statePath(target));
49
+ } catch { /* already gone */ }
50
+ }
51
+
52
+ // ---- daemon mode -----------------------------------------------------------
53
+ //
54
+ // `geneseed web start|stop|status|restart`. The reference's own banner explains the design
55
+ // — the running server records pid/port/token/url in a small JSON file and control is over
56
+ // HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
57
+ // token. Two things about the TWIN, neither of which is in that banner:
58
+ //
59
+ // * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
60
+ // synchronous. Node has no synchronous HTTP client, so every function below that
61
+ // reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
62
+ // awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
63
+ // * `agent: false` on both requests. Node's global agent has kept sockets alive by
64
+ // default since v19, and a pooled idle socket keeps the event loop referenced — a
65
+ // `web status` that answered correctly would then hang for the keep-alive timeout
66
+ // instead of exiting. Python's `urlopen` opens and closes a connection per call.
67
+
68
+ /** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
69
+ * launcher's wait loop are built on. Every failure mode is False, as the reference's
70
+ * `(URLError, OSError, ValueError)` catch is. */
71
+ export function probe(url, timeout = 1.5) {
72
+ return new Promise((done) => {
73
+ let settled = false;
74
+ const finish = (v) => { if (!settled) { settled = true; done(v); } };
75
+ let req;
76
+ try {
77
+ req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
78
+ res.resume(); // drain, or the socket never closes
79
+ finish(res.statusCode === 200);
80
+ });
81
+ } catch {
82
+ return finish(false); // a malformed url is `ValueError` there and a throw here
83
+ }
84
+ req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
85
+ req.on('error', () => finish(false));
86
+ req.end();
87
+ return undefined;
88
+ });
89
+ }
90
+
91
+ /** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
92
+ export function postShutdown(url, token, timeout = 3.0) {
93
+ return new Promise((done) => {
94
+ let settled = false;
95
+ const finish = (v) => { if (!settled) { settled = true; done(v); } };
96
+ let req;
97
+ try {
98
+ req = httpRequest(`${url}/api/shutdown`, {
99
+ method: 'POST',
100
+ agent: false,
101
+ headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
102
+ }, (res) => {
103
+ res.resume();
104
+ finish(res.statusCode === 200);
105
+ });
106
+ } catch {
107
+ return finish(false);
108
+ }
109
+ req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
110
+ req.on('error', () => finish(false));
111
+ req.end('{}');
112
+ return undefined;
113
+ });
114
+ }
115
+
116
+ /**
117
+ * The record ONLY if a server is answering; otherwise the stale file is deleted.
118
+ *
119
+ * The deletion is the part a port drops, and it is what stops a machine that crashed with
120
+ * a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
121
+ * are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
122
+ * carrying no url at all, which skips the probe and still clears.
123
+ */
124
+ export async function liveDaemon(target) {
125
+ const st = readDaemon(target);
126
+ if (st && isTruthy(st.url) && await probe(st.url)) return st;
127
+ if (isTruthy(st)) clearDaemon(target);
128
+ return null;
129
+ }
130
+
131
+ const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
132
+
133
+ /** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
134
+ * `undefined`. Only reachable through a hand-edited record, and one character of
135
+ * divergence in a message is still a divergence. */
136
+ export const none = (v) => (v === undefined || v === null ? 'None' : String(v));
137
+
138
+ /**
139
+ * `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
140
+ * module owns.
141
+ *
142
+ * WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
143
+ * the process that started it. `geneseed web start` prints a URL and returns to the shell;
144
+ * the server it started must still be there. A thread, a `worker_thread` or an in-process
145
+ * `serve()` all die with the launcher.
146
+ *
147
+ * WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
148
+ * the running interpreter by absolute path and this repo's own CLI. The reference names
149
+ * `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
150
+ * makes this not the passthrough the port exists to remove, and
151
+ * `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
152
+ * and no `build.py` at all.
153
+ */
154
+ function spawnDetached(webArgs, log) {
155
+ const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
156
+ let out = 'ignore';
157
+ try {
158
+ out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
159
+ } catch { /* the reference falls back to DEVNULL on exactly this failure */ }
160
+ const child = spawn(cmd[0], cmd.slice(1), {
161
+ // `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
162
+ // are both `detached` here; `unref()` is what lets the launcher's event loop end while
163
+ // the child runs on, which is `Popen`'s default and Node's is not.
164
+ detached: true, windowsHide: true, stdio: ['ignore', out, out],
165
+ });
166
+ child.unref();
167
+ }
168
+
169
+ /** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
170
+ * corpus: the binary differs per side by design, the FLAGS may not. */
171
+ export function daemonArgs(port, theme) {
172
+ const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
173
+ if (isTruthy(theme)) args.push('--theme', theme);
174
+ return args;
175
+ }
176
+
177
+ /** The same for the out-of-band restart — `_restart_args`. */
178
+ export function restartArgs(theme) {
179
+ const args = ['restart', '--no-browser'];
180
+ if (isTruthy(theme)) args.push('--theme', theme);
181
+ return args;
182
+ }
183
+
184
+ /** `request_restart` — a detached `web restart`, so the new server survives the exit of
185
+ * the very process asking for it. Called by POST `/api/restart`. */
186
+ export function requestRestart(theme) {
187
+ const target = webState(theme).target;
188
+ spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
189
+ }
190
+
191
+ /**
192
+ * `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
193
+ * that knows what a desktop's default browser is.
194
+ *
195
+ * IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
196
+ * opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
197
+ * program's work. Declining it instead would have been a silent regression in the one verb
198
+ * whose job is to put a UI on screen — `geneseed web` would print an address and do
199
+ * nothing, and no cell would say so because every cell passes `--no-browser`.
200
+ *
201
+ * The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
202
+ * sites and a bare `except` in the third), and so does this.
203
+ */
204
+ export function openUrl(url) {
205
+ const [file, args, extra] = process.platform === 'win32'
206
+ // `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
207
+ // without it `start "http://…"` treats the quoted URL as the window title and opens
208
+ // nothing. Verbatim args because Node would otherwise re-quote the whole string.
209
+ ? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
210
+ { windowsVerbatimArguments: true }]
211
+ : (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
212
+ try {
213
+ const child = spawn(file, args, {
214
+ detached: true, stdio: 'ignore', windowsHide: true, ...extra,
215
+ });
216
+ // ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
217
+ // This is where the reference's suppression failed to survive translation: Python's
218
+ // `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
219
+ // really did swallow a missing opener. Node's `spawn` reports the same condition on the
220
+ // child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
221
+ // exception. So the port's `try/catch` read as equivalent and suppressed nothing.
222
+ //
223
+ // MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
224
+ // printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
225
+ // 690-cell corpus, and the docblock above says why the corpus never could — every cell
226
+ // passes `--no-browser`, so no recorded cell has ever executed this line.
227
+ child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
228
+ child.unref();
229
+ } catch { /* the reference suppresses every exception from this call too */ }
230
+ }
231
+
232
+ export async function startDaemon(theme, port, openBrowser = true) {
233
+ const { target } = webState(theme);
234
+ const st = await liveDaemon(target);
235
+ if (st) {
236
+ printOut(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
237
+ if (openBrowser) openUrl(st.url);
238
+ return 0;
239
+ }
240
+ clearDaemon(target);
241
+ const log = join(target, '.geneseed-web.log');
242
+ spawnDetached(daemonArgs(port, theme), log);
243
+ // `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
244
+ // write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
245
+ for (let i = 0; i < 60; i += 1) {
246
+ const rec = readDaemon(target);
247
+
248
+ if (rec && isTruthy(rec.url) && await probe(rec.url, 0.5)) {
249
+ printOut(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
250
+ + `pid ${none(rec.pid)})\n`);
251
+ printOut('[web] running in the background — `geneseed web stop` to stop it.\n');
252
+ if (openBrowser) openUrl(rec.url);
253
+ return 0;
254
+ }
255
+
256
+ await sleep(200);
257
+ }
258
+ printOut('[web] daemon did not come up in time — check the log:\n');
259
+ printOut(` ${log}\n`);
260
+ return 1;
261
+ }
262
+
263
+ export async function stopDaemon(theme = null) {
264
+ const { target } = webState(theme);
265
+ const st = readDaemon(target);
266
+ if (!isTruthy(st) || !isTruthy(st.url)) {
267
+ // NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
268
+ // record that names no url is left exactly where it is; `web status` on the same file
269
+ // deletes it. Both directions are cells, because a port that unified them would delete
270
+ // a file the reference keeps and stdout would look identical.
271
+ printOut('[web] no running server recorded.\n');
272
+ return 0;
273
+ }
274
+ if (await postShutdown(st.url, isTruthy(st.token) ? st.token : '')) {
275
+ clearDaemon(target);
276
+ printOut(`[web] stopped (pid ${none(st.pid)}).\n`);
277
+ return 0;
278
+ }
279
+ clearDaemon(target);
280
+ printOut('[web] no live server (cleared a stale record).\n');
281
+ return 0;
282
+ }
283
+
284
+ export async function statusDaemon(theme = null) {
285
+ const { target } = webState(theme);
286
+ const st = await liveDaemon(target);
287
+ if (st) {
288
+ printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
289
+ return 0;
290
+ }
291
+ // EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
292
+ // column is what gates it.
293
+ printOut('[web] not running.\n');
294
+ return 1;
295
+ }
296
+
297
+ export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
298
+ onlyIfRunning = false) {
299
+ const { target } = webState(theme);
300
+ const st = readDaemon(target);
301
+ const live = (await liveDaemon(target)) !== null;
302
+ if (onlyIfRunning && !live) return 0;
303
+ const usePort = (st && isTruthy(st.port) ? st.port : null) || port;
304
+ let open = openBrowser;
305
+ if (live) {
306
+ // A live daemon means a tab is already open on this (preserved) port and will
307
+ // reconnect on its own — the reference's `ponytail:` note, kept.
308
+ open = false;
309
+ await stopDaemon(theme);
310
+ for (let i = 0; i < 50; i += 1) {
311
+
312
+ if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
313
+
314
+ await sleep(100);
315
+ }
316
+ }
317
+ return startDaemon(theme, usePort, open);
318
+ }
319
+