geneseed 3.14.0 → 3.15.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 (88) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  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/harness.config.json +1 -1
  12. package/js/README.md +9 -5
  13. package/js/build/args.mjs +4 -3
  14. package/js/build/bundle.mjs +10 -10
  15. package/js/build/driver.mjs +8 -7
  16. package/js/build/emit-claude.mjs +3 -2
  17. package/js/build/emit-common.mjs +2 -14
  18. package/js/build/emit-opencode.mjs +12 -8
  19. package/js/build/generate.mjs +15 -11
  20. package/js/build/render.mjs +1 -1
  21. package/js/build/source.mjs +6 -6
  22. package/js/build/stubs.mjs +6 -5
  23. package/js/build/version.mjs +21 -2
  24. package/js/hosts/hooks-context.mjs +2 -1
  25. package/js/hosts/hooks-prims.mjs +2 -2
  26. package/js/hosts/hosts.mjs +24 -26
  27. package/js/hosts/installs.mjs +33 -14
  28. package/js/hosts/link.mjs +75 -26
  29. package/js/hosts/memory-files.mjs +13 -4
  30. package/js/hosts/native.mjs +106 -48
  31. package/js/hosts/opencode.mjs +44 -36
  32. package/js/hosts/shim.mjs +4 -2
  33. package/js/inspect/diff.mjs +5 -6
  34. package/js/inspect/excludes.mjs +10 -5
  35. package/js/inspect/status.mjs +18 -21
  36. package/js/inspect/validate.mjs +2 -2
  37. package/js/lib/fs.mjs +12 -11
  38. package/js/lib/json.mjs +4 -4
  39. package/js/lib/paths.mjs +27 -33
  40. package/js/lib/proc.mjs +3 -3
  41. package/js/lib/prompt.mjs +132 -0
  42. package/js/lib/text.mjs +18 -4
  43. package/js/lib/udiff.mjs +3 -2
  44. package/js/maintain/memory.mjs +5 -6
  45. package/js/maintain/migrate.mjs +4 -3
  46. package/js/maintain/setup.mjs +9 -131
  47. package/js/maintain/uninstall.mjs +10 -9
  48. package/js/maintain/update.mjs +115 -34
  49. package/js/ui/anim.mjs +8 -12
  50. package/js/ui/cli.mjs +29 -35
  51. package/js/ui/menu.mjs +7 -13
  52. package/js/ui/tui.mjs +14 -123
  53. package/js/web/actions.mjs +82 -386
  54. package/js/web/activity.mjs +1 -1
  55. package/js/web/api.mjs +33 -717
  56. package/js/web/catalog.mjs +527 -0
  57. package/js/web/daemon.mjs +21 -3
  58. package/js/web/handler.mjs +51 -27
  59. package/js/web/jobs.mjs +24 -2
  60. package/js/web/routes.mjs +61 -6
  61. package/js/web/server.mjs +4 -4
  62. package/js/web/state.mjs +138 -0
  63. package/js/web/user-files.mjs +339 -0
  64. package/package.json +1 -1
  65. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  66. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  67. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  68. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  69. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  70. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  71. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  72. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  73. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  74. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  75. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  76. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  77. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  78. package/web/dist/assets/api-CxgfYu18.js +1 -0
  79. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  80. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  83. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  84. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  85. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  86. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  87. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  88. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` over one `readLine`.
3
+ *
4
+ * MOVED OUT OF `js/maintain/setup.mjs`, which is where the Python's own owner (`_harness_setup`)
5
+ * put them, because two callers that are not the wizard need them: `uninstall`'s `confirm` and
6
+ * the web server's npm prompt. Importing them from `setup.mjs` dragged the whole wizard closure
7
+ * — the driver, the doctor, the diff exporter — into `uninstall` and `web` for four functions
8
+ * whose only dependencies are fd 0, `printOut` and `parseIntStrict`.
9
+ */
10
+ import { readSync } from 'node:fs';
11
+
12
+ import { printOut } from './fs.mjs';
13
+ import { parseIntStrict } from './text.mjs';
14
+
15
+ /**
16
+ * `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
17
+ *
18
+ * WHY IT READS A BYTE AT A TIME. Node has no synchronous line read: `readFileSync(0)` blocks
19
+ * until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
20
+ * until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
21
+ * `input()` does, and it is the only synchronous shape that stops where Enter stops. The
22
+ * body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`, moved to `setup` when that
23
+ * became the second caller, and lives here now that the callers outnumber the wizard;
24
+ * `confirm` is built on it exactly as `_confirm` is built on `_ask` in the Python.
25
+ *
26
+ * The EOF branch is the one a piped caller sees, and it agrees with Python by two different
27
+ * routes that happen to produce one answer: `input()` RAISES `EOFError` and the `except`
28
+ * returns the default, while a zero-length read here leaves `line` empty and `|| dflt` does
29
+ * the same. A partial line with no trailing newline is not EOF to either — Python returns
30
+ * the characters and so does this.
31
+ *
32
+ * `.trim()` is `str.strip()` in every case but U+FEFF, which JS counts as whitespace and
33
+ * Python does not; that is a standing item of this port and not this function's to settle.
34
+ */
35
+ export function ask(prompt, dflt = '') {
36
+ const suffix = dflt ? ` [${dflt}]` : '';
37
+ // `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
38
+ // translate, and it must NOT gain one.
39
+ process.stdout.write(`${prompt}${suffix}: `);
40
+ // `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
41
+ // catches `EOFError` and returns the default, which is what the empty string produces
42
+ // here through `|| dflt`. The two routes to one answer are the docblock's own point.
43
+ const ans = (readLine() ?? '').trim();
44
+ return ans || dflt;
45
+ }
46
+
47
+ /**
48
+ * `input(prompt)` with EOF told apart from an empty line — `null` where Python RAISES.
49
+ *
50
+ * `ask` above cannot express that difference and does not need to: its EOF and its empty
51
+ * answer both mean "take the default". `_web_server.serve`'s npm prompt is the first
52
+ * caller where they differ, and they differ dangerously — `""` is in its accepted set, so
53
+ * a port that read EOF as an empty line would answer YES to "run npm install now?" on
54
+ * every non-interactive run. The reference catches `(EOFError, KeyboardInterrupt)` and
55
+ * answers "n"; this returns null so the caller can.
56
+ *
57
+ * No `.trim()`: `input()` does not strip, and both callers strip for themselves.
58
+ */
59
+ export function promptLine(prompt) {
60
+ process.stdout.write(prompt);
61
+ return readLine();
62
+ }
63
+
64
+ /**
65
+ * Fd 0 to the first `\n`, dropping a `\r` before it — the CRLF a Windows console sends.
66
+ *
67
+ * THE BYTES ARE COLLECTED AND DECODED ONCE, and that is a fix rather than a style. The
68
+ * version this arrived as decoded EACH BYTE on its own — `buf.toString('utf8', 0, n)` inside
69
+ * the loop — which turns every multi-byte character into two or three U+FFFD. It was
70
+ * invisible for a phase because its only caller was `confirm`, whose answer is `y` or `n`;
71
+ * the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
72
+ * first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
73
+ * are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
74
+ */
75
+ function readLine() {
76
+ const buf = Buffer.alloc(1);
77
+ const bytes = [];
78
+ let eof = false;
79
+ for (;;) {
80
+ let n = 0;
81
+ try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
82
+ if (n === 0) { eof = true; break; }
83
+ if (buf[0] === 0x0a) break;
84
+ if (buf[0] !== 0x0d) bytes.push(buf[0]);
85
+ }
86
+ // NULL ONLY FOR THE EMPTY EOF, which is the one case `input()` raises on. A partial line
87
+ // with no trailing newline is not EOF to Python — it returns the characters — and the
88
+ // `bytes.length` test is what keeps that arm returning a string here too.
89
+ if (eof && bytes.length === 0) return null;
90
+ return Buffer.from(bytes).toString('utf8');
91
+ }
92
+
93
+ /** `_harness_setup._confirm` — `_ask` with a Y/n suffix; the default on an empty answer. */
94
+ export function confirm(prompt, dflt = true) {
95
+ const ans = ask(`${prompt} (${dflt ? 'Y/n' : 'y/N'})`).toLowerCase();
96
+ return ans === '' ? dflt : ans[0] === 'y';
97
+ }
98
+
99
+ /**
100
+ * `_harness_setup._ask_choice` — a numbered menu; the chosen KEY, or the default.
101
+ *
102
+ * THREE THINGS ABOUT THE FALLBACK ORDER, all of them observable and none of them obvious:
103
+ *
104
+ * * The key match lives in Python's `except ValueError`, so an answer that PARSES as an
105
+ * integer never reaches it. `99` against a five-option menu returns the default; it does
106
+ * not go looking for an option literally named `99`.
107
+ * * An in-range index wins, a parsed out-of-range index falls to the default, and only an
108
+ * UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
109
+ * case from the third, and it is not `Number()` — see its own docblock.
110
+ * * A default that is NOT in `options` falls back to option 1. It is reachable — a deployed
111
+ * `.geneseed-theme` can name a theme this checkout no longer ships — and it used to throw,
112
+ * mirroring the Python reference's `StopIteration`, which crashed `geneseed setup` before
113
+ * its first question. The reference is gone, so there is nothing left to fail in step
114
+ * with, and returning a key that is not on the menu would hand the build a stale value.
115
+ */
116
+ export function askChoice(prompt, options, dflt) {
117
+ if (!options.some(([k]) => k === dflt)) dflt = options[0][0];
118
+ printOut(`\n${prompt}:\n`);
119
+ options.forEach(([key, desc], i) => {
120
+ const label = desc ? `${key} — ${desc}` : key;
121
+ printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
122
+ });
123
+ const defaultIdx = options.findIndex(([k]) => k === dflt);
124
+ const raw = ask('Choose', String(defaultIdx + 1));
125
+ const idx = parseIntStrict(raw);
126
+ if (idx !== null) {
127
+ if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
128
+ } else {
129
+ for (const [key] of options) if (raw === key) return key;
130
+ }
131
+ return dflt;
132
+ }
package/js/lib/text.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  * set than `String.trim`'s, `parseIntStrict` accepts what `Number()` does not and rejects what
10
10
  * it does, and `percentDecode` leaves a bad escape alone where `decodeURIComponent` throws.
11
11
  */
12
+ import path from 'node:path';
13
+
12
14
  /**
13
15
  * `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
14
16
  *
@@ -50,8 +52,10 @@ export const padEndToWidth = (s, width) => s + ' '.repeat(Math.max(0, width - co
50
52
  * raw environment value do it — `js/ui/cli.mjs`'s `helpWidth` and `js/web/activity.mjs` wrap
51
53
  * the argument in `stripWhitespace` first.
52
54
  *
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
+ * Its own corpus retired with the Python suite — a primitive reproduction gets one owner and
56
+ * a corpus, never a cell (P5c) — so what reaches it now is only its callers' gates:
57
+ * `tests/unit/wizard.test.mjs`'s choice corpus and `tests/unit/web_daemon.test.mjs`'s
58
+ * non-integer port.
55
59
  */
56
60
  export function parseIntStrict(s) {
57
61
  if (typeof s !== 'string' || s === '') return null;
@@ -146,8 +150,8 @@ export function stripWhitespaceEnd(s) {
146
150
  * `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
147
151
  * `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
148
152
  * 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.
153
+ * and the former Python corpus is where that was measured rather than assumed; nothing
154
+ * measures it now.
151
155
  */
152
156
  const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
153
157
 
@@ -179,3 +183,13 @@ export function percentDecode(s) {
179
183
  return res;
180
184
  }
181
185
 
186
+ /**
187
+ * `Path.relative_to(base).as_posix()` — `target` relative to `base`, always `/`-separated.
188
+ *
189
+ * Here, not in `js/build/emit-common.mjs`, because the hook path (`js/hosts/hooks-context.mjs`)
190
+ * needs it too and must not pay for the render module to get it; this module imports nothing
191
+ * but `node:path`. It had been defined four times.
192
+ */
193
+ export function relPosix(base, target) {
194
+ return path.relative(base, target).split(path.sep).join('/');
195
+ }
package/js/lib/udiff.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * WHY A HAND-WRITTEN MATCHER RATHER THAN A LIBRARY. The diff is the ENTIRE user-visible
5
5
  * payload of `geneseed diff`: the hunks in `--full` and in the exported improvements file
6
6
  * are this file's output and nothing else's. A generic JS diff would produce a CORRECT diff
7
- * and a DIFFERENT one, and `tests/__snapshots__/primitives/` compares the hunks character
8
- * for character, so "also a valid diff" fails every cell. The alignment below IS the
7
+ * and a DIFFERENT one, and the former Python suite's corpus compared the hunks character for
8
+ * character, so "also a valid diff" failed every case (today only the `diff/--full-…` CLI cell
9
+ * compares hunks, and only one; see `js/inspect/diff.mjs`). The alignment below IS the
9
10
  * specification of what this tool prints, and three of its choices are choices no other
10
11
  * algorithm makes:
11
12
  *
@@ -17,12 +17,11 @@
17
17
  * beside the cwd, then the global config dir — with `--memory` as the explicit override.
18
18
  * `status` prints the answer of that same resolver, so the two agree about which store
19
19
  * is "the" store.
20
- * * THE RESERVED-NAME CHECK IS CASE-INSENSITIVE, WHICH THE ENDPOINT'S IS NOT. It compares
21
- * `MEMORY` and `README` exactly. On a case-insensitive filesystem — which is every
22
- * Windows install and the default macOS one — `rm memory` then resolves to the index
23
- * file, passes the guard, and deletes the thing the agent reads at session start. This
24
- * is a NARROWING of what the endpoint accepts, on a destructive path, and it is the one
25
- * divergence in this file that is not about resolution.
20
+ * * THE RESERVED-NAME CHECK IS CASE-INSENSITIVE, and the web endpoints use this one. On a
21
+ * case-insensitive filesystem — every Windows install and the default macOS one — an
22
+ * exact `MEMORY`/`README` compare let `rm memory` resolve to the index file, pass the
23
+ * guard, and delete the thing the agent reads at session start. The endpoint once had
24
+ * that exact compare; it now calls `memoryDelete` from here.
26
25
  *
27
26
  * `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
28
27
  * index line are two halves of one format, and the half that writes lives there.
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * P10d, AND IT IS NOT A PORT. Every other module here reproduces a Python original byte for
5
5
  * byte; this verb is new behaviour on both sides, written twice in the same change so that
6
- * `tests/harness_golden.py` — a CROSS-IMPLEMENTATION gate — has a reference side at all. A
7
- * Node-only verb would have nothing to compare against, and `test_every_entry_verb_is_a_real_
8
- * harness_subcommand` plus `test_the_matrix_covers_every_verb_it_claims` would both fail it.
6
+ * the former Python CLI matrix — a CROSS-IMPLEMENTATION gate — had a reference side at all. A
7
+ * Node-only verb would have had nothing to compare against, and that suite's `test_every_entry_
8
+ * verb_is_a_real_harness_subcommand` plus `test_the_matrix_covers_every_verb_it_claims` would
9
+ * both have failed it.
9
10
  *
10
11
  * WHAT IT IS NOT: `rebuild-all`. That was the first answer and it was checked before being
11
12
  * discarded, because the re-emit really is the same engine and is reused rather than rewritten
@@ -31,17 +31,17 @@
31
31
  * and a cell's stdin is a pipe. So the whole wizard is unreachable from the acceptance
32
32
  * matrix in the same way `_confirm` was in P5h — except that here it is not one branch, it
33
33
  * is the verb. The `setup/*` cells therefore gate the REFUSAL, absolutely and on both sides,
34
- * and everything past it is gated as a CORPUS in `tests/test_pure_function_parity.py`.
34
+ * and everything past it is gated as a CORPUS in `tests/unit/wizard.test.mjs`.
35
35
  *
36
36
  * The corpus is a new shape and the brief predicted the reason: a corpus over a stdin reader
37
- * needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.{py,mjs}` are run with stdin
38
- * redirected from a file of answers and their WHOLE stdout compared — so the prompts, the
37
+ * needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.mjs` is run with stdin
38
+ * redirected from a file of answers and its WHOLE stdout compared — so the prompts, the
39
39
  * numbered menus, the default markers, the `About to run:` line and the returned selection
40
40
  * are all one byte comparison. That is a stricter gate than a cell would have been, because
41
41
  * a cell could not have varied the answers at all.
42
42
  */
43
43
  import { spawnSync } from 'node:child_process';
44
- import { existsSync, readSync } from 'node:fs';
44
+ import { existsSync } from 'node:fs';
45
45
  import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../build/driver.mjs';
@@ -57,132 +57,10 @@ import {
57
57
  } from '../hosts/installs.mjs';
58
58
  import { printOut, printErr } from '../lib/fs.mjs';
59
59
  import { which } from '../lib/paths.mjs';
60
+ import { ask, askChoice, confirm } from '../lib/prompt.mjs';
60
61
  import { parseIntStrict } from '../lib/text.mjs';
61
62
  import { NO_WINDOW } from '../lib/proc.mjs';
62
63
 
63
- // --------------------------------------------------------------------------------------
64
- // the three readers
65
- // --------------------------------------------------------------------------------------
66
-
67
- /**
68
- * `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
69
- *
70
- * WHY IT READS A BYTE AT A TIME. Node has no synchronous line read: `readFileSync(0)` blocks
71
- * until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
72
- * until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
73
- * `input()` does, and it is the only synchronous shape that stops where Enter stops. The
74
- * body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`; `setup` is the second caller,
75
- * so it moved to where the Python's own owner is — `_harness_setup` — and `confirm` is built
76
- * on it here exactly as `_confirm` is built on `_ask` there.
77
- *
78
- * The EOF branch is the one a piped caller sees, and it agrees with Python by two different
79
- * routes that happen to produce one answer: `input()` RAISES `EOFError` and the `except`
80
- * returns the default, while a zero-length read here leaves `line` empty and `|| dflt` does
81
- * the same. A partial line with no trailing newline is not EOF to either — Python returns
82
- * the characters and so does this.
83
- *
84
- * `.trim()` is `str.strip()` in every case but U+FEFF, which JS counts as whitespace and
85
- * Python does not; that is a standing item of this port and not this function's to settle.
86
- */
87
- export function ask(prompt, dflt = '') {
88
- const suffix = dflt ? ` [${dflt}]` : '';
89
- // `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
90
- // translate, and it must NOT gain one.
91
- process.stdout.write(`${prompt}${suffix}: `);
92
- // `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
93
- // catches `EOFError` and returns the default, which is what the empty string produces
94
- // here through `|| dflt`. The two routes to one answer are the docblock's own point.
95
- const ans = (readLine() ?? '').trim();
96
- return ans || dflt;
97
- }
98
-
99
- /**
100
- * `input(prompt)` with EOF told apart from an empty line — `null` where Python RAISES.
101
- *
102
- * `ask` above cannot express that difference and does not need to: its EOF and its empty
103
- * answer both mean "take the default". `_web_server.serve`'s npm prompt is the first
104
- * caller where they differ, and they differ dangerously — `""` is in its accepted set, so
105
- * a port that read EOF as an empty line would answer YES to "run npm install now?" on
106
- * every non-interactive run. The reference catches `(EOFError, KeyboardInterrupt)` and
107
- * answers "n"; this returns null so the caller can.
108
- *
109
- * No `.trim()`: `input()` does not strip, and both callers strip for themselves.
110
- */
111
- export function promptLine(prompt) {
112
- process.stdout.write(prompt);
113
- return readLine();
114
- }
115
-
116
- /**
117
- * Fd 0 to the first `\n`, dropping a `\r` before it — the CRLF a Windows console sends.
118
- *
119
- * THE BYTES ARE COLLECTED AND DECODED ONCE, and that is a fix rather than a style. The
120
- * version this arrived as decoded EACH BYTE on its own — `buf.toString('utf8', 0, n)` inside
121
- * the loop — which turns every multi-byte character into two or three U+FFFD. It was
122
- * invisible for a phase because its only caller was `confirm`, whose answer is `y` or `n`;
123
- * the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
124
- * first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
125
- * are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
126
- */
127
- function readLine() {
128
- const buf = Buffer.alloc(1);
129
- const bytes = [];
130
- let eof = false;
131
- for (;;) {
132
- let n = 0;
133
- try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
134
- if (n === 0) { eof = true; break; }
135
- if (buf[0] === 0x0a) break;
136
- if (buf[0] !== 0x0d) bytes.push(buf[0]);
137
- }
138
- // NULL ONLY FOR THE EMPTY EOF, which is the one case `input()` raises on. A partial line
139
- // with no trailing newline is not EOF to Python — it returns the characters — and the
140
- // `bytes.length` test is what keeps that arm returning a string here too.
141
- if (eof && bytes.length === 0) return null;
142
- return Buffer.from(bytes).toString('utf8');
143
- }
144
-
145
- /** `_harness_setup._confirm` — `_ask` with a Y/n suffix; the default on an empty answer. */
146
- export function confirm(prompt, dflt = true) {
147
- const ans = ask(`${prompt} (${dflt ? 'Y/n' : 'y/N'})`).toLowerCase();
148
- return ans === '' ? dflt : ans[0] === 'y';
149
- }
150
-
151
- /**
152
- * `_harness_setup._ask_choice` — a numbered menu; the chosen KEY, or the default.
153
- *
154
- * THREE THINGS ABOUT THE FALLBACK ORDER, all of them observable and none of them obvious:
155
- *
156
- * * The key match lives in Python's `except ValueError`, so an answer that PARSES as an
157
- * integer never reaches it. `99` against a five-option menu returns the default; it does
158
- * not go looking for an option literally named `99`.
159
- * * An in-range index wins, a parsed out-of-range index falls to the default, and only an
160
- * UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
161
- * case from the third, and it is not `Number()` — see its own docblock.
162
- * * The default's index is computed with `next(...)` and no fallback, so a default that is
163
- * not in `options` raises `StopIteration` on the reference. That is reachable (a
164
- * deployed install can carry a theme this checkout no longer ships) and it is mirrored
165
- * as a throw rather than smoothed over: the two implementations must fail in the same
166
- * place, and a silent `-1` here would be a divergence dressed as robustness.
167
- */
168
- export function askChoice(prompt, options, dflt) {
169
- printOut(`\n${prompt}:\n`);
170
- options.forEach(([key, desc], i) => {
171
- const label = desc ? `${key} — ${desc}` : key;
172
- printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
173
- });
174
- const defaultIdx = options.findIndex(([k]) => k === dflt);
175
- if (defaultIdx < 0) throw new Error(`StopIteration: no option named ${dflt}`);
176
- const raw = ask('Choose', String(defaultIdx + 1));
177
- const idx = parseIntStrict(raw);
178
- if (idx !== null) {
179
- if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
180
- } else {
181
- for (const [key] of options) if (raw === key) return key;
182
- }
183
- return dflt;
184
- }
185
-
186
64
  // --------------------------------------------------------------------------------------
187
65
  // the option tables
188
66
  // --------------------------------------------------------------------------------------
@@ -372,7 +250,7 @@ export function collectSetupLines() {
372
250
  const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
373
251
  const doctrines = askDoctrines(inst.doctrines);
374
252
  const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
375
- const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS.map(([k, d]) => [k, d]),
253
+ const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS,
376
254
  inst.footprint || 'lean');
377
255
  let out = null;
378
256
  let root = null;
@@ -430,8 +308,8 @@ export function javaMajorOk(versionOutput, minimum = 21) {
430
308
  * `_harness_setup._lsp_prereqs` — `(label, present, hint)` for what OpenCode cannot install
431
309
  * for itself. Today that is a JDK 21+ for `jdtls`; the JS-runtime servers self-download.
432
310
  *
433
- * THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `test_the_cli_reaches_child_process_\
434
- * only_where_it_is_declared` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
311
+ * THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `ALLOWED_SPAWNS` in
312
+ * `tests/unit/hook_cli.test.mjs` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
435
313
  * for one binding and one argv; this is the second, and the port's rule is that the second
436
314
  * instance of anything turns a special case into a table cross-checked against the source.
437
315
  * The argv is asserted literally there, so an allow-list entry cannot be reused to start an
@@ -572,7 +450,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
572
450
  * command the reader can type, and names one that survives the deletion.
573
451
  *
574
452
  * `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/ui/anim.mjs`, gated by
575
- * `_theme_anim_cases()` in `tests/test_pure_function_parity.py`. The bare `catch` around it
453
+ * the theme-animation jobs in `tests/unit/wizard.test.mjs`. The bare `catch` around it
576
454
  * is the reference's own, labelled there "cosmetic only — never block a successful install",
577
455
  * and it is kept for the same reason: the animation runs AFTER the build has already
578
456
  * succeeded, so nothing it can do may change the exit code.
@@ -17,9 +17,9 @@
17
17
  * 2. AN EMPTY DIRECTORY WAS INVISIBLE TO EVERY CELL. The ancestor-climb prune is a quarter of
18
18
  * this verb and `_snapshot` could not see it: a port that unlinked the files and left
19
19
  * `skills/<name>/` behind was byte-identical everywhere. Closed by a `<dirs>` column in
20
- * `tests/harness_golden.py`'s snapshot rather than a per-cell expectation, because one
21
- * entry closes it for all 219 cells written before it existed — `cmdTheme`'s statement
22
- * order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
20
+ * the CLI matrix's snapshot (`tests/helpers/cli_golden.mjs`) rather than a per-cell
21
+ * expectation, because one entry closes it for all 219 cells written before it existed —
22
+ * `cmdTheme`'s statement order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
23
23
  * expectation kind, `expect_absent_files`.
24
24
  * 3. `--archive-memory` TOUCHES THE MEMORY STORE, the one thing here a user cannot rebuild.
25
25
  * It is why `_archive_store` MOVES and never deletes, and why the Python's `memory=delete`
@@ -48,7 +48,7 @@ import {
48
48
  import path from 'node:path';
49
49
 
50
50
  import { hookRunnerEntry } from '../hosts/shim.mjs';
51
- import { confirm } from './setup.mjs';
51
+ import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
54
  installState,
@@ -374,7 +374,7 @@ export function installAgentEntry(root, kind) {
374
374
  * A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
375
375
  * that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
376
376
  * seed but whose answer then only differs on one platform, in one branch, with no other
377
- * observable effect. `tests/test_pure_function_parity.py` drives the list directly.
377
+ * observable effect. The former Python corpus drove the list directly; nothing does now.
378
378
  */
379
379
  export function installAgentEntryOf(instr) {
380
380
  if (Array.isArray(instr)) {
@@ -732,9 +732,10 @@ export function cmdUninstall(args) {
732
732
  // ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
733
733
  // puts every earlier move back and reports, leaving the install fully `active`; a port that
734
734
  // merely stopped on the failure would leave a half-gutted config dir that neither state
735
- // answers for. `tests/web_golden.py`'s `install/a-deactivate-whose-second-move-collides…`
736
- // drives it: a manifest naming a file AND its parent directory is the only shape a seeded
737
- // world can use to make the SECOND move collide.
735
+ // answers for. `tests/unit/web_api.test.mjs`'s `deactivate rolls back on a failed move`
736
+ // drives it with a file planted where the stash dir belongs, which fails the FIRST move; the
737
+ // former web matrix's SECOND-move collision (a manifest naming a file AND its parent
738
+ // directory) has no successor.
738
739
 
739
740
  /**
740
741
  * `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
@@ -926,7 +927,7 @@ function restoreAll(stash, base, skip = null) {
926
927
  * The config edit is the LAST step and the only non-move mutation, so a move failure rolls
927
928
  * back cleanly with the `instructions` entry still intact. The prune climbs from each moved
928
929
  * file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
929
- * snapshot can see, and the reason `tests/web_golden.py` grew a `<dirs>` column in P6i.
930
+ * snapshot can see, and the reason the former web matrix grew a `<dirs>` column in P6i.
930
931
  */
931
932
  export function installDeactivate(root, host = 'opencode', scope = 'global') {
932
933
  if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);