geneseed 3.13.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 (131) hide show
  1. package/CHANGELOG.md +93 -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 +45 -10
  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 +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/loop/state.mjs CHANGED
@@ -43,9 +43,9 @@ import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
43
43
 
44
44
  const BEGIN = '<!-- loop-state:begin -->';
45
45
  const END = '<!-- loop-state:end -->';
46
- export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
46
+ const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
47
47
  export const LOOP_FILE = 'LOOP.md';
48
- export const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
48
+ const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
49
49
  const slash = (p) => String(p).replaceAll('\\', '/');
50
50
 
51
51
  export function renderLoopFile(state) {
@@ -101,7 +101,11 @@ export function parseLoopFile(text) {
101
101
  const head = (state) => iterationLoop(state.graph).nodes[0];
102
102
  const fmt = (n) => Number(n).toFixed(1);
103
103
 
104
- /** `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. */
104
+ /**
105
+ * `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. Every
106
+ * field the engine ever writes is listed here, `gateAmends` included (`decideGate` still reads
107
+ * it with `?.`, for a LOOP.md written before it was listed).
108
+ */
105
109
  export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
106
110
  if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
107
111
  const loop = iterationLoop(graph);
@@ -111,7 +115,7 @@ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET,
111
115
  iteration: loop.nodes.includes(graph.start) ? 1 : 0,
112
116
  card: null, validated: false, pendingVerify: false, closing: false, counters: {},
113
117
  resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
114
- notes: [], history: [],
118
+ notes: [], history: [], gateAmends: {},
115
119
  };
116
120
  if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
117
121
  return state;
@@ -127,12 +131,14 @@ function resetUnit(state) {
127
131
  state.current = {}; state.tests = 'n/a';
128
132
  }
129
133
 
134
+ /** The soft-decision iterations of `state.history` — what the end-of-run summary and the
135
+ * registry's Active-tab row both call `review`, so the two read the same fields. */
136
+ export function reviewOf(state) {
137
+ return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
138
+ }
139
+
130
140
  function summary(state) {
131
- return {
132
- iterations: state.history.length,
133
- review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
134
- notes: state.notes,
135
- };
141
+ return { iterations: state.history.length, review: reviewOf(state), notes: state.notes };
136
142
  }
137
143
 
138
144
  export function trailers(state) {
@@ -296,13 +302,31 @@ function filterPorcelain(porcelain) {
296
302
 
297
303
  // Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
298
304
  // it has nowhere to retry back into, so it stops the loop instead of looping to the head.
305
+ // Iteration 0 alone says so: `initState` starts at 0 only when graph.start is outside the
306
+ // iteration loop, and `iteration` only ever counts up from there.
299
307
  function isSetupUnit(state) {
300
- return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
308
+ return state.iteration === 0;
309
+ }
310
+
311
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
312
+
313
+ // A card arrives from the model as JSON (`record --card`), so its shape is checked before
314
+ // anything is stored: a string `writeSet` stored here only throws later, inside `score --diff`,
315
+ // with `pendingVerify` already set — and `score --declared` refuses while that is set, so
316
+ // nothing could get the loop moving again.
317
+ function cardProblem(card) {
318
+ if (typeof card !== 'object' || Array.isArray(card)) return 'the card must be a JSON object';
319
+ if (!isStringList(card.actions)) return "the card's actions must be a list of strings";
320
+ if (!isStringList(card.writeSet)) return "the card's writeSet must be a list of strings";
321
+ if (card.intent != null && typeof card.intent !== 'string') return "the card's intent must be a string";
322
+ return null;
301
323
  }
302
324
 
303
325
  export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
304
326
  assertRunning(state);
305
327
  if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
328
+ const problem = card === null ? null : cardProblem(card);
329
+ if (problem) throw new Error(problem);
306
330
  const from = state.node;
307
331
  const brick = bricks.get(from);
308
332
  if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
@@ -323,7 +347,7 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
323
347
  // `fail` findings) — pushed before the transition below, so it lands in `notes` even when
324
348
  // this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
325
349
  // (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
326
- // `resetUnit`/`finishUnit` the same way the research's fact 1 says a card does not.
350
+ // `resetUnit`/`finishUnit` — which a card does not: `resetUnit` clears it.
327
351
  if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
328
352
  state.visited.push(from);
329
353
 
@@ -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.
@@ -30,7 +29,7 @@
30
29
  import { readdirSync, unlinkSync } from 'node:fs';
31
30
  import path from 'node:path';
32
31
 
33
- import { memoryDropIndex } from '../hosts/hooks.mjs';
32
+ import { memoryDropIndex } from '../hosts/memory-files.mjs';
34
33
  import { resolveMemoryDir } from '../hosts/hosts.mjs';
35
34
  import { printOut, printErr, isFile } from '../lib/fs.mjs';
36
35
 
@@ -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
@@ -35,17 +36,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:
35
36
  import path from 'node:path';
36
37
 
37
38
  import { main as driverMain } from '../build/driver.mjs';
38
- import { PACK_ORDER, ROOT } from '../build/source.mjs';
39
+ import { ROOT } from '../build/source.mjs';
40
+ import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
41
+ import { EMIT_HOST_SCOPE, installState, installTargets, readMaybe } from '../hosts/installs.mjs';
42
+ import { installProfile } from '../build/generate.mjs';
39
43
  import {
40
- EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
- footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
42
- trustOfDir,
43
- } from '../hosts/installs.mjs';
44
- import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
45
- import { DEFAULT_PRESET } from '../loop/score.mjs';
46
- import {
47
- autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
48
- } from '../hosts/settings.mjs';
44
+ autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
45
+ } from '../hosts/shim.mjs';
46
+ import { loadJsonObject } from '../hosts/settings.mjs';
49
47
  import { writeText, printOut, printErr } from '../lib/fs.mjs';
50
48
 
51
49
  /** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
@@ -61,21 +59,20 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
61
59
  * host-parity rule asks for.
62
60
  */
63
61
  function hookSettingsFile(root, host, scope) {
64
- if (!['claude', 'bob', 'openclaude'].includes(host)) return null;
62
+ if (!CLAUDE_STYLE.includes(host)) return null;
65
63
  const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
66
64
  return path.join(root, name);
67
65
  }
68
66
 
69
67
  /** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
70
68
  function hookCommandsOf(file) {
71
- // `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
72
- // comma is the one shape no machine in this project writes, and strict JSON rejects it —
69
+ // `loadJsonObject` (JSONC), NOT `JSON.parse`. A hand-edited settings.json with comments and a
70
+ // trailing comma is the one shape no machine in this project writes, and strict JSON rejects it —
73
71
  // which would classify a wired install as `none` and make `migrate` skip the machine
74
72
  // silently. `mergeClaudeSettings` already reads through comments for exactly this reason.
75
73
  // Found by the JSONC corpus fixture; a generated fixture would have hidden it.
76
- let loaded;
77
- try { [loaded] = readJsonc(readFileSync(file, 'utf8')); } catch { return []; }
78
- if (!loaded || typeof loaded !== 'object') return [];
74
+ const { state, data: loaded } = loadJsonObject(file);
75
+ if (state !== 'ok') return [];
79
76
  const hooks = loaded.hooks;
80
77
  if (!hooks || typeof hooks !== 'object') return [];
81
78
  const out = [];
@@ -210,21 +207,13 @@ export function cmdMigrate(args = {}) {
210
207
  try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
211
208
  };
212
209
 
213
- // Step 3 — re-emit, in each install's OWN six values. Same reads as `cmdRebuildAll`, and
214
- // the pack selection is the sixth: leaving it out re-emitted a narrowed install at whatever
215
- // `harness.config.json` said, which WIDENS a constitution its owner had cut down (and, the
216
- // other way, can strip the consent gate). `null` — a pre-marker carrier, which on a MIGRATION
217
- // is the common case and not the rare one — resolves to ALL packs through
218
- // `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
210
+ // Step 3 — re-emit, in each install's OWN values: `installProfile`, the one reading
211
+ // `rebuild-all` and `status` share. This loop hand-copied it once and drifted twice — it
212
+ // passed the excluded rules as `null` (re-admitting every rule an owner switched off,
213
+ // `process 5` among them) and dropped `--config-dir`, so a global found through
214
+ // `CLAUDE_CONFIG_DIR`/`OPENCODE_CONFIG_DIR` re-emitted into the DEFAULT dir instead.
219
215
  for (const r of rows) {
220
- const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
221
- ? r.marker : '';
222
- const emit = marker || DEFAULT_EMIT.get(`${r.host} ${r.scope}`) || 'opencode-global';
223
- const theme = themeOfDir(r.root) || defaultTheme();
224
- const out = r.scope === 'global' ? null : r.root;
225
- const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
226
- postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
227
- doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
216
+ const { emit, theme, argv } = installProfile(r.host, r.scope, r.root);
228
217
  const label = `${r.host}:${r.scope} (${r.root})`;
229
218
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
230
219
  let rc = 1;
@@ -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.