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.
- package/CHANGELOG.md +66 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +45 -11
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +37 -11
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/docs/concepts/exclusions.md +21 -9
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +1 -1
- package/docs/token-footprint.md +10 -7
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +38 -6
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +16 -14
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +26 -16
- package/js/build/render.mjs +34 -4
- package/js/build/source.mjs +43 -7
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +85 -34
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +58 -16
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +46 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +20 -22
- package/js/inspect/validate.mjs +5 -3
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +20 -135
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +87 -389
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/src/AGENT.md.tmpl +1 -1
- package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- 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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
111
|
-
* universal newlines
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
9
|
-
* `tests/
|
|
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
|
-
*
|
|
309
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
104
|
-
*
|
|
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
|
-
*
|
|
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
|
|
170
|
-
*
|
|
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.
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
150
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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
|
*
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -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,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
package/js/maintain/migrate.mjs
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* Node-only verb would have nothing to compare against, and `
|
|
8
|
-
*
|
|
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
|