geneseed 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/ui/cli.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  * copy in it.
11
11
  *
12
12
  * P10c's answer was to make the metadata DATA — generated into `cli.json` at the repo root by
13
- * `tests/gen_cli_reference.py` and read by both sides. P2 finishes the thought: the table is
13
+ * a since-deleted Python script and read by both sides. P2 finishes the thought: the table is
14
14
  * no longer GENERATED from anything, it is the OWNED document, and it lives here on a product
15
15
  * path because that is where a document npm ships belongs. `package.json`'s `files[]` already
16
16
  * carries `js/`, so it needs no row of its own, and nothing has to import out of `tests/`.
@@ -31,13 +31,9 @@
31
31
  * a file filtered for the page breaks the CLI, and a file in `cliSpec`'s shape cannot render
32
32
  * the docs. It stays exactly what the argparse walk produced.
33
33
  *
34
- * WHAT USED TO GUARD IT, AND WHAT DOES NOW. While the parser existed, `cliReferenceProblems()`
35
- * hashed `rituals/harness.py` and doctor reported drift on both binaries. That digest was over
36
- * a file this migration deletes, so it is gone; `tests/test_cli_reference.py` holds the
37
- * argparse-vs-table comparison, and it can only ever cover the rows argparse HAS: `catalog`,
38
- * `mcp` and `memory` never had a subparser and are outside it by construction. The successor
39
- * that checks the WHOLE table, and outlives the parser, is `tests/unit/cli_table.test.mjs`;
40
- * after that the table is simply the source of truth and is edited directly.
34
+ * WHAT GUARDS IT. The Python parser it was walked from is gone, and with it every comparison
35
+ * against argparse; the table is now simply the source of truth and is edited directly.
36
+ * `tests/unit/cli_table.test.mjs` checks the WHOLE table.
41
37
  *
42
38
  * THE COST, NAMED: this entry cannot parse anything without this file. `js/build/source.mjs`
43
39
  * already reads `harness.config.json` at import for every render, so a tracked data document
@@ -48,6 +44,7 @@ import { readFileSync } from 'node:fs';
48
44
  import path from 'node:path';
49
45
 
50
46
  import { ROOT } from '../build/source.mjs';
47
+ import { printOut } from '../lib/fs.mjs';
51
48
  import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
49
 
53
50
  const CLI_JSON = path.join(ROOT, 'js', 'cli-table.json');
@@ -156,8 +153,8 @@ function actionInvocation(a, positional) {
156
153
  // hyphenated word into two chunks, so it can break a line inside `back-compat` where a
157
154
  // space-only wrap cannot. Measured before deciding: every help string then in the table disagreed
158
155
  // with a greedy wrap at some width, the widest at 175 — so "the branch is dead" was simply
159
- // false, and `test_the_ports_line_breaker_is_textwrap_at_every_width` said so on the run that
160
- // first asserted it. The rules are transcribed instead.
156
+ // false, and the former Python suite's every-width comparison against `textwrap` said so on
157
+ // the run that first asserted it. The rules are transcribed instead.
161
158
  //
162
159
  // Python's classes, spelled for a `u`-flagged JavaScript regex: `\w` is letters, numbers and
163
160
  // underscore; `[^\d\W]` is that minus the DECIMAL digits, which leaves the letter-ish half
@@ -181,9 +178,8 @@ const WORDSEP = new RegExp(
181
178
  * `drop_whitespace`, `break_long_words` and `break_on_hyphens` all on, and the text already
182
179
  * whitespace-collapsed and stripped by the caller.
183
180
  *
184
- * ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer.
185
- * `rituals/_harness_tui*.py` calls `textwrap.wrap` in seven places and is declared unported;
186
- * move this when that changes, not before.
181
+ * ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer;
182
+ * move it when a second one appears, not before.
187
183
  */
188
184
  export function wrapText(text, width) {
189
185
  const chunks = text.split(WORDSEP).filter(Boolean).reverse();
@@ -310,7 +306,7 @@ function formatAction(a, positional, helpPosition, width) {
310
306
  * `parser.format_help()` for one subcommand, byte for byte with argparse.
311
307
  *
312
308
  * WHY THE PORT RENDERS THIS AT ALL. Task 10 compared the two implementations' `--help` for the
313
- * first time — `tests/harness_golden.py` has zero `--help` cells — and found the port answering
309
+ * first time — the former Python CLI matrix had zero `--help` cells — and found the port answering
314
310
  * `geneseed: error: unrecognized arguments: --help`, exit 2, on all 26 verbs. That is not the
315
311
  * declared wording divergence `parse`'s docblock reserves for argparse's ERROR text: it is the
316
312
  * absence of the surface, on the binary that survives the reference's deletion.
@@ -318,19 +314,19 @@ function formatAction(a, positional, helpPosition, width) {
318
314
  * `prog` IS AN ARGUMENT AND THAT IS THE ONE DELIBERATE DIVERGENCE. The reference names itself
319
315
  * `harness`, after the Python file this migration deletes; a user who typed `geneseed status
320
316
  * --help` is shown `usage: geneseed status`, which is also how every error this entry prints
321
- * already spells itself (`die`). `tests/__snapshots__/help/` freezes the reference's answers
322
- * and `tests/snapshot/cli_help.test.mjs` replays them through here with `prog` set to the reference's
323
- * own — so the layout is gated byte for byte and the rename is the only thing that moves.
317
+ * already spells itself (`die`). The former Python suite replayed the reference's recorded
318
+ * answers through here with `prog` set to the reference's own, so the layout was gated byte for
319
+ * byte and the rename was the only thing that moved. That corpus is gone; the layout is now
320
+ * pinned by the written-out `formatHelp` rows in `tests/unit/text_layout.test.mjs`.
324
321
  *
325
- * NOT EVERY VERB HAS A RECORDED ANSWER, AND THREE NEVER CAN. `catalog`, `mcp` and `memory` had
326
- * no subparser for the recorder to render from, so the corpus covers the verbs argparse
327
- * answered to and those three are held ABSOLUTELY instead — `tests/snapshot/cli_help.test.mjs` names
328
- * both populations (`RECORDED`, `NATIVE`) and fails a verb that falls into neither, which is
329
- * what keeps "no recorded text" from meaning "no gate".
322
+ * NOT EVERY VERB HAD A RECORDED ANSWER, AND THREE NEVER COULD. `catalog`, `mcp` and `memory` had
323
+ * no subparser for the recorder to render from, so they were held ABSOLUTELY instead — and
324
+ * `tests/unit/hook_cli.test.mjs`'s `NATIVE` list still names that population and fails a verb
325
+ * that falls into neither, which is what keeps "no recorded text" from meaning "no gate".
330
326
  *
331
327
  * The alias rides on the same argument: argparse gives `update` the parser object it built for
332
- * `upgrade`, so `harness.py update --help` prints `usage: harness upgrade`. The recorded prog
333
- * map reproduces that; the shipped call passes `geneseed update`, which is what the user typed.
328
+ * `upgrade`, so `harness.py update --help` printed `usage: harness upgrade`. The recorded prog
329
+ * map reproduced that; the shipped call passes `geneseed update`, which is what the user typed.
334
330
  *
335
331
  * `width` is argparse's `shutil.get_terminal_size().columns - 2`, passed in rather than read,
336
332
  * because a recorded help text whose wrap column came from the recorder's terminal is a corpus
@@ -407,12 +403,13 @@ export function formatHelp(cmd, prog, width) {
407
403
  *
408
404
  * `parseIntStrict`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
409
405
  * this `int(os.environ['COLUMNS'])` inside `except (KeyError, ValueError)`, and `int` is not
410
- * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values
411
- * (`test_the_ports_help_width_is_shutil_get_terminal_size`): `COLUMNS=1_0` is 10 to Python and
406
+ * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values in the former
407
+ * Python suite (`tests/unit/cli_table.test.mjs`'s 'the help width is the terminal width less
408
+ * two, and COLUMNS wins' keeps a written-out part of it): `COLUMNS=1_0` is 10 to Python and
412
409
  * 1 to `parseInt`, `COLUMNS=٣` is 3 and NaN, `COLUMNS=' 80 '` is 80 and NaN. `COLUMNS=80abc`
413
410
  * is a ValueError to Python and 80 to `parseInt` — the same answer down a PIPE, where the
414
411
  * fallback is 80 either way, and a different one on any terminal that is not 80 wide, which is
415
- * why the corpus is the gate rather than a cell. A non-positive value falls through on both
412
+ * why a unit test is the gate rather than a cell. A non-positive value falls through on both
416
413
  * sides, which is Python's `if columns <= 0` and was already right here.
417
414
  *
418
415
  * `stripWhitespace` because THIS is the caller `parseIntStrict`'s docblock did not have: its other two
@@ -443,8 +440,7 @@ export function printVerbList(prog, verbs, extra = {}) {
443
440
  `A bare \`${prog}\` runs \`home\`. \`${prog} <command> --help\` describes one command.`, '',
444
441
  'commands:'];
445
442
  for (const n of names) lines.push(` ${n.padEnd(pad)}${helpOf.get(n) || ''}`.trimEnd());
446
- const text = `${lines.join('\n')}\n`;
447
- process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
443
+ printOut(`${lines.join('\n')}\n`);
448
444
  return 0;
449
445
  }
450
446
 
@@ -453,18 +449,16 @@ export function printVerbList(prog, verbs, extra = {}) {
453
449
  * describe — the caller already owns that refusal and its message.
454
450
  *
455
451
  * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
456
- * `bin/geneseed-cli.mjs` the rest of the table, and
457
- * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
452
+ * `bin/geneseed-cli.mjs` the rest of the table, and `tests/unit/hook_cli.test.mjs`'s
453
+ * "the two entry points carry disjoint verb sets" keeps them from learning each other's
458
454
  * tables — so the help each prints has to come from here rather than from a copy in each.
459
455
  *
460
- * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
461
- * translates, and this text lands in the same terminals and the same pipes as that one.
456
+ * CRLF ON WINDOWS through `printOut`, the one funnel every CLI print takes.
462
457
  */
463
458
  export function printHelp(prog, verb) {
464
459
  const cmd = cliCommand(verb);
465
460
  if (cmd === null) return null;
466
- const text = formatHelp(cmd, `${prog} ${verb}`, helpWidth());
467
- process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
461
+ printOut(formatHelp(cmd, `${prog} ${verb}`, helpWidth()));
468
462
  return 0;
469
463
  }
470
464
 
package/js/ui/menu.mjs CHANGED
@@ -22,8 +22,8 @@
22
22
  * still the right shape; only its explanation moved. The alternative that was considered and
23
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
- * screen, unreachable from every cell (no cell has a TTY), and it would have to be deleted
26
- * the day P7c lands the real panel. The fallback is the reference's own contract for "no
25
+ * screen, unreachable from every cell (no cell has a TTY), for a panel that was rejected
26
+ * outright rather than deferred. The fallback is the reference's own contract for "no
27
27
  * full-screen menu available here"; a new UI would not be.
28
28
  *
29
29
  * `cmd_home` is the default for a bare `geneseed`, and it crosses WHOLE. Its two arms are
@@ -32,10 +32,9 @@
32
32
  *
33
33
  * `_web_first_ok`'s FIVE REFUSALS ARE THE PAYLOAD, and only two of them are reachable from a
34
34
  * cell: a cell's stdin is a pipe, so `isTTY` is false and everything past it is dead. The
35
- * other three (SSH, the Linux display server, the browser) are gated by the corpus in
36
- * `tests/test_pure_function_parity.py`, which drives this function with `isTTY` FAKED — the
37
- * one input no cell can vary. See that file's P7a section for what it reaches and what it
38
- * declares.
35
+ * other three (SSH, the Linux display server, the browser) are gated by
36
+ * `tests/unit/web_first.test.mjs`, which drives this function through
37
+ * `tests/fixtures/pure_probe.mjs` with `isTTY` FAKED — the one input no cell can vary.
39
38
  */
40
39
  import { printOut, printErr } from '../lib/fs.mjs';
41
40
  import { which } from '../lib/paths.mjs';
@@ -93,13 +92,8 @@ export function cmdMenu() {
93
92
  // The reference's own `except Exception` arm, with a reason that is permanent here rather
94
93
  // than machine-dependent. stderr, and CRLF on Windows, because `sys.stderr.write` does.
95
94
  //
96
- // P7b CORRECTED THE REASON RATHER THAN THE ARM. This module's header said the fallback was
97
- // reached because "`import curses` fails on stock Windows Python", and P7b measured that
98
- // to be false where it matters: `rituals/_harness_core.py` installs `rituals/_winterm.py`
99
- // as `sys.modules["curses"]` when the stdlib module is missing, so the reference opens its
100
- // panel on this machine and `tests/test_tui_boundary.py` drives it doing so. What is
101
- // missing is on THIS side — there is no window implementation in the port — and that is
102
- // what the message now says.
95
+ // The reason is on THIS side: there is no window implementation, by decision, and that is
96
+ // what the message says (`tests/unit/no_panel.test.mjs` keeps it so).
103
97
  // NOTHING TO POINT AT ANY MORE. The message used to end by sending the reader at the
104
98
  // Python panel, and P2 took the pointer out rather than re-aiming it: the panel it named
105
99
  // is the Python this migration deletes, and a refusal that sends a user to a file which
package/js/ui/tui.mjs CHANGED
@@ -21,13 +21,11 @@
21
21
  * deleted the day the real one crossed, would be a second user interface and not a port.
22
22
  *
23
23
  * WHAT DOES CROSS is the alignment contract — the functions below that decide what a row
24
- * looks like before any curses call is made. They have no caller in this file yet, and that
25
- * is deliberate and dated: **P7c is the due date**, when `_tui_loop` and the eighteen
26
- * screens of `_harness_tui_views.py` arrive and consume them. They are here now because they
27
- * are the half that can be gated exhaustively, and `tests/test_pure_function_parity.py`
28
- * gates every one of them in both glyph modes. Deleting any of them turns that gate red —
29
- * this port has shipped code before whose absence nothing would have noticed, and these are
30
- * not that.
24
+ * looks like: display width, fitting, glyph tiers, the catalogue's entries and detail lines.
25
+ * Each has a caller outside this file (the catalogue, the status panel, the web console).
26
+ * The panel itself was REJECTED, not deferred, so the helpers that existed only for it —
27
+ * the spinner, the wordmark, scroll clamping, the progress bar and the theme preview/flair
28
+ * readers — were deleted rather than kept waiting for a screen that will not come.
31
29
  *
32
30
  * WHAT IS DELIBERATELY NOT HERE: `_bx` (its non-ASCII arm returned `curses.ACS_*` chtype
33
31
  * ints, which belonged with the window) and every `(stdscr, curses, pal)` drawing helper.
@@ -37,14 +35,10 @@
37
35
  * **DO NOT WRITE A SECOND LINE BREAKER.** This block used to argue that `textwrap.wrap` was
38
36
  * "a second `difflib` — a stdlib payload with no Node twin". It has one since P1:
39
37
  * `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.
38
+ * required it, pinned by the written-out rows in `tests/unit/text_layout.test.mjs`.
41
39
  * Anything that needs wrapping wraps THAT. `_parse_laws`, `load_registry`, `entity_status` and
42
40
  * `_tui_inventory` crossed in P6c and live in `js/inspect/inventory.mjs`.
43
41
  */
44
- import path from 'node:path';
45
-
46
- import { THEMES } from '../build/source.mjs';
47
- import { readJsonMaybe } from '../hosts/installs.mjs';
48
42
  import { splitLines } from '../lib/udiff.mjs';
49
43
  import { printOut } from '../lib/fs.mjs';
50
44
 
@@ -58,7 +52,6 @@ import { printOut } from '../lib/fs.mjs';
58
52
  const TUI_ASCII = !!process.env.GENESEED_TUI_ASCII;
59
53
  const TUI_PLAIN = !!process.env.GENESEED_TUI_PLAIN;
60
54
  const TUI_EMOJI = !(TUI_ASCII || TUI_PLAIN);
61
- const TUI_ANIM = TUI_EMOJI;
62
55
 
63
56
  /** `_glyphs` — the one glyph table for the whole TUI, unicode or ASCII stand-ins. */
64
57
  export function glyphs(asciiMode) {
@@ -269,119 +262,18 @@ export function mark(kind) {
269
262
  return TUI_ASCII ? asc : (TUI_EMOJI ? emoji : sym);
270
263
  }
271
264
 
272
- const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
273
-
274
- /** `_spin` — one spinner frame for tick `i`; a static dot in the non-animated tiers. */
275
- export function spin(i) {
276
- if (!TUI_ANIM) return TUI_ASCII ? '-' : '·';
277
- const frames = TUI_ASCII ? '|/-\\' : SPIN;
278
- // `frames[i % len]` on the reference indexes CODE POINTS; the braille frames are BMP so
279
- // UTF-16 indexing agrees, but spelling it through `[...frames]` keeps that an argument
280
- // about the data rather than a coincidence. Python's `%` on a negative `i` returns a
281
- // non-negative remainder and JavaScript's does not, so the modulo is corrected.
282
- const f = [...frames];
283
- return f[((i % f.length) + f.length) % f.length];
284
- }
285
-
286
- const LOGO_FONT = {
287
- G: [' ### ', '# ', '# ## ', '# # ', ' ### '],
288
- E: ['#### ', '# ', '### ', '# ', '#### '],
289
- N: ['# #', '## #', '# # #', '# ##', '# #'],
290
- S: [' ####', '# ', ' ### ', ' #', '#### '],
291
- D: ['### ', '# # ', '# #', '# # ', '### '],
292
- };
293
-
294
- /** `_logo_lines` — the GENESEED wordmark as 5 text rows. */
295
- export function logoLines() {
296
- const ink = TUI_ASCII ? '#' : '█';
297
- const rows = [];
298
- for (let r = 0; r < 5; r += 1) {
299
- rows.push([...'GENESEED'].map((c) => LOGO_FONT[c][r].replaceAll('#', ink)).join(' '));
300
- }
301
- return rows;
302
- }
303
-
304
- // ---- layout maths --------------------------------------------------------------------
305
-
306
- /** `_clamp` — clamp a scroll offset so [top, top+viewH) stays inside `total`. */
307
- export function clamp(top, total, viewH) {
308
- return Math.max(0, Math.min(top, Math.max(0, total - viewH)));
309
- }
310
-
311
- const BAR_EIGHTHS = ' ▏▎▍▌▋▊▉█';
312
-
313
- /** `_progress_bar` — a determinate bar exactly `width` display columns wide. */
314
- export function progressBar(frac, width = 24) {
315
- const f = Math.max(0.0, Math.min(1.0, frac));
316
- if (TUI_ASCII) {
317
- const filled = roundHalfEven(f * width);
318
- return '#'.repeat(filled) + '-'.repeat(width - filled);
319
- }
320
- const eighths = roundHalfEven(f * width * 8);
321
- const full = Math.floor(eighths / 8);
322
- const rem = eighths % 8;
323
- if (full >= width) return '█'.repeat(width);
324
- return '█'.repeat(full) + [...BAR_EIGHTHS][rem] + ' '.repeat(width - full - 1);
325
- }
326
-
327
- /**
328
- * `round()` — Python rounds HALF TO EVEN and `Math.round` rounds half UP, and this is the
329
- * one place in the file where that shows: a bar at exactly 0.5 of a cell is the frontier
330
- * case the eighths resolution exists to draw, and `round(0.5)` is 0 there and 1 here.
331
- */
332
- function roundHalfEven(x) {
333
- const f = Math.floor(x);
334
- const d = x - f;
335
- if (d > 0.5) return f + 1;
336
- if (d < 0.5) return f;
337
- return f % 2 === 0 ? f : f + 1;
338
- }
339
-
340
- // ---- theme reads ---------------------------------------------------------------------
341
-
342
- /** `_theme_preview` — right-panel preview rows for a theme, as (kind, text) pairs. */
343
- export function themePreview(key) {
344
- const data = readJsonMaybe(path.join(THEMES, `${key}.json`));
345
- if (!data || typeof data !== 'object') return [['dim', '(no preview available)']];
346
- const lines = [['title', key], ['', '']];
347
- if (data.TAGLINE) lines.push(['dim', data.TAGLINE], ['', '']);
348
- if (data.LOADED_SIGIL) lines.push(['ok', data.LOADED_SIGIL], ['', '']);
349
- if (data.VOICE) lines.push(['', `Voice — ${data.VOICE}`], ['', '']);
350
- if (data.LEX_SEALED_SECRETS) lines.push(['', `e.g. Rule I — ${data.LEX_SEALED_SECRETS}`]);
351
- if (data.BENEDICTION) lines.push(['', ''], ['dim', data.BENEDICTION]);
352
- return lines;
353
- }
354
-
355
- /** `_theme_flair` — the voice elements the setup chrome speaks in once a theme is chosen. */
356
- export function themeFlair(theme) {
357
- const data = readJsonMaybe(path.join(THEMES, `${theme}.json`)) || {};
358
- const banner = data.BANNER ?? '';
359
- return {
360
- accent: data.ACCENT ?? 'cyan',
361
- tagline: data.TAGLINE ?? '',
362
- sigil: data.LOADED_SIGIL ?? '',
363
- // `str.splitlines()` — `js/lib/udiff.mjs`' twin, already gated. It answers `[]` for
364
- // the empty string where `''.split('\n')` answers `['']`, which would put a blank row
365
- // in the banner of every theme that omits one; and its boundary set is Python's rather
366
- // than `\s`, which P6d measured apart.
367
- banner: splitLines(banner),
368
- benediction: data.BENEDICTION ?? '',
369
- };
370
- }
371
-
372
265
  // ---- the inventory's two consumers ----------------------------------------------------
373
266
 
374
267
  /**
375
268
  * `_tui_entries` — the ordered (kind, label, data) rows for the left list. `head` is a
376
269
  * section divider and is not selectable.
377
270
  *
378
- * ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. This function has two
379
- * recorded cases per platform in `tests/__snapshots__/primitives/`, replayed under
380
- * `deepStrictEqual`, and their argument is a synthetic inventory carrying exactly
381
- * `{agents, skills, laws, theme}` — no recorder survives to re-bless them. Reading
382
- * `inv.doctrines.length` there THROWS; emitting a head unconditionally changes the recorded
383
- * result array. Keyed on presence, the recording renders byte-identical and a real inventory
384
- * gets all three tiers.
271
+ * ⚠ THE TWO NEW TIERS ARE GUARDED, AND THE GUARD IS NOT DEFENSIVENESS. When they landed, this
272
+ * function had two recorded cases per platform in the former Python suite's corpus, and their
273
+ * argument was a synthetic inventory carrying exactly `{agents, skills, laws, theme}`. Reading
274
+ * `inv.doctrines.length` on such an inventory THROWS; emitting a head unconditionally changes
275
+ * the result array. Keyed on presence, an inventory without the tiers renders as it always did
276
+ * and a real inventory gets all three tiers.
385
277
  *
386
278
  * The order is CONSTITUTIONAL and not alphabetical: ontology, then invariants, then doctrines
387
279
  * — the order they are read in, the order AGENT.md renders them, and the order the console's
@@ -435,12 +327,11 @@ export function tuiEntries(inv) {
435
327
  * because line 554 moves `sel` onto the first SELECTABLE row — but line 555 is
436
328
  * `if not selectable: sel = 0`, and row 0 is always the AGENTS header. So an inventory with
437
329
  * no agents, no skills AND no laws crashes the panel on its first frame instead of drawing
438
- * an empty state. `_TUI_INV_EMPTY` in `tests/test_pure_function_parity.py` is the corpus
330
+ * an empty state. `_TUI_INV_EMPTY` in the former Python parity suite is the corpus
439
331
  * entry that surfaced it. **P7c must not reproduce the crash when it ports `_tui_loop`** —
440
332
  * the fix is one `or []` at the call site, and it belongs in the same change as the port.
441
333
  *
442
- * Its only consumer with direct assertions is `tests/unit/setup.test.mjs`; tests/fixtures/pure_probe.mjs
443
- * also dispatches it to exercise wizard/web_first test scenarios.
334
+ * Its only consumer with direct assertions is `tests/unit/setup.test.mjs`.
444
335
  */
445
336
  export function detailLines(kind, label, data) {
446
337
  // The three constitutional tiers share one treatment — the label, a blank, then the body —