geneseed 3.1.0 → 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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -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 +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
@@ -0,0 +1,181 @@
1
+ /**
2
+ * String primitives — measuring, padding, stripping, decoding and reading an integer.
3
+ *
4
+ * These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
5
+ * measurements against CPython are recorded per function, and why NONE of these is the Node
6
+ * default — the warning the retired `py` prefix used to carry now lives in each docblock.
7
+ * Every function here is a place the Node default DIVERGES from the answer this tool needs:
8
+ * `codePointLength` counts code points where `.length` counts units, `WHITESPACE` is a wider
9
+ * set than `String.trim`'s, `parseIntStrict` accepts what `Number()` does not and rejects what
10
+ * it does, and `percentDecode` leaves a bad escape alone where `decodeURIComponent` throws.
11
+ */
12
+ /**
13
+ * `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
14
+ *
15
+ * They differ on every astral character: `len("𝔊")` is 1 and `"𝔊".length` is 2. Only
16
+ * matters where a length becomes layout, which is exactly what the status panel does with
17
+ * it — a theme name or a memory path outside the BMP would shear the frame by one column
18
+ * per character under `.length`.
19
+ */
20
+ export const codePointLength = (s) => [...s].length;
21
+
22
+ /**
23
+ * `str.ljust(width)` — pad with spaces to `width` CODE POINTS, never truncate.
24
+ *
25
+ * `String.padEnd` counts UTF-16 units, so it is `codePointLength`'s difference again, one call later.
26
+ */
27
+ export const padEndToWidth = (s, width) => s + ' '.repeat(Math.max(0, width - codePointLength(s)));
28
+
29
+ /**
30
+ * `int(s)` for a base-10 string — the VALUE, or null where Python raises `ValueError`.
31
+ *
32
+ * Null rather than a throw because EVERY call site branches on the failure rather than
33
+ * propagating it: `askChoice` falls through to matching the answer against the option KEYS,
34
+ * `javaMajorOk` treats an unparseable major as "not new enough", and the terminal-width,
35
+ * port and timeout readers each fall back to a default. Python spells that `except ValueError`.
36
+ *
37
+ * Three things separate this from `Number(s)` and each one changes an answer:
38
+ *
39
+ * * `Number('')` is 0 and `int('')` raises. So is `Number(' ')`, and `Number('0x10')` is
40
+ * 16 where `int('0x10')` raises — a wizard answer of `0x2` must fall through to the key
41
+ * match, not select option 16.
42
+ * * PEP 515 underscores: `int('1_0')` is 10, `Number('1_0')` is NaN. Leading, trailing and
43
+ * doubled ones still raise, so the rule is "between digits" and not "strip them".
44
+ * * `int()` accepts any Unicode decimal digit — `int('٣')` is 3 — and `Number('٣')` is NaN.
45
+ * Every `Nd` block is exactly ten contiguous code points, so a digit's value is its
46
+ * offset from the first `Nd` code point at or below it, which is what the walk finds.
47
+ *
48
+ * Surrounding whitespace is Python's to strip and this does NOT strip it: `int(' 1')` is 1
49
+ * where this answers null. STRIPPING IS THE CALLER'S JOB, and the callers that are handed a
50
+ * raw environment value do it — `js/ui/cli.mjs`'s `helpWidth` and `js/web/activity.mjs` wrap
51
+ * the argument in `stripWhitespace` first.
52
+ *
53
+ * Gated by a corpus in `tests/test_pure_function_parity.py` — a primitive reproduction gets
54
+ * one owner and a corpus, never a cell (P5c).
55
+ */
56
+ export function parseIntStrict(s) {
57
+ if (typeof s !== 'string' || s === '') return null;
58
+ let body = s;
59
+ let sign = 1;
60
+ if (body[0] === '+' || body[0] === '-') {
61
+ if (body[0] === '-') sign = -1;
62
+ body = body.slice(1);
63
+ }
64
+ const chars = [...body];
65
+ if (chars.length === 0) return null;
66
+ let value = 0n;
67
+ let prevWasDigit = false;
68
+ for (const ch of chars) {
69
+ if (ch === '_') {
70
+ // Only BETWEEN digits: a leading, trailing or doubled underscore is a ValueError.
71
+ if (!prevWasDigit) return null;
72
+ prevWasDigit = false;
73
+ continue;
74
+ }
75
+ if (!/^\p{Nd}$/u.test(ch)) return null;
76
+ const cp = ch.codePointAt(0);
77
+ let zero = cp;
78
+ while (zero > 0 && /^\p{Nd}$/u.test(String.fromCodePoint(zero - 1))) zero -= 1;
79
+ value = value * 10n + BigInt(cp - zero);
80
+ prevWasDigit = true;
81
+ }
82
+ if (!prevWasDigit) return null; // a trailing underscore
83
+ return Number(sign === -1 ? -value : value);
84
+ }
85
+
86
+ /**
87
+ * Python's `\s` for a `str` pattern, as a character-class BODY — measured, not recalled.
88
+ *
89
+ * The two languages' whitespace classes are nearly the same and the difference had been
90
+ * carried as a standing item reading "differing from JS `\s` only at U+FEFF". That was
91
+ * incomplete in both directions, and a corpus over `slugifyHeading` is what found it:
92
+ *
93
+ * python \s = js \s - U+FEFF + U+001C..U+001F + U+0085
94
+ *
95
+ * JavaScript counts the byte-order mark as whitespace and Python does not; Python counts
96
+ * the four C0 information separators and the NEL, and JavaScript does not. Neither shows
97
+ * up in a docs heading anyone has written — which is exactly why it took a corpus rather
98
+ * than a cell, and why it is spelled out here rather than approximated with `\s`.
99
+ */
100
+ export const WHITESPACE = '\t\n\v\f\r \u001c-\u001f\u0085\u00a0\u1680'
101
+ + '\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
102
+
103
+ const STRIP_RE = new RegExp(`^[${WHITESPACE}]+|[${WHITESPACE}]+$`, 'g');
104
+ const STRIP_START_RE = new RegExp(`^[${WHITESPACE}]+`);
105
+ const STRIP_END_RE = new RegExp(`[${WHITESPACE}]+$`);
106
+
107
+ /** `str.strip()` — Python's whitespace set, which `String.trim()`'s is not. */
108
+ export function stripWhitespace(s) {
109
+ return s.replace(STRIP_RE, '');
110
+ }
111
+
112
+ /**
113
+ * `str.lstrip()` / `str.rstrip()` — the ONE-SIDED halves, over the same `WHITESPACE`.
114
+ *
115
+ * P2 added them for `_build_render._insert_theme_keys`, which uses all three spellings within
116
+ * four lines (`lines[0].strip()`, `ln.lstrip().startswith('"')`,
117
+ * `lines[pred].rstrip().endswith(",")`). They are here rather than in `js/build/themes.mjs` for the
118
+ * reason this file exists at all: `js/hosts/settings.mjs` already carries a private second copy of
119
+ * the character class, and a THIRD one — in the module that decides where a comma goes in a
120
+ * committed theme file — is how the three drift apart. One owner of the set, three views of it.
121
+ */
122
+ export function stripWhitespaceStart(s) {
123
+ return s.replace(STRIP_START_RE, '');
124
+ }
125
+
126
+ export function stripWhitespaceEnd(s) {
127
+ return s.replace(STRIP_END_RE, '');
128
+ }
129
+ /**
130
+ * `urllib.parse.unquote(s)` — percent-decoding as Python does it, which is NOT
131
+ * `decodeURIComponent`.
132
+ *
133
+ * The whole difference is what happens to a sequence that is not an escape.
134
+ * `decodeURIComponent('a%ZZ')` throws a `URIError`, and so does a trailing bare `%`; the
135
+ * web shell would answer both as a 500 where the reference answers a 404 naming the
136
+ * literal text. `unquote` leaves them alone: CPython builds a table of every two-hex-digit
137
+ * pair in both cases and treats a `KeyError` as "this was not an escape", emitting `%` and
138
+ * the rest of the fragment verbatim.
139
+ *
140
+ * The outer split on `([\x00-\x7f]+)` is Python's too, and it is not decoration: only the
141
+ * ASCII runs are candidates for decoding, so a literal non-ASCII character already in the
142
+ * path is passed through rather than mixed into the byte buffer. Consecutive escapes ARE
143
+ * accumulated before decoding, which is what makes `%C3%A9` one `é` and not two
144
+ * replacement characters.
145
+ *
146
+ * `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
147
+ * `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
148
+ * truncated multi-byte sequence yields — Python's maximal-subpart rule against WHATWG's —
149
+ * and the corpus in `tests/test_pure_function_parity.py` is where that is measured rather
150
+ * than assumed.
151
+ */
152
+ const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
153
+
154
+ function unquoteToBytes(run) {
155
+ const bits = run.split('%');
156
+ if (bits.length === 1) return Buffer.from(run, 'latin1');
157
+ const out = [Buffer.from(bits[0], 'latin1')];
158
+ for (const item of bits.slice(1)) {
159
+ const pair = item.slice(0, 2);
160
+ if (HEX_PAIR.test(pair)) {
161
+ out.push(Buffer.from([parseInt(pair, 16)]), Buffer.from(item.slice(2), 'latin1'));
162
+ } else {
163
+ out.push(Buffer.from(`%${item}`, 'latin1'));
164
+ }
165
+ }
166
+ return Buffer.concat(out);
167
+ }
168
+
169
+ export function percentDecode(s) {
170
+ if (!s.includes('%')) return s;
171
+ // `re.split` with ONE capture group alternates [gap, group, gap, group, …], so the ASCII
172
+ // runs are the odd indices on both sides.
173
+ const bits = s.split(/([\x00-\x7f]+)/);
174
+ let res = bits[0];
175
+ for (let i = 1; i < bits.length; i += 2) {
176
+ res += unquoteToBytes(bits[i]).toString('utf-8');
177
+ res += bits[i + 1] ?? '';
178
+ }
179
+ return res;
180
+ }
181
+
package/js/lib/udiff.mjs CHANGED
@@ -260,7 +260,7 @@ export function unifiedDiff(a, b, {
260
260
  */
261
261
  const LINE_BOUNDARY = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
262
262
 
263
- export function pySplitLines(text) {
263
+ export function splitLines(text) {
264
264
  const out = [];
265
265
  let last = 0;
266
266
  LINE_BOUNDARY.lastIndex = 0;
@@ -30,9 +30,9 @@
30
30
  import { readdirSync, statSync, unlinkSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
 
33
- import { memoryDropIndex } from './hooks.mjs';
34
- import { resolveMemoryDir } from './hosts.mjs';
35
- import { pyPrint, pyPrintErr } from './lib/fs.mjs';
33
+ import { memoryDropIndex } from '../hosts/hooks.mjs';
34
+ import { resolveMemoryDir } from '../hosts/hosts.mjs';
35
+ import { printOut, printErr } from '../lib/fs.mjs';
36
36
 
37
37
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
38
38
 
@@ -77,22 +77,22 @@ export function cmdMemory(args) {
77
77
  if (dir === null) {
78
78
  // The resolver answers null for "no store anywhere on the chain", which is also what an
79
79
  // explicit `--memory` that is not a directory gives — so the message names both.
80
- pyPrintErr('[memory] no memory store found. Pass --memory <dir>, set $GENESEED_MEMORY, '
80
+ printErr('[memory] no memory store found. Pass --memory <dir>, set $GENESEED_MEMORY, '
81
81
  + 'or run this from a directory with a memory/ store.\n');
82
82
  return 1;
83
83
  }
84
84
  if (args.action === 'list') {
85
85
  const facts = memoryFacts(dir);
86
86
  if (!facts.length) {
87
- pyPrint(`[memory] ${dir}: no facts.\n`);
87
+ printOut(`[memory] ${dir}: no facts.\n`);
88
88
  return 0;
89
89
  }
90
- pyPrint(`[memory] ${dir}\n`);
91
- for (const name of facts) pyPrint(` ${name}\n`);
90
+ printOut(`[memory] ${dir}\n`);
91
+ for (const name of facts) printOut(` ${name}\n`);
92
92
  return 0;
93
93
  }
94
94
  if (!args.name) {
95
- pyPrintErr('[memory] rm needs the name of a fact.\n');
95
+ printErr('[memory] rm needs the name of a fact.\n');
96
96
  return 2;
97
97
  }
98
98
  if (!memoryDelete(dir, args.name)) {
@@ -100,9 +100,9 @@ export function cmdMemory(args) {
100
100
  // `NotFound(name)` for a reserved name, a name with a separator and a fact that is not
101
101
  // there. Telling a caller that `MEMORY` exists but is protected is a distinction this
102
102
  // verb has no reason to draw.
103
- pyPrintErr(`[memory] no such fact '${args.name}' in ${dir}.\n`);
103
+ printErr(`[memory] no such fact '${args.name}' in ${dir}.\n`);
104
104
  return 1;
105
105
  }
106
- pyPrint(`[memory] deleted ${args.name}.\n`);
106
+ printOut(`[memory] deleted ${args.name}.\n`);
107
107
  return 0;
108
108
  }
@@ -24,25 +24,27 @@
24
24
  * THE TWO TRIGGERS, AND WHY IT TAKES BOTH. Work is needed when the machine's wiring is
25
25
  * `legacy` (see `migrateShape` — and note that shapes 2 and 3 differ only inside the shim
26
26
  * body, never in the settings file) OR when the stamp does not name this ROOT. Neither alone
27
- * is enough: an OpenCode-only machine wires no hooks at all, so its shape is permanently
28
- * `none` and a shape-only trigger would re-emit it on every run forever; and a user who
29
- * reinstalls the old Python build flips the shape back to `legacy` while the stamp still
30
- * matches, which must migrate again. Both inputs, and the stamp is written only on success.
27
+ * was enough: an OpenCode-only machine wires no hooks at all, so its shape is permanently
28
+ * `none` and a shape-only trigger would re-emit it on every run forever; and reinstalling the
29
+ * old build flipped the shape back to `legacy` while the stamp still matched, which had to
30
+ * migrate again. With the old build gone the second case can no longer arise, but the shape
31
+ * read stays: it is what makes the verb idempotent rather than stamp-dependent. The stamp is
32
+ * written only on success.
31
33
  */
32
34
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
33
35
  import path from 'node:path';
34
36
 
35
- import { main as driverMain } from '../bin/geneseed.mjs';
36
- import { ROOT } from './checkout.mjs';
37
+ import { main as driverMain } from '../../bin/build-driver.mjs';
38
+ import { ROOT } from '../build/source.mjs';
37
39
  import {
38
40
  EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
39
41
  footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
40
- } from './installs.mjs';
41
- import { DEFAULT_EMIT, setupBuildArgs } from './generate.mjs';
42
+ } from '../hosts/installs.mjs';
43
+ import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
42
44
  import {
43
45
  autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
44
- } from './settings.mjs';
45
- import { writeText, pyPrint, pyPrintErr } from './lib/fs.mjs';
46
+ } from '../hosts/settings.mjs';
47
+ import { writeText, printOut, printErr } from '../lib/fs.mjs';
46
48
 
47
49
  /** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
48
50
  const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
@@ -50,7 +52,7 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
50
52
  /**
51
53
  * The settings file a host wires hooks into, or null for a host that has none.
52
54
  *
53
- * Only Claude and Bob carry hook commands. OpenCode's hooks are a PLUGIN (`js/opencode.mjs`
55
+ * Only Claude and Bob carry hook commands. OpenCode's hooks are a PLUGIN (`js/hosts/opencode.mjs`
54
56
  * emits JS that runs in the host's own process and names neither the shim nor `harness.py`),
55
57
  * and Copilot has no hook mechanism at all. So those two classify as `none` and their whole
56
58
  * migration is the re-emit — which is the same re-emit, in the same change, on all three
@@ -150,34 +152,34 @@ export function cmdMigrate(args = {}) {
150
152
  if (unrecognised.length) {
151
153
  // Refuse the WHOLE run. A migration that skipped the row it did not understand and
152
154
  // migrated the rest is the half-migrated machine this verb exists to prevent.
153
- pyPrintErr('[migrate] REFUSED — unrecognised install marker(s):\n');
155
+ printErr('[migrate] REFUSED — unrecognised install marker(s):\n');
154
156
  for (const u of unrecognised) {
155
- pyPrintErr(`[migrate] ${u.host}:${u.scope} (${u.root}) has .geneseed-emit `
157
+ printErr(`[migrate] ${u.host}:${u.scope} (${u.root}) has .geneseed-emit `
156
158
  + `'${u.marker}', which is not an emit this generator answers to.\n`);
157
159
  }
158
- pyPrintErr('[migrate] Nothing was changed. Fix or delete the marker, then re-run.\n');
160
+ printErr('[migrate] Nothing was changed. Fix or delete the marker, then re-run.\n');
159
161
  return 3;
160
162
  }
161
163
 
162
164
  if (!rows.length) {
163
- pyPrint('[migrate] no active installs detected.\n');
165
+ printOut('[migrate] no active installs detected.\n');
164
166
  return 0;
165
167
  }
166
168
 
167
169
  if (!needed) {
168
- pyPrint(`[migrate] already on the npm shape (${rows.length} install(s)); nothing to do.\n`);
170
+ printOut(`[migrate] already on the npm shape (${rows.length} install(s)); nothing to do.\n`);
169
171
  return 0;
170
172
  }
171
173
 
172
- pyPrint(`[migrate] ${rows.length} install(s), wiring shape: ${shape}\n`);
174
+ printOut(`[migrate] ${rows.length} install(s), wiring shape: ${shape}\n`);
173
175
  if (dryRun) {
174
176
  for (const r of rows) {
175
- pyPrint(`[migrate] would re-emit ${r.host}:${r.scope} (${r.root})\n`);
177
+ printOut(`[migrate] would re-emit ${r.host}:${r.scope} (${r.root})\n`);
176
178
  }
177
179
  for (const p of autostartFindings()) {
178
- pyPrint(`[migrate] would report the autostart entry at ${p}\n`);
180
+ printOut(`[migrate] would report the autostart entry at ${p}\n`);
179
181
  }
180
- pyPrint('[migrate] --dry-run: nothing was written.\n');
182
+ printOut('[migrate] --dry-run: nothing was written.\n');
181
183
  return 0;
182
184
  }
183
185
 
@@ -195,7 +197,7 @@ export function cmdMigrate(args = {}) {
195
197
  saved.push([p, dest]);
196
198
  }
197
199
  } catch (e) {
198
- pyPrintErr(`[migrate] could not stage a backup (${e.message}); nothing was changed.\n`);
200
+ printErr(`[migrate] could not stage a backup (${e.message}); nothing was changed.\n`);
199
201
  return 1;
200
202
  }
201
203
 
@@ -222,15 +224,15 @@ export function cmdMigrate(args = {}) {
222
224
  postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
223
225
  doctrinesForBuild(r.root));
224
226
  const label = `${r.host}:${r.scope} (${r.root})`;
225
- pyPrint(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
227
+ printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
226
228
  let rc = 1;
227
229
  try { rc = driverMain(argv); } catch { rc = 1; }
228
230
  if (rc !== 0) {
229
231
  // Step 4 — ALL OR NOTHING. `rebuild-all` continues here on purpose; a migration
230
232
  // must not, or the machine is left half on each shape.
231
- pyPrintErr(`[migrate] FAILED ${label} (exit ${rc}) — rolling back.\n`);
233
+ printErr(`[migrate] FAILED ${label} (exit ${rc}) — rolling back.\n`);
232
234
  restore();
233
- pyPrintErr(`[migrate] rolled back ${saved.length} file(s); the install is unchanged.\n`);
235
+ printErr(`[migrate] rolled back ${saved.length} file(s); the install is unchanged.\n`);
234
236
  return 1;
235
237
  }
236
238
  }
@@ -241,7 +243,7 @@ export function cmdMigrate(args = {}) {
241
243
  writeText(stampPath(), `${ROOT}\n`);
242
244
  } catch { /* an unwritable stamp costs a redundant re-emit next run, never correctness */ }
243
245
 
244
- pyPrint(`[migrate] migrated ${rows.length} install(s).\n`);
246
+ printOut(`[migrate] migrated ${rows.length} install(s).\n`);
245
247
 
246
248
  // Step 5 — what could NOT be fixed automatically. Reported, never rewritten: nothing in
247
249
  // this repository has ever written an autostart entry, so migrate does not own the file.
@@ -250,7 +252,7 @@ export function cmdMigrate(args = {}) {
250
252
  // `web restart` and `web status` stay blind to the server this note just told someone to
251
253
  // launch at every login. Derived, not trusted: `TheMigrateNoteAdvisesARealWebAction`.
252
254
  for (const p of autostartFindings()) {
253
- pyPrint(`[migrate] NOTE: the autostart entry at ${p} still names another checkout — `
255
+ printOut(`[migrate] NOTE: the autostart entry at ${p} still names another checkout — `
254
256
  + 'update it by hand to run: geneseed web start --no-browser\n');
255
257
  }
256
258
  return 0;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `rituals/_harness_setup.py`'s wizard — the LINE-MODE install flow, and the first verb in
2
+ * The install wizard — the LINE-MODE flow, and the first verb in
3
3
  * this port that reads stdin.
4
4
  *
5
5
  * WHAT `setup` ACTUALLY IS, measured rather than taken from the brief. `cmd_setup` is 21
@@ -44,18 +44,20 @@ import { spawnSync } from 'node:child_process';
44
44
  import { existsSync, readSync } from 'node:fs';
45
45
  import path from 'node:path';
46
46
 
47
- import { main as driverMain, resolveOut } from '../bin/geneseed.mjs';
48
- import { playLine } from './anim.mjs';
49
- import { discoverNames, PACK_ORDER } from './checkout.mjs';
50
- import { exportImprovements } from './diff.mjs';
51
- import { cmdDoctor } from './doctor.mjs';
52
- import { setupBuildArgs } from './generate.mjs';
53
- import { GLOBAL_MANIFEST, opencodeConfigDir } from './hosts.mjs';
47
+ import { main as driverMain, resolveOut } from '../../bin/build-driver.mjs';
48
+ import { playLine } from '../ui/anim.mjs';
49
+ import { discoverNames, PACK_ORDER } from '../build/source.mjs';
50
+ import { exportImprovements } from '../inspect/diff.mjs';
51
+ import { cmdDoctor } from '../inspect/doctor.mjs';
52
+ import { setupBuildArgs } from '../build/generate.mjs';
53
+ import { GLOBAL_MANIFEST, opencodeConfigDir } from '../hosts/hosts.mjs';
54
54
  import {
55
55
  defaultMode, defaultPosture, defaultTheme, installedDefaults, themeFiles,
56
- } from './installs.mjs';
57
- import { pyInt, pyPrint, pyPrintErr, pyWhich } from './lib/fs.mjs';
58
- import { NO_WINDOW } from './lib/proc.mjs';
56
+ } from '../hosts/installs.mjs';
57
+ import { printOut, printErr } from '../lib/fs.mjs';
58
+ import { which } from '../lib/paths.mjs';
59
+ import { parseIntStrict } from '../lib/text.mjs';
60
+ import { NO_WINDOW } from '../lib/proc.mjs';
59
61
 
60
62
  // --------------------------------------------------------------------------------------
61
63
  // the three readers
@@ -68,7 +70,7 @@ import { NO_WINDOW } from './lib/proc.mjs';
68
70
  * until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
69
71
  * until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
70
72
  * `input()` does, and it is the only synchronous shape that stops where Enter stops. The
71
- * body arrived in P5h inside `js/uninstall.mjs`'s `confirm`; `setup` is the second caller,
73
+ * body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`; `setup` is the second caller,
72
74
  * so it moved to where the Python's own owner is — `_harness_setup` — and `confirm` is built
73
75
  * on it here exactly as `_confirm` is built on `_ask` there.
74
76
  *
@@ -83,7 +85,7 @@ import { NO_WINDOW } from './lib/proc.mjs';
83
85
  */
84
86
  export function ask(prompt, dflt = '') {
85
87
  const suffix = dflt ? ` [${dflt}]` : '';
86
- // `input()` writes its prompt to stdout with no newline — nothing for `pyPrint` to
88
+ // `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
87
89
  // translate, and it must NOT gain one.
88
90
  process.stdout.write(`${prompt}${suffix}: `);
89
91
  // `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
@@ -154,7 +156,7 @@ export function confirm(prompt, dflt = true) {
154
156
  * integer never reaches it. `99` against a five-option menu returns the default; it does
155
157
  * not go looking for an option literally named `99`.
156
158
  * * An in-range index wins, a parsed out-of-range index falls to the default, and only an
157
- * UNPARSEABLE answer is matched against the keys. `pyInt` is what separates the second
159
+ * UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
158
160
  * case from the third, and it is not `Number()` — see its own docblock.
159
161
  * * The default's index is computed with `next(...)` and no fallback, so a default that is
160
162
  * not in `options` raises `StopIteration` on the reference. That is reachable (a
@@ -163,15 +165,15 @@ export function confirm(prompt, dflt = true) {
163
165
  * place, and a silent `-1` here would be a divergence dressed as robustness.
164
166
  */
165
167
  export function askChoice(prompt, options, dflt) {
166
- pyPrint(`\n${prompt}:\n`);
168
+ printOut(`\n${prompt}:\n`);
167
169
  options.forEach(([key, desc], i) => {
168
170
  const label = desc ? `${key} — ${desc}` : key;
169
- pyPrint(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
171
+ printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
170
172
  });
171
173
  const defaultIdx = options.findIndex(([k]) => k === dflt);
172
174
  if (defaultIdx < 0) throw new Error(`StopIteration: no option named ${dflt}`);
173
175
  const raw = ask('Choose', String(defaultIdx + 1));
174
- const idx = pyInt(raw);
176
+ const idx = parseIntStrict(raw);
175
177
  if (idx !== null) {
176
178
  if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
177
179
  } else {
@@ -359,7 +361,7 @@ function askDoctrines(deployed = null) {
359
361
  * carrier said none" and the two open the question differently.
360
362
  */
361
363
  export function collectSetupLines() {
362
- pyPrint('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
364
+ printOut('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
363
365
  const inst = installedDefaults();
364
366
  const theme = askChoice('Theme', themeOptions(), inst.theme || defaultTheme());
365
367
  const posture = askChoice('Posture', postureOptions(), inst.posture || defaultPosture());
@@ -383,11 +385,21 @@ export function collectSetupLines() {
383
385
  // own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
384
386
  // everything" must be judged against that same list, not against a second one.
385
387
  const allPacks = doctrineOptions().map(([n]) => n);
386
- pyPrint(`\nAbout to run: geneseed build ${
387
- setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks)
388
- .join(' ')}\n`);
388
+ // ⚠ PRESERVED, NOT ASKED. The wizard has no per-rule question — five registers is already
389
+ // the ceiling for a prompt sequence somebody answers by holding Enter, and a rule list is a
390
+ // console control, not a terminal one. But it must not be DROPPED either: re-running the
391
+ // wizard on an install that excluded `process 5` would otherwise hand the consent gate back
392
+ // without a word, which is the same silent widening the pack question was fixed for.
393
+ //
394
+ // `null` when there is nothing to preserve rather than `[]`: an empty list makes
395
+ // `setupBuildArgs` spell `--exclude-rules none`, and that string would land in the
396
+ // "About to run:" line every recorded wizard probe compares byte for byte.
397
+ const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
398
+ printOut(`\nAbout to run: geneseed build ${
399
+ setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
400
+ excludeRules).join(' ')}\n`);
389
401
  if (!confirm('Proceed?', true)) return null;
390
- return { theme, posture, mode, doctrines, emit, out, root, footprint };
402
+ return { theme, posture, mode, doctrines, excludeRules, emit, out, root, footprint };
391
403
  }
392
404
 
393
405
  // --------------------------------------------------------------------------------------
@@ -399,14 +411,14 @@ export function collectSetupLines() {
399
411
  *
400
412
  * `21.0.2` gives 21 and the legacy `1.8.0` gives 1, which is never >= 21 — that asymmetry is
401
413
  * the whole function. `\p{Nd}` rather than `[0-9]` because Python's `\d` matches any Unicode
402
- * decimal and JS's does not without the flag, and `pyInt` reads the group for the same
414
+ * decimal and JS's does not without the flag, and `parseIntStrict` reads the group for the same
403
415
  * reason. No `java` prints such a banner; matching Python where it is free is cheaper than a
404
416
  * comment explaining where it does not.
405
417
  */
406
418
  export function javaMajorOk(versionOutput, minimum = 21) {
407
419
  const m = /version "(\p{Nd}+)/u.exec(versionOutput);
408
420
  if (!m) return false;
409
- const n = pyInt(m[1]);
421
+ const n = parseIntStrict(m[1]);
410
422
  return n !== null && n >= minimum;
411
423
  }
412
424
 
@@ -415,14 +427,14 @@ export function javaMajorOk(versionOutput, minimum = 21) {
415
427
  * for itself. Today that is a JDK 21+ for `jdtls`; the JS-runtime servers self-download.
416
428
  *
417
429
  * THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `test_the_cli_reaches_child_process_\
418
- * only_where_it_is_declared` became a TABLE. `js/doctor.mjs` was the sole allowed importer
430
+ * only_where_it_is_declared` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
419
431
  * for one binding and one argv; this is the second, and the port's rule is that the second
420
432
  * instance of anything turns a special case into a table cross-checked against the source.
421
433
  * The argv is asserted literally there, so an allow-list entry cannot be reused to start an
422
434
  * interpreter.
423
435
  */
424
436
  export function lspPrereqs() {
425
- const java = pyWhich('java');
437
+ const java = which('java');
426
438
  let ok = false;
427
439
  if (java) {
428
440
  try {
@@ -512,7 +524,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
512
524
  * sides (`_harness_build.cmd_build`, and `driverMain` here), so the line still names a
513
525
  * command the reader can type, and names one that survives the deletion.
514
526
  *
515
- * `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/anim.mjs`, gated by
527
+ * `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/ui/anim.mjs`, gated by
516
528
  * `_theme_anim_cases()` in `tests/test_pure_function_parity.py`. The bare `catch` around it
517
529
  * is the reference's own, labelled there "cosmetic only — never block a successful install",
518
530
  * and it is kept for the same reason: the animation runs AFTER the build has already
@@ -521,12 +533,12 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
521
533
  export function setupLines() {
522
534
  const sel = collectSetupLines();
523
535
  if (!sel) {
524
- pyPrint('[setup] cancelled — nothing written.\n');
536
+ printOut('[setup] cancelled — nothing written.\n');
525
537
  return 0;
526
538
  }
527
539
  const {
528
540
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
529
- doctrines = null,
541
+ doctrines = null, excludeRules = null,
530
542
  } = sel;
531
543
  if (emit === 'opencode-global') {
532
544
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -534,29 +546,29 @@ export function setupLines() {
534
546
  try {
535
547
  const [ipath] = exportImprovements();
536
548
  if (ipath) {
537
- pyPrint(`- local edits found in the deployed harness — saved to ${ipath}\n`);
538
- pyPrint(' (hand that file to your agent to back-port them into src/)\n');
549
+ printOut(`- local edits found in the deployed harness — saved to ${ipath}\n`);
550
+ printOut(' (hand that file to your agent to back-port them into src/)\n');
539
551
  }
540
552
  } catch (e) {
541
- pyPrint(`! could not export local edits (${e && e.message}) — continuing.\n`);
553
+ printOut(`! could not export local edits (${e && e.message}) — continuing.\n`);
542
554
  }
543
555
  }
544
556
  // Same list the question was built from — see `collectSetupLines`. The build that actually
545
557
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
546
558
  // one command and run another.
547
559
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
548
- doctrineOptions().map(([n]) => n));
549
- pyPrint(`Running: geneseed build ${argv.join(' ')}\n`);
560
+ doctrineOptions().map(([n]) => n), excludeRules);
561
+ printOut(`Running: geneseed build ${argv.join(' ')}\n`);
550
562
  const rc = driverMain(argv);
551
563
  if (rc !== 0) {
552
- pyPrintErr('[setup] build failed — no harness written (see the output above).\n');
564
+ printErr('[setup] build failed — no harness written (see the output above).\n');
553
565
  return rc;
554
566
  }
555
567
  try {
556
568
  playLine(theme, true); // themed install animation (motion → reveal card)
557
569
  } catch { /* cosmetic only — never block a successful install */ }
558
570
  for (const [kind, text] of setupSummaryLines(theme, emit, out, root, true)) {
559
- pyPrint(`${{ ok: '✓', warn: '!', info: '-' }[kind] ?? '-'} ${text}\n`);
571
+ printOut(`${{ ok: '✓', warn: '!', info: '-' }[kind] ?? '-'} ${text}\n`);
560
572
  }
561
573
  if (confirm('\nRun a health check (doctor) now?', true)) {
562
574
  // Scoped to the theme just installed — no full-sweep noise post-install.
@@ -574,7 +586,7 @@ export function setupLines() {
574
586
  */
575
587
  export function cmdSetup() {
576
588
  if (!process.stdin.isTTY) {
577
- pyPrintErr('[setup] needs an interactive terminal. Non-interactive? e.g.:\n'
589
+ printErr('[setup] needs an interactive terminal. Non-interactive? e.g.:\n'
578
590
  + ' geneseed build --emit opencode-global --theme neutral\n');
579
591
  return 1;
580
592
  }