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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `rituals/theme_anim.py` — the per-theme install animation the LINE wizard plays.
2
+ * The per-theme install animation the LINE wizard plays.
3
3
  *
4
4
  * P7c, and the one leftover of that phase that is NOT the declared curses panel. P5i ported
5
5
  * `_setup_lines` and left this out with a note — "belongs to P7 with the rest of the terminal
@@ -8,7 +8,7 @@
8
8
  * that has nothing to do with curses: it draws on the ORDINARY terminal, with cursor-up
9
9
  * escapes and a sleep, and its only caller is a function that already crossed.
10
10
  *
11
- * SO IT IS NOT IN `js/tui.mjs`, DELIBERATELY. That module carries a structural assertion that
11
+ * SO IT IS NOT IN `js/ui/tui.mjs`, DELIBERATELY. That module carries a structural assertion that
12
12
  * it contains no escape sequence at all — the thing that makes "the panel is declared" a
13
13
  * measurement instead of a promise. This file necessarily contains `\x1b[{n}A`, so putting it
14
14
  * there would have turned that assertion red and invited someone to weaken it. A line-mode
@@ -27,13 +27,15 @@
27
27
  * environments in `tests/test_pure_function_parity.py` — none of which can produce it on
28
28
  * either side.
29
29
  *
30
- * WHAT GATES IT. `tests/test_pure_function_parity.py`'s `_theme_anim_cases()`: `art_for` over
31
- * every key plus the prototype-chain names, `_place`/`_tile`/`_height` over their arithmetic,
32
- * `_anim_ok` over its whole decision table, and `play_line` itself over 24 environments —
33
- * captured on the reference side through a real `TextIOWrapper`, so the CRLF translation is
34
- * inside the comparison rather than outside it.
30
+ * WHAT GATES IT. `tests/__snapshots__/primitives/{posix,win32}.json`, replayed by
31
+ * `tests/snapshot/pure_snapshot.test.mjs`: `art_for` over every key plus the prototype-chain
32
+ * names, `_place`/`_tile`/`_height` over their arithmetic, `anim_ok` over its whole decision
33
+ * table, and `play_line` itself over 24 environments — captured on the reference side through
34
+ * a real `TextIOWrapper`, so the CRLF translation is inside the recording rather than outside
35
+ * it. There is no recorder left, so a red run means these functions moved, never the corpus.
35
36
  */
36
- import { pyInt, pyLen, pyLjust, pyPrint } from './lib/fs.mjs';
37
+ import { printOut } from '../lib/fs.mjs';
38
+ import { parseIntStrict, codePointLength, padEndToWidth } from '../lib/text.mjs';
37
39
 
38
40
  export const ART = {
39
41
  imperial: {
@@ -184,14 +186,14 @@ const cut = (s, a, b) => Array.from(s).slice(a, b).join('');
184
186
  /**
185
187
  * `shutil.get_terminal_size((80, 24)).columns`.
186
188
  *
187
- * `pyInt` and not `parseInt`, and the corpus has a case for exactly that: Python's `int()`
189
+ * `parseIntStrict` and not `parseInt`, and the corpus has a case for exactly that: Python's `int()`
188
190
  * refuses `'80x'` outright and `get_terminal_size` swallows the ValueError into the fallback,
189
191
  * where `parseInt('80x')` is a cheerful 80. A width one column off is invisible in a terminal
190
192
  * and loud in a byte comparison. `<= 0` is the reference's own guard, which is why `'0'` and
191
193
  * `'-5'` fall through to the fallback rather than producing a zero-width canvas.
192
194
  */
193
195
  function terminalColumns() {
194
- const env = pyInt(process.env.COLUMNS);
196
+ const env = parseIntStrict(process.env.COLUMNS);
195
197
  if (env !== null && env > 0) return env;
196
198
  // `sys.__stdout__` over there, `process.stdout` here. Both are undefined-ish off a
197
199
  // terminal, and both then take the (80, 24) fallback.
@@ -237,7 +239,7 @@ export function place(row, x, width) {
237
239
  */
238
240
  export function tile(tileStr, width, phase = 0) {
239
241
  if (!tileStr) return '';
240
- const n = pyLen(tileStr);
242
+ const n = codePointLength(tileStr);
241
243
  const base = tileStr.repeat(Math.max(0, Math.floor(width / n) + 2));
242
244
  const off = ((phase % n) + n) % n;
243
245
  return cut(base, off, off + width);
@@ -256,10 +258,10 @@ export function height(poses) {
256
258
  * both odd the extra column goes on the LEFT, and otherwise on the right. Nothing about
257
259
  * "centre this" implies it. The title line of every animation is the only caller.
258
260
  */
259
- function pyCenter(s, width) {
260
- const marg = width - pyLen(s);
261
+ function centerText(s, width) {
262
+ const marg = width - codePointLength(s);
261
263
  if (marg <= 0) return s;
262
- /* eslint-disable-next-line no-bitwise */
264
+
263
265
  const left = Math.floor(marg / 2) + (marg & width & 1);
264
266
  return ' '.repeat(left) + s + ' '.repeat(marg - left);
265
267
  }
@@ -280,21 +282,21 @@ const sleep = (ms) => {
280
282
  export function playLine(theme, ok = true) {
281
283
  const art = artFor(theme);
282
284
  const width = Math.min(terminalColumns() - 1, 56);
283
- pyPrint('\n');
284
- pyPrint(`${pyCenter(art.title, width)}\n`);
285
+ printOut('\n');
286
+ printOut(`${centerText(art.title, width)}\n`);
285
287
  if (!ok) return;
286
288
  if (!animOk()) {
287
- for (const r of art.card) pyPrint(`${cut(r, 0, width)}\n`);
288
- pyPrint('\n');
289
+ for (const r of art.card) printOut(`${cut(r, 0, width)}\n`);
290
+ printOut('\n');
289
291
  return;
290
292
  }
291
293
  const poses = art.sprite;
292
294
  const ground = art.ground || '';
293
295
  const h = height(poses);
294
296
  const canvasH = h + (ground ? 1 : 0);
295
- const spriteW = Math.max(0, ...poses.flatMap((p) => p.map((r) => pyLen(r))));
297
+ const spriteW = Math.max(0, ...poses.flatMap((p) => p.map((r) => codePointLength(r))));
296
298
  try {
297
- pyPrint('\n'.repeat(canvasH));
299
+ printOut('\n'.repeat(canvasH));
298
300
  const steps = Math.min(width + spriteW, 64); // cap to ~1.6s of motion
299
301
  for (let i = 0; i <= steps; i += 1) {
300
302
  const pose = poses[Math.floor(i / 3) % poses.length];
@@ -302,25 +304,25 @@ export function playLine(theme, ok = true) {
302
304
  const lines = [];
303
305
  for (let r = 0; r < h; r += 1) lines.push(place(r < pose.length ? pose[r] : '', x, width));
304
306
  if (ground) lines.push(tile(ground, width, i));
305
- pyPrint(`\x1b[${canvasH}A`);
306
- for (const ln of lines) pyPrint(`\r${cut(pyLjust(ln, width), 0, width)}\n`);
307
+ printOut(`\x1b[${canvasH}A`);
308
+ for (const ln of lines) printOut(`\r${cut(padEndToWidth(ln, width), 0, width)}\n`);
307
309
  sleep(25);
308
310
  }
309
311
  // settle on the card
310
- pyPrint(`\x1b[${canvasH}A`);
312
+ printOut(`\x1b[${canvasH}A`);
311
313
  const card = art.card;
312
314
  for (let r = 0; r < canvasH; r += 1) {
313
315
  const row = r < card.length ? cut(card[r], 0, width) : '';
314
- pyPrint(`\r${cut(pyLjust(row, width), 0, width)}\n`);
316
+ printOut(`\r${cut(padEndToWidth(row, width), 0, width)}\n`);
315
317
  }
316
318
  // any extra card rows beyond the canvas
317
- for (let r = canvasH; r < card.length; r += 1) pyPrint(`${cut(card[r], 0, width)}\n`);
318
- pyPrint('\n');
319
+ for (let r = canvasH; r < card.length; r += 1) printOut(`${cut(card[r], 0, width)}\n`);
320
+ printOut('\n');
319
321
  } catch {
320
322
  // Terminal didn't cooperate — leave a clean static card.
321
323
  try {
322
- for (const r of art.card) pyPrint(`${cut(r, 0, width)}\n`);
323
- pyPrint('\n');
324
+ for (const r of art.card) printOut(`${cut(r, 0, width)}\n`);
325
+ printOut('\n');
324
326
  } catch { /* nothing left to try */ }
325
327
  }
326
328
  }
@@ -39,7 +39,7 @@
39
39
  * that checks the WHOLE table, and outlives the parser, is `tests/unit/cli_table.test.mjs`;
40
40
  * after that the table is simply the source of truth and is edited directly.
41
41
  *
42
- * THE COST, NAMED: this entry cannot parse anything without this file. `js/checkout.mjs`
42
+ * THE COST, NAMED: this entry cannot parse anything without this file. `js/build/source.mjs`
43
43
  * already reads `harness.config.json` at import for every render, so a tracked data document
44
44
  * the code needs is not a new shape here — and this is not the hook entry, whose latency
45
45
  * budget is `bin/geneseed-hook.mjs`'s and is untouched.
@@ -47,8 +47,8 @@
47
47
  import { readFileSync } from 'node:fs';
48
48
  import path from 'node:path';
49
49
 
50
- import { ROOT } from './checkout.mjs';
51
- import { pyInt, pyStripSpace } from './lib/fs.mjs';
50
+ import { ROOT } from '../build/source.mjs';
51
+ import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
52
 
53
53
  const CLI_JSON = path.join(ROOT, 'js', 'cli-table.json');
54
54
 
@@ -150,7 +150,7 @@ function actionInvocation(a, positional) {
150
150
  return args ? `${a.names.join(', ')} ${args}` : a.names.join(', ');
151
151
  }
152
152
 
153
- // `textwrap`'s `wordsep_re`, which is what makes `pyTextWrap` below a port rather than a guess.
153
+ // `textwrap`'s `wordsep_re`, which is what makes `wrapText` below a port rather than a guess.
154
154
  //
155
155
  // A GREEDY SPACE-ONLY WRAP WAS THE FIRST DRAFT AND THE GATE REFUSED IT. `textwrap` splits a
156
156
  // hyphenated word into two chunks, so it can break a line inside `back-compat` where a
@@ -185,7 +185,7 @@ const WORDSEP = new RegExp(
185
185
  * `rituals/_harness_tui*.py` calls `textwrap.wrap` in seven places and is declared unported;
186
186
  * move this when that changes, not before.
187
187
  */
188
- export function pyTextWrap(text, width) {
188
+ export function wrapText(text, width) {
189
189
  const chunks = text.split(WORDSEP).filter(Boolean).reverse();
190
190
  const blank = (s) => s.trim() === '';
191
191
  const lines = [];
@@ -297,7 +297,7 @@ function formatAction(a, positional, helpPosition, width) {
297
297
  // `_whitespace_matcher` is `re.compile(r'\s+', re.ASCII)` — the ASCII class, so a help
298
298
  // string carrying U+00A0 keeps it rather than having it collapsed to a break opportunity.
299
299
  const collapsed = help.replace(/[ \t\n\v\f\r]+/g, ' ').trim();
300
- const lines = pyTextWrap(collapsed, Math.max(width - helpPosition, 11));
300
+ const lines = wrapText(collapsed, Math.max(width - helpPosition, 11));
301
301
  parts.push(`${' '.repeat(indentFirst)}${lines[0]}\n`);
302
302
  for (const line of lines.slice(1)) parts.push(`${' '.repeat(helpPosition)}${line}\n`);
303
303
  } else if (!parts[0].endsWith('\n')) {
@@ -405,7 +405,7 @@ export function formatHelp(cmd, prog, width) {
405
405
  * reads `COLUMNS` before it asks the tty, and falls back to 80 when neither answers, which is
406
406
  * every pipe both implementations write help into.)
407
407
  *
408
- * `pyInt`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
408
+ * `parseIntStrict`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
409
409
  * this `int(os.environ['COLUMNS'])` inside `except (KeyError, ValueError)`, and `int` is not
410
410
  * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values
411
411
  * (`test_the_ports_help_width_is_shutil_get_terminal_size`): `COLUMNS=1_0` is 10 to Python and
@@ -415,11 +415,11 @@ export function formatHelp(cmd, prog, width) {
415
415
  * why the corpus is the gate rather than a cell. A non-positive value falls through on both
416
416
  * sides, which is Python's `if columns <= 0` and was already right here.
417
417
  *
418
- * `pyStripSpace` because THIS is the caller `pyInt`'s docblock did not have: its other two
418
+ * `stripWhitespace` because THIS is the caller `parseIntStrict`'s docblock did not have: its other two
419
419
  * strip before calling, and `$COLUMNS` arrives however the shell set it — `int(' 80 ')` is 80.
420
420
  */
421
421
  export function helpWidth() {
422
- const env = pyInt(pyStripSpace(process.env.COLUMNS ?? ''));
422
+ const env = parseIntStrict(stripWhitespace(process.env.COLUMNS ?? ''));
423
423
  if (env !== null && env > 0) return env - 2;
424
424
  return (process.stdout.columns > 0 ? process.stdout.columns : 80) - 2;
425
425
  }
@@ -486,7 +486,7 @@ export function cliSpec(verb) {
486
486
  }
487
487
  for (const a of cmd.options ?? []) {
488
488
  const dest = camel(a.dest);
489
- // eslint-disable-next-line no-nested-ternary
489
+
490
490
  const table = a.is_flag ? 'flags' : (a.nargs === '?' ? 'optValue' : 'options');
491
491
  for (const name of a.names ?? []) {
492
492
  (spec[table] ??= {})[name] = dest;
@@ -20,7 +20,7 @@
20
20
  * `rituals/_winterm.py` under the name `curses` when it does — so inside the harness the
21
21
  * import succeeds, the panel opens, and it is the PORT that has no window. The fallback is
22
22
  * still the right shape; only its explanation moved. The alternative that was considered and
23
- * refused: building a numbered line-menu out of `js/setup.mjs`'s `askChoice`. That is not a
23
+ * refused: building a numbered line-menu out of `js/maintain/setup.mjs`'s `askChoice`. That is not a
24
24
  * port of `_main_menu` — it is a THIRD user interface, differing from the reference on every
25
25
  * screen, unreachable from every cell (no cell has a TTY), and it would have to be deleted
26
26
  * the day P7c lands the real panel. The fallback is the reference's own contract for "no
@@ -37,14 +37,15 @@
37
37
  * one input no cell can vary. See that file's P7a section for what it reaches and what it
38
38
  * declares.
39
39
  */
40
- import { pyPrint, pyPrintErr, pyWhich } from './lib/fs.mjs';
41
- import { startDaemon } from './web/server.mjs';
40
+ import { printOut, printErr } from '../lib/fs.mjs';
41
+ import { which } from '../lib/paths.mjs';
42
+ import { startDaemon } from '../web/daemon.mjs';
42
43
 
43
44
  /** `_menu_help` — the off-TTY arm, and the fallback both TTY arms end in. */
44
45
  export function menuHelp() {
45
- pyPrint('Geneseed — no interactive menu here. Get started with: geneseed setup\n');
46
- pyPrint('Other commands: bootstrap · update · build · doctor · diff · tui · web\n');
47
- pyPrint('On a VT-capable terminal, a bare `./geneseed` opens the interactive menu of these.\n');
46
+ printOut('Geneseed — no interactive menu here. Get started with: geneseed setup\n');
47
+ printOut('Other commands: bootstrap · update · build · doctor · diff · tui · web\n');
48
+ printOut('On a VT-capable terminal, a bare `./geneseed` opens the interactive menu of these.\n');
48
49
  return 0;
49
50
  }
50
51
 
@@ -66,7 +67,7 @@ export function menuHelp() {
66
67
  */
67
68
  function browserAvailable() {
68
69
  if (process.platform === 'win32' || process.platform === 'darwin') return true;
69
- return pyWhich('xdg-open') !== null;
70
+ return which('xdg-open') !== null;
70
71
  }
71
72
 
72
73
  /**
@@ -103,14 +104,14 @@ export function cmdMenu() {
103
104
  // Python panel, and P2 took the pointer out rather than re-aiming it: the panel it named
104
105
  // is the Python this migration deletes, and a refusal that sends a user to a file which
105
106
  // will not be there is worse than one that simply says the screen does not exist.
106
- pyPrintErr('[menu] TUI unavailable (this entry has no full-screen menu).\n');
107
+ printErr('[menu] TUI unavailable (this entry has no full-screen menu).\n');
107
108
  return menuHelp();
108
109
  }
109
110
 
110
111
  /** `cmd_home` — the default for a bare `geneseed`. */
111
112
  export async function cmdHome(args) {
112
113
  if (webFirstOk()) {
113
- pyPrint('[geneseed] opening the web console — `geneseed menu` for the terminal UI, '
114
+ printOut('[geneseed] opening the web console — `geneseed menu` for the terminal UI, '
114
115
  + 'GENESEED_NO_WEB=1 to disable.\n');
115
116
  return startDaemon(null, 4747, true);
116
117
  }
@@ -6,11 +6,13 @@
6
6
  * `cmd_tui` (`rituals/_harness_tui.py`) is twenty-six lines and three arms. Off a TTY it
7
7
  * prints one line and returns 1; when `import curses` fails it prints one line and returns
8
8
  * 1; otherwise it builds the inventory and hands the screen to `curses.wrapper(_tui_loop)`.
9
- * **Every cell in `tests/harness_golden.py` runs off a TTY**, so the first arm is the whole
10
- * of `tui`'s behaviour that the acceptance matrix can reach, and it is ported byte for byte.
9
+ * NO CELL COULD EVER GIVE `cmd_tui` A TTY while the acceptance matrix was the gate, so the
10
+ * off-TTY arm was the whole of its reachable behaviour. That changed with the matrix:
11
+ * `tests/unit/no_panel.test.mjs` fakes `isTTY` true in a child and asserts BOTH arms — the
12
+ * exit code, and that the refusal names the console rather than pretending to draw.
11
13
  *
12
14
  * THE PANEL ITSELF IS P7C'S, AND THIS ENTRY FALLS BACK RATHER THAN INVENTING ONE — the same
13
- * decision `js/menu.mjs` made in P7a, for the same reason. `cmd_tui` already owns a fourth
15
+ * decision `js/ui/menu.mjs` made in P7a, for the same reason. `cmd_tui` already owns a fourth
14
16
  * path: an `except Exception` around `curses.wrapper` that prints `[tui] full-screen panel
15
17
  * unavailable (<reason>)` and returns 1, written for the Windows shim's `Unsupported`. That
16
18
  * is the reference's own contract for "no full-screen panel is available here", so the port
@@ -27,25 +29,24 @@
27
29
  * this port has shipped code before whose absence nothing would have noticed, and these are
28
30
  * not that.
29
31
  *
30
- * WHAT IS DELIBERATELY NOT HERE: `_bx` (its non-ASCII arm returns `curses.ACS_*` chtype ints,
31
- * which belong with the window), `_wrap_lines` (no consumer until the screens arrive, so
32
- * porting it now would be a reduced port with no caller to check it against), and every
33
- * `(stdscr, curses, pal)` drawing helper.
32
+ * WHAT IS DELIBERATELY NOT HERE: `_bx` (its non-ASCII arm returned `curses.ACS_*` chtype
33
+ * ints, which belonged with the window) and every `(stdscr, curses, pal)` drawing helper.
34
+ * `_wrap_lines` is NOT deferred but RETIRED — it reflowed a body into a fixed-width detail
35
+ * pane, the pane is gone, and the web console does its own wrapping.
34
36
  *
35
37
  * **DO NOT WRITE A SECOND LINE BREAKER.** This block used to argue that `textwrap.wrap` was
36
38
  * "a second `difflib` — a stdlib payload with no Node twin". It has one since P1:
37
- * `pyTextWrap` in `js/cli.mjs`, written because `--help` was the caller that finally
38
- * required it, frozen at `tests/__snapshots__/textwrap.json` and swept against the running
39
- * interpreter at every width from 11 to 198. When P7c brings `_wrap_lines` across it wraps
40
- * THAT, and the only open question is the ASCII/glyph width the panel measures in. `_parse_laws`, `load_registry`, `entity_status` and
41
- * `_tui_inventory` crossed in P6c and live in `js/inventory.mjs`.
39
+ * `wrapText` in `js/ui/cli.mjs`, written because `--help` was the caller that finally
40
+ * required it, frozen at `tests/__snapshots__/textwrap.json` at every width from 11 to 198.
41
+ * Anything that needs wrapping wraps THAT. `_parse_laws`, `load_registry`, `entity_status` and
42
+ * `_tui_inventory` crossed in P6c and live in `js/inspect/inventory.mjs`.
42
43
  */
43
44
  import path from 'node:path';
44
45
 
45
- import { THEMES } from './checkout.mjs';
46
- import { readJsonMaybe } from './installs.mjs';
47
- import { pySplitLines } from './lib/udiff.mjs';
48
- import { pyPrint } from './lib/fs.mjs';
46
+ import { THEMES } from '../build/source.mjs';
47
+ import { readJsonMaybe } from '../hosts/installs.mjs';
48
+ import { splitLines } from '../lib/udiff.mjs';
49
+ import { printOut } from '../lib/fs.mjs';
49
50
 
50
51
  // ---- display tiers -------------------------------------------------------------------
51
52
  //
@@ -84,12 +85,11 @@ export const GLYPH = glyphs(TUI_ASCII);
84
85
  // are the port's own, and no dependency was added to get them.
85
86
  //
86
87
  // A HAND-CARRIED UNICODE TABLE IS THE KIND OF THING THAT IS RIGHT UNTIL IT IS NOT, so the
87
- // gate over it is not a case list. `tests/test_pure_function_parity.py` walks EVERY
88
- // codepoint from U+0000 to U+2FFFF on both sides, run-length encodes the widths, and
89
- // compares the run lists — 196 608 inputs, and a range this table gets wrong has nowhere to
90
- // hide. The comparison is against Python's LIVE `unicodedata`, never against a copy of it,
91
- // so the day the reference's Unicode version moves under these tables the gate says so
92
- // instead of agreeing with itself.
88
+ // gate over it is not a case list. `tests/__snapshots__/dwidth.json` holds the whole
89
+ // U+0000..U+2FFFF sweep run-length encoded — 196 608 inputs, and a range this table gets wrong
90
+ // has nowhere to hide — replayed by `tests/snapshot/pure_snapshot.test.mjs`. It was RECORDED
91
+ // from Python's live `unicodedata` while that was still here; it cannot be re-recorded now, so
92
+ // a run that goes red is a change in these tables and never a change in the reference.
93
93
  //
94
94
  // THE VERSION IS DECLARED, not left in a comment, and that changed on the day the gate fired
95
95
  // for real. `python3.14` ships unidata 16.0.0, which assigned U+0897 a combining class these
@@ -97,9 +97,11 @@ export const GLYPH = glyphs(TUI_ASCII);
97
97
  // as designed, on a machine whose only difference was the interpreter. That is not a defect
98
98
  // in either implementation and it must not read as one: the sweep is a comparison against a
99
99
  // SPECIFIC Unicode version, and a comparison is meaningless against a different one. So the
100
- // version is a constant the test reads, `.github/workflows/ci.yml` pins the interpreter that
101
- // supplies it, and `tests/test_pure_function_parity.py` asserts the pin and the constant
102
- // still agree. Regenerating the tables means bumping all three together.
100
+ // version is a constant, `tests/__snapshots__/dwidth.json` records the version its runs were
101
+ // measured at, and `tests/snapshot/pure_snapshot.test.mjs` asserts the two still agree —
102
+ // reading the constant both by import and by regex out of this file, so the declaration
103
+ // cannot be satisfied by deleting it. There is no interpreter left to pin: CI now FAILS if
104
+ // python is findable at all.
103
105
  export const DWIDTH_UNIDATA = '15.1.0';
104
106
 
105
107
  //: East_Asian_Width W or F, below U+1F000 — above it the reference's own `>= 0x1F000` rule
@@ -332,10 +334,10 @@ const BAR_EIGHTHS = ' ▏▎▍▌▋▊▉█';
332
334
  export function progressBar(frac, width = 24) {
333
335
  const f = Math.max(0.0, Math.min(1.0, frac));
334
336
  if (TUI_ASCII) {
335
- const filled = pyRound(f * width);
337
+ const filled = roundHalfEven(f * width);
336
338
  return '#'.repeat(filled) + '-'.repeat(width - filled);
337
339
  }
338
- const eighths = pyRound(f * width * 8);
340
+ const eighths = roundHalfEven(f * width * 8);
339
341
  const full = Math.floor(eighths / 8);
340
342
  const rem = eighths % 8;
341
343
  if (full >= width) return '█'.repeat(width);
@@ -347,7 +349,7 @@ export function progressBar(frac, width = 24) {
347
349
  * one place in the file where that shows: a bar at exactly 0.5 of a cell is the frontier
348
350
  * case the eighths resolution exists to draw, and `round(0.5)` is 0 there and 1 here.
349
351
  */
350
- function pyRound(x) {
352
+ function roundHalfEven(x) {
351
353
  const f = Math.floor(x);
352
354
  const d = x - f;
353
355
  if (d > 0.5) return f + 1;
@@ -382,7 +384,7 @@ export function themeFlair(theme) {
382
384
  // the empty string where `''.split('\n')` answers `['']`, which would put a blank row
383
385
  // in the banner of every theme that omits one; and its boundary set is Python's rather
384
386
  // than `\s`, which P6d measured apart.
385
- banner: pySplitLines(banner),
387
+ banner: splitLines(banner),
386
388
  benediction: data.BENEDICTION ?? '',
387
389
  };
388
390
  }
@@ -462,9 +464,9 @@ export function detailLines(kind, label, data) {
462
464
  // because all three are a titled rule rather than a rendered spec. New kinds only: the nine
463
465
  // recorded cases name kinds that already existed, so no recorded answer moves.
464
466
  if (kind === 'law' || kind === 'doctrine' || kind === 'ontology') {
465
- return [label, ''].concat(data ? pySplitLines(data.body) : []);
467
+ return [label, ''].concat(data ? splitLines(data.body) : []);
466
468
  }
467
- if ((kind === 'agent' || kind === 'skill') && data) return pySplitLines(data.body);
469
+ if ((kind === 'agent' || kind === 'skill') && data) return splitLines(data.body);
468
470
  return null;
469
471
  }
470
472
 
@@ -476,12 +478,12 @@ export function detailLines(kind, label, data) {
476
478
  */
477
479
  export function cmdTui() {
478
480
  if (!process.stdin.isTTY) {
479
- pyPrint('[tui] not an interactive terminal. Use `geneseed setup`, `geneseed doctor`, or `geneseed build`.\n');
481
+ printOut('[tui] not an interactive terminal. Use `geneseed setup`, `geneseed doctor`, or `geneseed build`.\n');
480
482
  return 1;
481
483
  }
482
- // The pointer to the Python panel is gone for `js/menu.mjs`'s reason: the
484
+ // The pointer to the Python panel is gone for `js/ui/menu.mjs`'s reason: the
483
485
  // panel it named is the Python being deleted. What is left is true and stays true.
484
- pyPrint('[tui] full-screen panel unavailable (this entry carries the TUI\'s layout '
486
+ printOut('[tui] full-screen panel unavailable (this entry carries the TUI\'s layout '
485
487
  + 'half, not its screens). Use `geneseed setup`, `geneseed doctor`, or `geneseed build`.\n');
486
488
  return 1;
487
489
  }