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.
- package/CHANGELOG.md +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- 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 +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- 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/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- 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/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.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-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
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/loop/catalog.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { readdirSync } from 'node:fs';
|
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { readText, isFile, isDir } from '../lib/fs.mjs';
|
|
22
|
-
import { frontmatter } from '../hosts/
|
|
22
|
+
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
23
23
|
import { SRC } from '../build/source.mjs';
|
|
24
24
|
import { checkGraph } from './graph.mjs';
|
|
25
25
|
|
|
@@ -117,8 +117,10 @@ export function loadCatalog({
|
|
|
117
117
|
return { bricks, templates, problems, overridden: overridden.sort() };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
/** `catalog`: an already-loaded `loadCatalog(opts)`, for a caller that needs the catalogue
|
|
121
|
+
* itself too — passing it saves reading every brick and template twice. */
|
|
122
|
+
export function catalogProblems(opts = {}, catalog = loadCatalog(opts)) {
|
|
123
|
+
const { bricks, templates, problems } = catalog;
|
|
122
124
|
const out = [...problems];
|
|
123
125
|
for (const [name, graph] of [...templates].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
124
126
|
out.push(...checkGraph(graph, bricks).map((p) => `loops/${name}: ${p}`));
|
package/js/loop/cli.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* NO SPAWN. The CLI's closure may not start processes, so git is the model's job: it pipes
|
|
7
7
|
* `git diff --cached --numstat` into `score --diff` and `git status --porcelain` into `record`.
|
|
8
|
-
* stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang
|
|
8
|
+
* stdin is read only when it is not a TTY — a hand-run `geneseed loop record` must not hang —
|
|
9
|
+
* and a TTY reads as `null`, "no porcelain given", never as `''`: an empty pipe is a CLEAN
|
|
10
|
+
* tree, and a clean tree is exactly the snapshot a read brick's check must compare against.
|
|
9
11
|
*
|
|
10
12
|
* NO `--file`. `state.mjs`'s `LOOP_FILE` constant is the ROOT-RELATIVE path the engine filters
|
|
11
13
|
* out of every diff and porcelain check, so LOOP.md has to live at the git root of the worktree
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
import { recordLoop } from './registry.mjs';
|
|
28
30
|
|
|
29
31
|
const emit = (obj) => { printOut(`${JSON.stringify(obj)}\n`); return obj.error || obj.ok === false ? 1 : 0; };
|
|
30
|
-
const stdin = () => (process.stdin.isTTY ?
|
|
32
|
+
const stdin = () => (process.stdin.isTTY ? null : readFileSync(0, 'utf8'));
|
|
31
33
|
const list = (s) => (s ? String(s).split(',').map((x) => x.trim()).filter(Boolean) : null);
|
|
32
34
|
|
|
33
35
|
function requireRoot() {
|
|
@@ -58,14 +60,17 @@ function writeCommitMessage(root, gitdir, result) {
|
|
|
58
60
|
return { ...result, message_file: file.replaceAll('\\', '/') };
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
/** `write: false` for a read-only action (`next`): rewriting LOOP.md there would change
|
|
64
|
+
* nothing, and would race the Active tab's preset picker (`registry.mjs`'s `setLoopPreset`) —
|
|
65
|
+
* a preset it saved between this read and the rewrite would be silently undone. */
|
|
66
|
+
function withState(fn, { write = true } = {}) {
|
|
62
67
|
const root = requireRoot();
|
|
63
68
|
const file = path.join(root, LOOP_FILE);
|
|
64
69
|
if (!isFile(file)) throw new Error(`${file} does not exist — run \`geneseed loop init\` first`);
|
|
65
70
|
const state = parseLoopFile(readText(file));
|
|
66
71
|
const { bricks } = loadCatalog({ projectRoot: root });
|
|
67
72
|
const result = fn(state, bricks);
|
|
68
|
-
writeLoopFile(file, state);
|
|
73
|
+
if (write) writeLoopFile(file, state);
|
|
69
74
|
return result;
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -79,26 +84,30 @@ function resolveGraph(spec, catalog) {
|
|
|
79
84
|
|
|
80
85
|
const ACTIONS = {
|
|
81
86
|
check(args) {
|
|
87
|
+
// The project level of the catalogue is `<git root>/.geneseed/`, as for `init` and `next`,
|
|
88
|
+
// so a check run from a subdirectory sees the same bricks and templates they will.
|
|
89
|
+
const root = gitRootOf(process.cwd()) ?? process.cwd();
|
|
82
90
|
if (args.brick) {
|
|
83
91
|
const { problems } = parseBrick(readText(path.resolve(args.brick)), 'checked');
|
|
84
92
|
return { ok: !problems.length, problems };
|
|
85
93
|
}
|
|
86
94
|
if (args.graph) {
|
|
87
|
-
const catalog = loadCatalog({ projectRoot:
|
|
95
|
+
const catalog = loadCatalog({ projectRoot: root });
|
|
88
96
|
const problems = checkGraph(resolveGraph(args.graph, catalog), catalog.bricks);
|
|
89
97
|
return { ok: !problems.length, problems };
|
|
90
98
|
}
|
|
91
99
|
// The bare check doubles as the catalogue LISTING the loop skill composes a free graph
|
|
92
100
|
// from — frontmatter only: a brick's body reaches the model through `next`, for the node
|
|
93
101
|
// the engine chose, never as a menu.
|
|
94
|
-
const
|
|
95
|
-
const
|
|
102
|
+
const catalog = loadCatalog({ projectRoot: root });
|
|
103
|
+
const problems = catalogProblems({ projectRoot: root }, catalog);
|
|
104
|
+
const { bricks, templates, overridden } = catalog;
|
|
96
105
|
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
97
106
|
return {
|
|
98
107
|
ok: !problems.length, problems,
|
|
99
108
|
templates: [...templates.keys()].sort(byName),
|
|
100
109
|
bricks: [...bricks.values()].sort((a, b) => byName(a.name, b.name))
|
|
101
|
-
.map(({ body,
|
|
110
|
+
.map(({ body, reason, ...b }) => ({ ...b, ...(reason ? { reason } : {}) })),
|
|
102
111
|
overridden,
|
|
103
112
|
};
|
|
104
113
|
},
|
|
@@ -121,14 +130,14 @@ const ACTIONS = {
|
|
|
121
130
|
recordLoop({ root, branch: currentBranch(root), title: args.title });
|
|
122
131
|
return { file, graph: graph.name, preset: state.preset, status: state.status };
|
|
123
132
|
},
|
|
124
|
-
next: () => withState((s, b) => nextStep(s, b)),
|
|
133
|
+
next: () => withState((s, b) => nextStep(s, b), { write: false }),
|
|
125
134
|
score(args) {
|
|
126
135
|
if (args.diff) {
|
|
127
136
|
// X6: resolve (and validate) the gitdir BEFORE withState runs the state change, so a
|
|
128
137
|
// malformed `.git` file errors without advancing the loop.
|
|
129
138
|
const root = requireRoot();
|
|
130
139
|
const gitdir = gitDirOf(root);
|
|
131
|
-
return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin()))));
|
|
140
|
+
return writeCommitMessage(root, gitdir, withState((s) => scoreDiff(s, parseNumstat(stdin() ?? ''))));
|
|
132
141
|
}
|
|
133
142
|
if (!args.declared) throw new Error('score needs --declared or --diff');
|
|
134
143
|
return withState((s) => scoreDeclared(s, {
|
|
@@ -150,10 +159,15 @@ const ACTIONS = {
|
|
|
150
159
|
}
|
|
151
160
|
note = raw.replace(/\r\n/g, '\n').trimEnd();
|
|
152
161
|
}
|
|
153
|
-
|
|
162
|
+
let card = null;
|
|
163
|
+
if (args.card) {
|
|
164
|
+
try { card = JSON.parse(args.card); } catch (e) {
|
|
165
|
+
throw new Error(`--card is not valid JSON: ${e.message}`, { cause: e });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
154
168
|
const porcelain = stdin();
|
|
155
169
|
return withState((s, b) => recordOutcome(s, b, args.outcome, {
|
|
156
|
-
card, porcelain: porcelain ? porcelain.replace(/\r\n/g, '\n').trimEnd()
|
|
170
|
+
card, porcelain: porcelain === null ? null : porcelain.replace(/\r\n/g, '\n').trimEnd(), note,
|
|
157
171
|
}));
|
|
158
172
|
},
|
|
159
173
|
decide(args) {
|
package/js/loop/graph.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { WEIGHTS } from './score.mjs';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
const TERMINALS = new Set(['$close', '$stop']);
|
|
25
25
|
export const ENGINE_MAX_ITERATIONS = 20;
|
|
26
26
|
/** A template's optional `category`, in the order the console's Loops list shows them. */
|
|
27
27
|
export const CATEGORIES = ['architecture', 'tests', 'development', 'refactoring', 'day-to-day'];
|
package/js/loop/registry.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* WRITER CONVENTIONS, mirrored from `js/inspect/registry.mjs` (`installs.json`'s own registry)
|
|
8
8
|
* on purpose rather than invented fresh: `jsonDumpsIndent` because these rows are absolute
|
|
9
9
|
* paths and a username carrying an accent writes one here (ensure_ascii matters); a
|
|
10
|
-
* `<file>.tmp` + `
|
|
10
|
+
* `<file>.tmp` + `renameWithRetry` (LOOP.md's own atomic rename, retried on a transient Windows
|
|
11
|
+
* lock) so a crash mid-write cannot leave `loops.json` half-written where
|
|
11
12
|
* the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
|
|
12
13
|
* one; and every public function swallows its own errors — a registry hiccup must never fail
|
|
13
14
|
* `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
|
|
@@ -28,20 +29,20 @@
|
|
|
28
29
|
* re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
|
|
29
30
|
* again.
|
|
30
31
|
*
|
|
31
|
-
* `recordLoop` and `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* `recordLoop` and `setLoopPreset` resolve the root they are given the same way — `normalize`:
|
|
33
|
+
* `realpathSync.native`, falling back to `path.resolve` for a root that no longer exists — and
|
|
34
|
+
* compare it with `normcase`, the same case-folding `js/lib/paths.mjs` uses everywhere a Windows
|
|
35
|
+
* path is compared, so a loop launched from a differently-cased spelling of the same worktree is
|
|
36
|
+
* still one row, not two. `activeLoops` compares nothing: it reads each row's root as stored.
|
|
36
37
|
*/
|
|
37
|
-
import { readFileSync, mkdirSync, realpathSync
|
|
38
|
+
import { readFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
38
39
|
import path from 'node:path';
|
|
39
40
|
|
|
40
41
|
import { userLoopsDir } from './catalog.mjs';
|
|
41
42
|
import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
|
|
42
43
|
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
43
44
|
import { normcase } from '../lib/paths.mjs';
|
|
44
|
-
import { parseLoopFile, writeLoopFile, LOOP_FILE } from './state.mjs';
|
|
45
|
+
import { parseLoopFile, writeLoopFile, renameWithRetry, reviewOf, LOOP_FILE } from './state.mjs';
|
|
45
46
|
import { PRESETS } from './score.mjs';
|
|
46
47
|
|
|
47
48
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -65,7 +66,7 @@ function save(loops) {
|
|
|
65
66
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
66
67
|
const tmp = `${file}.tmp`;
|
|
67
68
|
writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
|
|
68
|
-
|
|
69
|
+
renameWithRetry(tmp, file);
|
|
69
70
|
} catch {
|
|
70
71
|
/* best-effort: a registry hiccup must never fail init or a read */
|
|
71
72
|
}
|
|
@@ -77,12 +78,6 @@ function normalize(dir) {
|
|
|
77
78
|
|
|
78
79
|
const sameRoot = (a, b) => normcase(a) === normcase(b);
|
|
79
80
|
|
|
80
|
-
/** The soft-decision iterations of `state.history` — the same shape `state.mjs`'s own
|
|
81
|
-
* end-of-run `summary()` reports, so a card and a finished-loop review read the same fields. */
|
|
82
|
-
function reviewOf(state) {
|
|
83
|
-
return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
81
|
function runningRow(entry, state) {
|
|
87
82
|
return {
|
|
88
83
|
root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
|
package/js/loop/score.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const PRESETS = Object.freeze({
|
|
|
29
29
|
export const DEFAULT_PRESET = 'balanced';
|
|
30
30
|
|
|
31
31
|
/** More deleted lines than this in one iteration counts as a code deletion. */
|
|
32
|
-
|
|
32
|
+
const DELETION_LINES = 20;
|
|
33
33
|
|
|
34
34
|
const RANK = { silent: 0, soft: 1, blocking: 2 };
|
|
35
35
|
|