geneseed 3.14.0 → 3.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
package/js/lib/fs.mjs CHANGED
@@ -4,9 +4,11 @@
4
4
  * These are definitions, not an adaptation of somebody else's. Every rule below was
5
5
  * originally MEASURED against CPython, because the generator these functions serve had to
6
6
  * emit bytes indistinguishable from a Python one; that second party is gone and the rules
7
- * are not. What holds them now is the recorded corpora under `tests/__snapshots__/`, which
8
- * compare this module's output byte for byte and can never be re-recorded — so a rule here
9
- * is a frozen fact about what this tool emits, and changing one is a product change.
7
+ * are not. The recorded corpora that compared this module's output byte for byte went with
8
+ * the former Python suite; what holds the rules now is `tests/unit/lib_primitives.test.mjs`
9
+ * (`isFile`/`isDir`), `tests/unit/hook_cli.test.mjs`'s platform-newline count (the stream
10
+ * funnels), and for the rest only the per-function provenance below. A rule here is still a
11
+ * frozen fact about what this tool emits, and changing one is a product change.
10
12
  *
11
13
  * ⚠ NONE OF THESE IS THE NODE DEFAULT, AND THAT IS THE ONE THING TO KNOW BEFORE EDITING ONE.
12
14
  * Each is a deliberate divergence from the obvious one-liner, and the obvious one-liner is
@@ -107,10 +109,10 @@ export function copyFile(src, dest) {
107
109
  * its caller different BYTES than the Python one: measured at 176 vs 171 for one `context`
108
110
  * run before this existed.
109
111
  *
110
- * `harness_golden.py` structurally cannot see the difference — `subprocess` decodes with
111
- * universal newlines on the way back, folding both shapes to `\n` before any cell compares
112
- * them, the same shape as the shim's exclusion from `golden.py`. So the gate for it is
113
- * `test_the_two_entry_points_agree_on_stdout_BYTES`, which captures raw bytes. Moved here
112
+ * The CLI matrix structurally cannot see the difference — every cell decodes stdout with
113
+ * universal newlines, folding both shapes to `\n` before anything compares them. So the gate
114
+ * for it is `tests/unit/hook_cli.test.mjs`'s `every newline the entry points write is the
115
+ * platform's`, which counts raw bytes. Moved here
114
116
  * from `js/hosts/hooks.mjs` in P5c, when `js/inspect/excludes.mjs` became the second caller: a translation
115
117
  * that exists twice is a translation that can be fixed once.
116
118
  */
@@ -167,10 +169,9 @@ export function withDiscardableStderr(fn) {
167
169
  * `emitGlobalInto`.
168
170
  *
169
171
  * The item it closes: a plain `--emit files` handed its caller 104 bytes where `python
170
- * build.py` handed over 105, and `tests/golden.py`'s `text=True` capture folds both to the
171
- * same string. `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `build` row (through
172
- * `harness build`, which calls this `main` in-process) and a `rebuild-all` row, and those are
173
- * what can see it.
172
+ * build.py` handed over 105, and a `text=True` capture folds both to the same string.
173
+ * `tests/unit/hook_cli.test.mjs`'s newline test carries a `build` row (through `geneseed
174
+ * build`, which calls this `main` in-process), and that is what can see it.
174
175
  *
175
176
  * A Buffer chunk passes through untouched: `writeText` owns file bytes and nothing on this
176
177
  * path writes binary to a stream, so translating one would be corrupting data rather than
package/js/lib/json.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
6
6
  * measurements against CPython are recorded per function, and why NONE of these is the Node
7
7
  * default — the warning the retired `py` prefix used to carry now lives in each docblock.
8
- * A rule here is a frozen fact about what this tool emits — the corpora under
9
- * `tests/__snapshots__/` compare it byte for byte and can never be re-recorded.
8
+ * A rule here is a frozen fact about what this tool emits — the written-out tables in
9
+ * `tests/unit/lib_primitives.test.mjs` and `tests/unit/settings_jsonc.test.mjs` pin it.
10
10
  *
11
11
  * `JsonNumber` is why this module holds both the parser and the renderers: the wrapper is
12
12
  * created by `parseJson` and read by `formatValue`, `formatRepr`, `isTruthy` and `deepEquals`, so the
@@ -305,8 +305,8 @@ function escapeNonAscii(text) {
305
305
  * Indentation is what makes this tractable: passing `indent` switches Python's
306
306
  * separators from `(', ', ': ')` to `(',', ': ')`, which is exactly what
307
307
  * `JSON.stringify(v, null, 2)` emits. Verified rather than argued \u2014
308
- * `tests/test_opencode_extras_parity.py` compares both variants against Python over a
309
- * corpus of container shapes.
308
+ * the former Python suite compared both variants against Python over a corpus of container
309
+ * shapes; `tests/unit/lib_primitives.test.mjs` now pins only this writer's number spellings.
310
310
  */
311
311
  export function jsonDumpsIndent(value, { ensureAscii = true } = {}) {
312
312
  const text = JSON.stringify(value, null, 2);
package/js/lib/paths.mjs CHANGED
@@ -22,7 +22,7 @@ const { X_OK } = constants;
22
22
  * BMP), and `str.lower()` is not `toLowerCase()` for a handful of characters.
23
23
  *
24
24
  * THE SEPARATOR CONVERSION IS NOT OPTIONAL, and it was missing until the byte-bearing
25
- * corpus in `tests/test_pure_function_parity.py` asked. `ntpath.normcase` is
25
+ * corpus in the former Python parity suite asked. `ntpath.normcase` is
26
26
  * `s.replace('/', '\\').lower()`, and `PurePath._str_normcase` gets the same conversion for
27
27
  * free because `Path` has already done it — so BOTH readings of the reference fold slashes
28
28
  * and this did not. Every sort call site feeds it `path.join` output or a bare `readdir`
@@ -50,7 +50,7 @@ export const normcase = process.platform === 'win32'
50
50
  * a directory depth, and `golden.py` compared CONTENT rather than order. It became visible
51
51
  * the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
52
52
  * skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
53
- * `tests/test_maintainer_tools_parity.py` found it and died with the reference it compared
53
+ * The former Python maintainer-tools test found it and died with the reference it compared
54
54
  * against. What HOLDS it now is `tests/unit/maintainer_tools.test.mjs`, and specifically its
55
55
  * `theme files are visited in THIS platform's Path collation order` test: it re-derives Python's
56
56
  * `sorted(Path)` order for the running platform instead of replaying a recording, so it cannot
@@ -73,10 +73,14 @@ export function comparePaths(a, b) {
73
73
  * `_install_move_list` in `js/maintain/uninstall.mjs` \u2014 a CLI-level module that must not import the
74
74
  * web tree to reach it. Beside `normcase`, which is the thing it is actually about.
75
75
  * `js/web/api.mjs` re-exports it so its existing importers are unchanged.
76
+ *
77
+ * TRAILING SEPARATORS ARE STRIPPED FROM `parent` FIRST. A root is the one path whose canonical
78
+ * spelling ends in one — `D:\` and `/` — and splitting it leaves an empty last segment that no
79
+ * child's segment equals, so nothing was ever `within` a drive root or `/`.
76
80
  */
77
81
  export function within(child, parent) {
78
82
  const c = normcase(child).split(/[\\/]/);
79
- const p = normcase(parent).split(/[\\/]/);
83
+ const p = normcase(parent).replace(/[\\/]+$/, '').split(/[\\/]/);
80
84
  return p.length <= c.length && p.every((seg, i) => c[i] === seg);
81
85
  }
82
86
 
@@ -86,7 +90,7 @@ export function within(child, parent) {
86
90
  * `Path` is `WindowsPath` on Windows and `PosixPath` everywhere else, and the two parsers
87
91
  * disagree about the two things below. A body that is right on one host is therefore wrong
88
92
  * on the other, which is exactly how this one passed a whole Windows suite and failed two
89
- * cases on the first Linux run of `tests/test_pure_function_parity.py`:
93
+ * cases on the first Linux run of the former Python pure-function parity suite:
90
94
  *
91
95
  * * SEPARATORS. `ntpath` treats `\` and `/` alike; `posixpath` treats `\` as an ordinary
92
96
  * filename character. `str(PurePosixPath('C:\\x\\bin\\'))` is that string VERBATIM —
@@ -100,8 +104,8 @@ export function within(child, parent) {
100
104
  * NOT reproduced, and named here rather than left as a gap nobody wrote down: `//` and
101
105
  * `///x` on WINDOWS, where `PureWindowsPath` answers `\\` and `\\\x` and this answers `\`
102
106
  * and `\x`. Those are incomplete UNC prefixes and matching them needs ntpath's share-name
103
- * parsing; both inputs are kept OUT of the corpus in `tests/test_pure_function_parity.py`
104
- * instead of sitting in it wrong.
107
+ * parsing; both inputs were kept OUT of the retired parity corpus instead of sitting in it
108
+ * wrong.
105
109
  *
106
110
  * The root's separators are still rewritten ONE AT A TIME, never `replace(/[\\/]+/g, sep)`:
107
111
  * a UNC root's leading pair is part of it, so collapsing runs turns `//server/share/x` into
@@ -143,7 +147,8 @@ export function toPlatformPath(s) {
143
147
  * `context` cell varies it, and changing it in this phase would be an ungated edit to a verb
144
148
  * that crossed three phases ago. Recorded in the spec's "Still owed" with this reproduction.
145
149
  *
146
- * Gated by a corpus in `tests/test_pure_function_parity.py`: no cell can vary the shape.
150
+ * It was gated by a corpus in the retired Python pure-function parity suite; nothing has
151
+ * replaced it, and no CLI test can vary the shape.
147
152
  */
148
153
  export function isAbsolutePath(s) {
149
154
  if (process.platform !== 'win32') return s.startsWith('/');
@@ -166,27 +171,21 @@ export function isAbsolutePath(s) {
166
171
  * PATH-removal refutation is aimed at, and the reason this is a real lookup rather than one
167
172
  * line of "we are node, we know where node is".
168
173
  *
169
- * The Windows half is the part with behaviour in it, and three of its four rules were
170
- * MEASURED against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
174
+ * The Windows half is the part with behaviour in it, and its suffix rules were MEASURED
175
+ * against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
171
176
  * returned path carries PATHEXT's OWN SPELLING of the extension rather than the file's
172
177
  * (`zzcmd.CMD` for a `zzcmd.cmd` on disk). An extension already in that set means the command
173
- * is spelled in full. An EMPTY PATH entry is not skipped either: `join('', cmd)` is a
174
- * relative path, and Python answers with one.
175
- *
176
- * THE FOURTH RULE IS THE CURRENT DIRECTORY, and it was measured on a machine that could not
177
- * see it. `shutil.which` prepends `os.curdir` to the search — even to a `path=` given
178
- * explicitly — when `_winapi.NeedCurrentDirectoryForExePath` says so, and that Win32 call
179
- * answers TRUE unless `NoDefaultCurrentDirectoryInExePath` is DEFINED in the environment.
180
- * Git Bash and this repo's developer machine both define it, so the reference answered None
181
- * there and the first draft's "the current directory is NOT prepended — it was until 3.12"
182
- * described an environment variable it had mistaken for a version change. GitHub's Windows
183
- * runner does not define it: the reference answered `.\\geneseed.CMD` for a `geneseed` looked
184
- * up beside the repo root, this returned null, and the parity corpus said so on the first CI
185
- * run. The variable is an INPUT, so it is read here rather than baked, and
186
- * `test_the_curdir_rule_agrees_in_both_environment_states` runs the corpus with it both ways.
187
- *
188
- * Gated as a pure function over a seeded directory in `tests/test_pure_function_parity.py`:
189
- * a cell can only ever observe the one answer this machine's PATH gives.
178
+ * is spelled in full.
179
+ *
180
+ * THE CURRENT DIRECTORY IS NEVER SEARCHED IMPLICITLY, and here this departs from
181
+ * `shutil.which` on purpose. The reference prepends `os.curdir` on Windows unless
182
+ * `NoDefaultCurrentDirectoryInExePath` is defined, and answers an EMPTY PATH entry (a
183
+ * trailing `;` is routine) with a cwd-relative path. Every caller SPAWNS what this returns —
184
+ * `git` from `upgrade`, `java` from `setup`, `node` from the doctor, `npm` from the web server
185
+ * — from whatever directory the user is in, so a `git.exe` committed to an untrusted clone ran
186
+ * the moment `geneseed upgrade` was typed inside it. Both routes are closed; a `.` the user
187
+ * wrote into PATH themselves is still honoured, because that is their choice and not ours.
188
+ * Gated in `tests/unit/lib_primitives.test.mjs`.
190
189
  */
191
190
  export function which(cmd, searchPath = null) {
192
191
  const win = process.platform === 'win32';
@@ -209,14 +208,9 @@ export function which(cmd, searchPath = null) {
209
208
  // then spawns. Measured: the corpus case with a posix-spelled directory was the one that
210
209
  // differed, and `path.join` had rewritten the whole path rather than appending to it.
211
210
  const joinRaw = (dir, name) => (
212
- dir === '' || /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
211
+ /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
213
212
  ? dir + name : dir + path.sep + name);
214
- const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter);
215
- // `NeedCurrentDirectoryForExePath(cmd)`: TRUE — so `.` goes in FRONT — unless the variable
216
- // is defined. Only reachable with a separator-free `cmd`, which is the only way execution
217
- // gets this far. Inserted before the dedupe, exactly where `shutil.which` inserts it, so a
218
- // PATH that already names `.` is searched once.
219
- if (win && process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift('.');
213
+ const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
220
214
  const seen = new Set();
221
215
  for (const dir of dirs) {
222
216
  const key = win ? dir.toLowerCase() : dir;
package/js/lib/proc.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  * that module and six of its spawns used it, so the four spawn sites in `js/inspect/checks-authoring.mjs`,
12
12
  * `js/hosts/hooks.mjs`, `js/maintain/setup.mjs` and `js/hosts/link.mjs` each had to remember the flag on their
13
13
  * own. Two of them did not — and the miss is BYTE-INVISIBLE: a console window is not
14
- * something the CLI prints, so every recorded cell under `tests/__snapshots__/` stays green
15
- * while the windows pop. The property under test never reaches the comparison, which is why
16
- * a recorded corpus cannot be the gate here. A shared constant plus
14
+ * something the CLI prints, so every cell of `tests/golden.mjs --cli` stays green while the
15
+ * windows pop. The property under test never reaches the comparison, which is why a byte
16
+ * comparison cannot be the gate here. A shared constant plus
17
17
  * `tests/unit/spawn_hygiene.test.mjs` is what makes the next spawn site inherit the answer
18
18
  * instead of re-deciding it.
19
19
  *
@@ -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