geneseed 3.1.0 → 3.1.2
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 +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/lib/text.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String primitives — measuring, padding, stripping, decoding and reading an integer.
|
|
3
|
+
*
|
|
4
|
+
* These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
|
|
5
|
+
* measurements against CPython are recorded per function, and why NONE of these is the Node
|
|
6
|
+
* default — the warning the retired `py` prefix used to carry now lives in each docblock.
|
|
7
|
+
* Every function here is a place the Node default DIVERGES from the answer this tool needs:
|
|
8
|
+
* `codePointLength` counts code points where `.length` counts units, `WHITESPACE` is a wider
|
|
9
|
+
* set than `String.trim`'s, `parseIntStrict` accepts what `Number()` does not and rejects what
|
|
10
|
+
* it does, and `percentDecode` leaves a bad escape alone where `decodeURIComponent` throws.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
|
|
14
|
+
*
|
|
15
|
+
* They differ on every astral character: `len("𝔊")` is 1 and `"𝔊".length` is 2. Only
|
|
16
|
+
* matters where a length becomes layout, which is exactly what the status panel does with
|
|
17
|
+
* it — a theme name or a memory path outside the BMP would shear the frame by one column
|
|
18
|
+
* per character under `.length`.
|
|
19
|
+
*/
|
|
20
|
+
export const codePointLength = (s) => [...s].length;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `str.ljust(width)` — pad with spaces to `width` CODE POINTS, never truncate.
|
|
24
|
+
*
|
|
25
|
+
* `String.padEnd` counts UTF-16 units, so it is `codePointLength`'s difference again, one call later.
|
|
26
|
+
*/
|
|
27
|
+
export const padEndToWidth = (s, width) => s + ' '.repeat(Math.max(0, width - codePointLength(s)));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `int(s)` for a base-10 string — the VALUE, or null where Python raises `ValueError`.
|
|
31
|
+
*
|
|
32
|
+
* Null rather than a throw because EVERY call site branches on the failure rather than
|
|
33
|
+
* propagating it: `askChoice` falls through to matching the answer against the option KEYS,
|
|
34
|
+
* `javaMajorOk` treats an unparseable major as "not new enough", and the terminal-width,
|
|
35
|
+
* port and timeout readers each fall back to a default. Python spells that `except ValueError`.
|
|
36
|
+
*
|
|
37
|
+
* Three things separate this from `Number(s)` and each one changes an answer:
|
|
38
|
+
*
|
|
39
|
+
* * `Number('')` is 0 and `int('')` raises. So is `Number(' ')`, and `Number('0x10')` is
|
|
40
|
+
* 16 where `int('0x10')` raises — a wizard answer of `0x2` must fall through to the key
|
|
41
|
+
* match, not select option 16.
|
|
42
|
+
* * PEP 515 underscores: `int('1_0')` is 10, `Number('1_0')` is NaN. Leading, trailing and
|
|
43
|
+
* doubled ones still raise, so the rule is "between digits" and not "strip them".
|
|
44
|
+
* * `int()` accepts any Unicode decimal digit — `int('٣')` is 3 — and `Number('٣')` is NaN.
|
|
45
|
+
* Every `Nd` block is exactly ten contiguous code points, so a digit's value is its
|
|
46
|
+
* offset from the first `Nd` code point at or below it, which is what the walk finds.
|
|
47
|
+
*
|
|
48
|
+
* Surrounding whitespace is Python's to strip and this does NOT strip it: `int(' 1')` is 1
|
|
49
|
+
* where this answers null. STRIPPING IS THE CALLER'S JOB, and the callers that are handed a
|
|
50
|
+
* raw environment value do it — `js/ui/cli.mjs`'s `helpWidth` and `js/web/activity.mjs` wrap
|
|
51
|
+
* the argument in `stripWhitespace` first.
|
|
52
|
+
*
|
|
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
|
+
*/
|
|
56
|
+
export function parseIntStrict(s) {
|
|
57
|
+
if (typeof s !== 'string' || s === '') return null;
|
|
58
|
+
let body = s;
|
|
59
|
+
let sign = 1;
|
|
60
|
+
if (body[0] === '+' || body[0] === '-') {
|
|
61
|
+
if (body[0] === '-') sign = -1;
|
|
62
|
+
body = body.slice(1);
|
|
63
|
+
}
|
|
64
|
+
const chars = [...body];
|
|
65
|
+
if (chars.length === 0) return null;
|
|
66
|
+
let value = 0n;
|
|
67
|
+
let prevWasDigit = false;
|
|
68
|
+
for (const ch of chars) {
|
|
69
|
+
if (ch === '_') {
|
|
70
|
+
// Only BETWEEN digits: a leading, trailing or doubled underscore is a ValueError.
|
|
71
|
+
if (!prevWasDigit) return null;
|
|
72
|
+
prevWasDigit = false;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!/^\p{Nd}$/u.test(ch)) return null;
|
|
76
|
+
const cp = ch.codePointAt(0);
|
|
77
|
+
let zero = cp;
|
|
78
|
+
while (zero > 0 && /^\p{Nd}$/u.test(String.fromCodePoint(zero - 1))) zero -= 1;
|
|
79
|
+
value = value * 10n + BigInt(cp - zero);
|
|
80
|
+
prevWasDigit = true;
|
|
81
|
+
}
|
|
82
|
+
if (!prevWasDigit) return null; // a trailing underscore
|
|
83
|
+
return Number(sign === -1 ? -value : value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Python's `\s` for a `str` pattern, as a character-class BODY — measured, not recalled.
|
|
88
|
+
*
|
|
89
|
+
* The two languages' whitespace classes are nearly the same and the difference had been
|
|
90
|
+
* carried as a standing item reading "differing from JS `\s` only at U+FEFF". That was
|
|
91
|
+
* incomplete in both directions, and a corpus over `slugifyHeading` is what found it:
|
|
92
|
+
*
|
|
93
|
+
* python \s = js \s - U+FEFF + U+001C..U+001F + U+0085
|
|
94
|
+
*
|
|
95
|
+
* JavaScript counts the byte-order mark as whitespace and Python does not; Python counts
|
|
96
|
+
* the four C0 information separators and the NEL, and JavaScript does not. Neither shows
|
|
97
|
+
* up in a docs heading anyone has written — which is exactly why it took a corpus rather
|
|
98
|
+
* than a cell, and why it is spelled out here rather than approximated with `\s`.
|
|
99
|
+
*/
|
|
100
|
+
export const WHITESPACE = '\t\n\v\f\r \u001c-\u001f\u0085\u00a0\u1680'
|
|
101
|
+
+ '\u2000-\u200a\u2028\u2029\u202f\u205f\u3000';
|
|
102
|
+
|
|
103
|
+
const STRIP_RE = new RegExp(`^[${WHITESPACE}]+|[${WHITESPACE}]+$`, 'g');
|
|
104
|
+
const STRIP_START_RE = new RegExp(`^[${WHITESPACE}]+`);
|
|
105
|
+
const STRIP_END_RE = new RegExp(`[${WHITESPACE}]+$`);
|
|
106
|
+
|
|
107
|
+
/** `str.strip()` — Python's whitespace set, which `String.trim()`'s is not. */
|
|
108
|
+
export function stripWhitespace(s) {
|
|
109
|
+
return s.replace(STRIP_RE, '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `str.lstrip()` / `str.rstrip()` — the ONE-SIDED halves, over the same `WHITESPACE`.
|
|
114
|
+
*
|
|
115
|
+
* P2 added them for `_build_render._insert_theme_keys`, which uses all three spellings within
|
|
116
|
+
* four lines (`lines[0].strip()`, `ln.lstrip().startswith('"')`,
|
|
117
|
+
* `lines[pred].rstrip().endswith(",")`). They are here rather than in `js/build/themes.mjs` for the
|
|
118
|
+
* reason this file exists at all: `js/hosts/settings.mjs` already carries a private second copy of
|
|
119
|
+
* the character class, and a THIRD one — in the module that decides where a comma goes in a
|
|
120
|
+
* committed theme file — is how the three drift apart. One owner of the set, three views of it.
|
|
121
|
+
*/
|
|
122
|
+
export function stripWhitespaceStart(s) {
|
|
123
|
+
return s.replace(STRIP_START_RE, '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function stripWhitespaceEnd(s) {
|
|
127
|
+
return s.replace(STRIP_END_RE, '');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `urllib.parse.unquote(s)` — percent-decoding as Python does it, which is NOT
|
|
131
|
+
* `decodeURIComponent`.
|
|
132
|
+
*
|
|
133
|
+
* The whole difference is what happens to a sequence that is not an escape.
|
|
134
|
+
* `decodeURIComponent('a%ZZ')` throws a `URIError`, and so does a trailing bare `%`; the
|
|
135
|
+
* web shell would answer both as a 500 where the reference answers a 404 naming the
|
|
136
|
+
* literal text. `unquote` leaves them alone: CPython builds a table of every two-hex-digit
|
|
137
|
+
* pair in both cases and treats a `KeyError` as "this was not an escape", emitting `%` and
|
|
138
|
+
* the rest of the fragment verbatim.
|
|
139
|
+
*
|
|
140
|
+
* The outer split on `([\x00-\x7f]+)` is Python's too, and it is not decoration: only the
|
|
141
|
+
* ASCII runs are candidates for decoding, so a literal non-ASCII character already in the
|
|
142
|
+
* path is passed through rather than mixed into the byte buffer. Consecutive escapes ARE
|
|
143
|
+
* accumulated before decoding, which is what makes `%C3%A9` one `é` and not two
|
|
144
|
+
* replacement characters.
|
|
145
|
+
*
|
|
146
|
+
* `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
|
|
147
|
+
* `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
|
|
148
|
+
* 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.
|
|
151
|
+
*/
|
|
152
|
+
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
|
|
153
|
+
|
|
154
|
+
function unquoteToBytes(run) {
|
|
155
|
+
const bits = run.split('%');
|
|
156
|
+
if (bits.length === 1) return Buffer.from(run, 'latin1');
|
|
157
|
+
const out = [Buffer.from(bits[0], 'latin1')];
|
|
158
|
+
for (const item of bits.slice(1)) {
|
|
159
|
+
const pair = item.slice(0, 2);
|
|
160
|
+
if (HEX_PAIR.test(pair)) {
|
|
161
|
+
out.push(Buffer.from([parseInt(pair, 16)]), Buffer.from(item.slice(2), 'latin1'));
|
|
162
|
+
} else {
|
|
163
|
+
out.push(Buffer.from(`%${item}`, 'latin1'));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return Buffer.concat(out);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function percentDecode(s) {
|
|
170
|
+
if (!s.includes('%')) return s;
|
|
171
|
+
// `re.split` with ONE capture group alternates [gap, group, gap, group, …], so the ASCII
|
|
172
|
+
// runs are the odd indices on both sides.
|
|
173
|
+
const bits = s.split(/([\x00-\x7f]+)/);
|
|
174
|
+
let res = bits[0];
|
|
175
|
+
for (let i = 1; i < bits.length; i += 2) {
|
|
176
|
+
res += unquoteToBytes(bits[i]).toString('utf-8');
|
|
177
|
+
res += bits[i + 1] ?? '';
|
|
178
|
+
}
|
|
179
|
+
return res;
|
|
180
|
+
}
|
|
181
|
+
|
package/js/lib/udiff.mjs
CHANGED
|
@@ -260,7 +260,7 @@ export function unifiedDiff(a, b, {
|
|
|
260
260
|
*/
|
|
261
261
|
const LINE_BOUNDARY = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
|
|
262
262
|
|
|
263
|
-
export function
|
|
263
|
+
export function splitLines(text) {
|
|
264
264
|
const out = [];
|
|
265
265
|
let last = 0;
|
|
266
266
|
LINE_BOUNDARY.lastIndex = 0;
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
import { readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
|
|
33
|
-
import { memoryDropIndex } from '
|
|
34
|
-
import { resolveMemoryDir } from '
|
|
35
|
-
import {
|
|
33
|
+
import { memoryDropIndex } from '../hosts/hooks.mjs';
|
|
34
|
+
import { resolveMemoryDir } from '../hosts/hosts.mjs';
|
|
35
|
+
import { printOut, printErr } from '../lib/fs.mjs';
|
|
36
36
|
|
|
37
37
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
38
38
|
|
|
@@ -77,22 +77,22 @@ export function cmdMemory(args) {
|
|
|
77
77
|
if (dir === null) {
|
|
78
78
|
// The resolver answers null for "no store anywhere on the chain", which is also what an
|
|
79
79
|
// explicit `--memory` that is not a directory gives — so the message names both.
|
|
80
|
-
|
|
80
|
+
printErr('[memory] no memory store found. Pass --memory <dir>, set $GENESEED_MEMORY, '
|
|
81
81
|
+ 'or run this from a directory with a memory/ store.\n');
|
|
82
82
|
return 1;
|
|
83
83
|
}
|
|
84
84
|
if (args.action === 'list') {
|
|
85
85
|
const facts = memoryFacts(dir);
|
|
86
86
|
if (!facts.length) {
|
|
87
|
-
|
|
87
|
+
printOut(`[memory] ${dir}: no facts.\n`);
|
|
88
88
|
return 0;
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
for (const name of facts)
|
|
90
|
+
printOut(`[memory] ${dir}\n`);
|
|
91
|
+
for (const name of facts) printOut(` ${name}\n`);
|
|
92
92
|
return 0;
|
|
93
93
|
}
|
|
94
94
|
if (!args.name) {
|
|
95
|
-
|
|
95
|
+
printErr('[memory] rm needs the name of a fact.\n');
|
|
96
96
|
return 2;
|
|
97
97
|
}
|
|
98
98
|
if (!memoryDelete(dir, args.name)) {
|
|
@@ -100,9 +100,9 @@ export function cmdMemory(args) {
|
|
|
100
100
|
// `NotFound(name)` for a reserved name, a name with a separator and a fact that is not
|
|
101
101
|
// there. Telling a caller that `MEMORY` exists but is protected is a distinction this
|
|
102
102
|
// verb has no reason to draw.
|
|
103
|
-
|
|
103
|
+
printErr(`[memory] no such fact '${args.name}' in ${dir}.\n`);
|
|
104
104
|
return 1;
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
printOut(`[memory] deleted ${args.name}.\n`);
|
|
107
107
|
return 0;
|
|
108
108
|
}
|
|
@@ -24,25 +24,27 @@
|
|
|
24
24
|
* THE TWO TRIGGERS, AND WHY IT TAKES BOTH. Work is needed when the machine's wiring is
|
|
25
25
|
* `legacy` (see `migrateShape` — and note that shapes 2 and 3 differ only inside the shim
|
|
26
26
|
* body, never in the settings file) OR when the stamp does not name this ROOT. Neither alone
|
|
27
|
-
*
|
|
28
|
-
* `none` and a shape-only trigger would re-emit it on every run forever; and
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* was enough: an OpenCode-only machine wires no hooks at all, so its shape is permanently
|
|
28
|
+
* `none` and a shape-only trigger would re-emit it on every run forever; and reinstalling the
|
|
29
|
+
* old build flipped the shape back to `legacy` while the stamp still matched, which had to
|
|
30
|
+
* migrate again. With the old build gone the second case can no longer arise, but the shape
|
|
31
|
+
* read stays: it is what makes the verb idempotent rather than stamp-dependent. The stamp is
|
|
32
|
+
* written only on success.
|
|
31
33
|
*/
|
|
32
34
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
33
35
|
import path from 'node:path';
|
|
34
36
|
|
|
35
|
-
import { main as driverMain } from '
|
|
36
|
-
import { ROOT } from '
|
|
37
|
+
import { main as driverMain } from '../../bin/build-driver.mjs';
|
|
38
|
+
import { ROOT } from '../build/source.mjs';
|
|
37
39
|
import {
|
|
38
40
|
EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
|
|
39
41
|
footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
|
|
40
|
-
} from '
|
|
41
|
-
import { DEFAULT_EMIT, setupBuildArgs } from '
|
|
42
|
+
} from '../hosts/installs.mjs';
|
|
43
|
+
import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
|
|
42
44
|
import {
|
|
43
45
|
autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
|
|
44
|
-
} from '
|
|
45
|
-
import { writeText,
|
|
46
|
+
} from '../hosts/settings.mjs';
|
|
47
|
+
import { writeText, printOut, printErr } from '../lib/fs.mjs';
|
|
46
48
|
|
|
47
49
|
/** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
|
|
48
50
|
const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
|
|
@@ -50,7 +52,7 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
|
|
|
50
52
|
/**
|
|
51
53
|
* The settings file a host wires hooks into, or null for a host that has none.
|
|
52
54
|
*
|
|
53
|
-
* Only Claude and Bob carry hook commands. OpenCode's hooks are a PLUGIN (`js/opencode.mjs`
|
|
55
|
+
* Only Claude and Bob carry hook commands. OpenCode's hooks are a PLUGIN (`js/hosts/opencode.mjs`
|
|
54
56
|
* emits JS that runs in the host's own process and names neither the shim nor `harness.py`),
|
|
55
57
|
* and Copilot has no hook mechanism at all. So those two classify as `none` and their whole
|
|
56
58
|
* migration is the re-emit — which is the same re-emit, in the same change, on all three
|
|
@@ -150,34 +152,34 @@ export function cmdMigrate(args = {}) {
|
|
|
150
152
|
if (unrecognised.length) {
|
|
151
153
|
// Refuse the WHOLE run. A migration that skipped the row it did not understand and
|
|
152
154
|
// migrated the rest is the half-migrated machine this verb exists to prevent.
|
|
153
|
-
|
|
155
|
+
printErr('[migrate] REFUSED — unrecognised install marker(s):\n');
|
|
154
156
|
for (const u of unrecognised) {
|
|
155
|
-
|
|
157
|
+
printErr(`[migrate] ${u.host}:${u.scope} (${u.root}) has .geneseed-emit `
|
|
156
158
|
+ `'${u.marker}', which is not an emit this generator answers to.\n`);
|
|
157
159
|
}
|
|
158
|
-
|
|
160
|
+
printErr('[migrate] Nothing was changed. Fix or delete the marker, then re-run.\n');
|
|
159
161
|
return 3;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
if (!rows.length) {
|
|
163
|
-
|
|
165
|
+
printOut('[migrate] no active installs detected.\n');
|
|
164
166
|
return 0;
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
if (!needed) {
|
|
168
|
-
|
|
170
|
+
printOut(`[migrate] already on the npm shape (${rows.length} install(s)); nothing to do.\n`);
|
|
169
171
|
return 0;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
|
|
174
|
+
printOut(`[migrate] ${rows.length} install(s), wiring shape: ${shape}\n`);
|
|
173
175
|
if (dryRun) {
|
|
174
176
|
for (const r of rows) {
|
|
175
|
-
|
|
177
|
+
printOut(`[migrate] would re-emit ${r.host}:${r.scope} (${r.root})\n`);
|
|
176
178
|
}
|
|
177
179
|
for (const p of autostartFindings()) {
|
|
178
|
-
|
|
180
|
+
printOut(`[migrate] would report the autostart entry at ${p}\n`);
|
|
179
181
|
}
|
|
180
|
-
|
|
182
|
+
printOut('[migrate] --dry-run: nothing was written.\n');
|
|
181
183
|
return 0;
|
|
182
184
|
}
|
|
183
185
|
|
|
@@ -195,7 +197,7 @@ export function cmdMigrate(args = {}) {
|
|
|
195
197
|
saved.push([p, dest]);
|
|
196
198
|
}
|
|
197
199
|
} catch (e) {
|
|
198
|
-
|
|
200
|
+
printErr(`[migrate] could not stage a backup (${e.message}); nothing was changed.\n`);
|
|
199
201
|
return 1;
|
|
200
202
|
}
|
|
201
203
|
|
|
@@ -222,15 +224,15 @@ export function cmdMigrate(args = {}) {
|
|
|
222
224
|
postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
|
|
223
225
|
doctrinesForBuild(r.root));
|
|
224
226
|
const label = `${r.host}:${r.scope} (${r.root})`;
|
|
225
|
-
|
|
227
|
+
printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
|
|
226
228
|
let rc = 1;
|
|
227
229
|
try { rc = driverMain(argv); } catch { rc = 1; }
|
|
228
230
|
if (rc !== 0) {
|
|
229
231
|
// Step 4 — ALL OR NOTHING. `rebuild-all` continues here on purpose; a migration
|
|
230
232
|
// must not, or the machine is left half on each shape.
|
|
231
|
-
|
|
233
|
+
printErr(`[migrate] FAILED ${label} (exit ${rc}) — rolling back.\n`);
|
|
232
234
|
restore();
|
|
233
|
-
|
|
235
|
+
printErr(`[migrate] rolled back ${saved.length} file(s); the install is unchanged.\n`);
|
|
234
236
|
return 1;
|
|
235
237
|
}
|
|
236
238
|
}
|
|
@@ -241,7 +243,7 @@ export function cmdMigrate(args = {}) {
|
|
|
241
243
|
writeText(stampPath(), `${ROOT}\n`);
|
|
242
244
|
} catch { /* an unwritable stamp costs a redundant re-emit next run, never correctness */ }
|
|
243
245
|
|
|
244
|
-
|
|
246
|
+
printOut(`[migrate] migrated ${rows.length} install(s).\n`);
|
|
245
247
|
|
|
246
248
|
// Step 5 — what could NOT be fixed automatically. Reported, never rewritten: nothing in
|
|
247
249
|
// this repository has ever written an autostart entry, so migrate does not own the file.
|
|
@@ -250,7 +252,7 @@ export function cmdMigrate(args = {}) {
|
|
|
250
252
|
// `web restart` and `web status` stay blind to the server this note just told someone to
|
|
251
253
|
// launch at every login. Derived, not trusted: `TheMigrateNoteAdvisesARealWebAction`.
|
|
252
254
|
for (const p of autostartFindings()) {
|
|
253
|
-
|
|
255
|
+
printOut(`[migrate] NOTE: the autostart entry at ${p} still names another checkout — `
|
|
254
256
|
+ 'update it by hand to run: geneseed web start --no-browser\n');
|
|
255
257
|
}
|
|
256
258
|
return 0;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The install wizard — the LINE-MODE flow, and the first verb in
|
|
3
3
|
* this port that reads stdin.
|
|
4
4
|
*
|
|
5
5
|
* WHAT `setup` ACTUALLY IS, measured rather than taken from the brief. `cmd_setup` is 21
|
|
@@ -44,18 +44,20 @@ import { spawnSync } from 'node:child_process';
|
|
|
44
44
|
import { existsSync, readSync } from 'node:fs';
|
|
45
45
|
import path from 'node:path';
|
|
46
46
|
|
|
47
|
-
import { main as driverMain, resolveOut } from '
|
|
48
|
-
import { playLine } from '
|
|
49
|
-
import { discoverNames, PACK_ORDER } from '
|
|
50
|
-
import { exportImprovements } from '
|
|
51
|
-
import { cmdDoctor } from '
|
|
52
|
-
import { setupBuildArgs } from '
|
|
53
|
-
import { GLOBAL_MANIFEST, opencodeConfigDir } from '
|
|
47
|
+
import { main as driverMain, resolveOut } from '../../bin/build-driver.mjs';
|
|
48
|
+
import { playLine } from '../ui/anim.mjs';
|
|
49
|
+
import { discoverNames, PACK_ORDER } from '../build/source.mjs';
|
|
50
|
+
import { exportImprovements } from '../inspect/diff.mjs';
|
|
51
|
+
import { cmdDoctor } from '../inspect/doctor.mjs';
|
|
52
|
+
import { setupBuildArgs } from '../build/generate.mjs';
|
|
53
|
+
import { GLOBAL_MANIFEST, opencodeConfigDir } from '../hosts/hosts.mjs';
|
|
54
54
|
import {
|
|
55
55
|
defaultMode, defaultPosture, defaultTheme, installedDefaults, themeFiles,
|
|
56
|
-
} from '
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
56
|
+
} from '../hosts/installs.mjs';
|
|
57
|
+
import { printOut, printErr } from '../lib/fs.mjs';
|
|
58
|
+
import { which } from '../lib/paths.mjs';
|
|
59
|
+
import { parseIntStrict } from '../lib/text.mjs';
|
|
60
|
+
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
59
61
|
|
|
60
62
|
// --------------------------------------------------------------------------------------
|
|
61
63
|
// the three readers
|
|
@@ -68,7 +70,7 @@ import { NO_WINDOW } from './lib/proc.mjs';
|
|
|
68
70
|
* until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
|
|
69
71
|
* until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
|
|
70
72
|
* `input()` does, and it is the only synchronous shape that stops where Enter stops. The
|
|
71
|
-
* body arrived in P5h inside `js/uninstall.mjs`'s `confirm`; `setup` is the second caller,
|
|
73
|
+
* body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`; `setup` is the second caller,
|
|
72
74
|
* so it moved to where the Python's own owner is — `_harness_setup` — and `confirm` is built
|
|
73
75
|
* on it here exactly as `_confirm` is built on `_ask` there.
|
|
74
76
|
*
|
|
@@ -83,7 +85,7 @@ import { NO_WINDOW } from './lib/proc.mjs';
|
|
|
83
85
|
*/
|
|
84
86
|
export function ask(prompt, dflt = '') {
|
|
85
87
|
const suffix = dflt ? ` [${dflt}]` : '';
|
|
86
|
-
// `input()` writes its prompt to stdout with no newline — nothing for `
|
|
88
|
+
// `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
|
|
87
89
|
// translate, and it must NOT gain one.
|
|
88
90
|
process.stdout.write(`${prompt}${suffix}: `);
|
|
89
91
|
// `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
|
|
@@ -154,7 +156,7 @@ export function confirm(prompt, dflt = true) {
|
|
|
154
156
|
* integer never reaches it. `99` against a five-option menu returns the default; it does
|
|
155
157
|
* not go looking for an option literally named `99`.
|
|
156
158
|
* * An in-range index wins, a parsed out-of-range index falls to the default, and only an
|
|
157
|
-
* UNPARSEABLE answer is matched against the keys. `
|
|
159
|
+
* UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
|
|
158
160
|
* case from the third, and it is not `Number()` — see its own docblock.
|
|
159
161
|
* * The default's index is computed with `next(...)` and no fallback, so a default that is
|
|
160
162
|
* not in `options` raises `StopIteration` on the reference. That is reachable (a
|
|
@@ -163,15 +165,15 @@ export function confirm(prompt, dflt = true) {
|
|
|
163
165
|
* place, and a silent `-1` here would be a divergence dressed as robustness.
|
|
164
166
|
*/
|
|
165
167
|
export function askChoice(prompt, options, dflt) {
|
|
166
|
-
|
|
168
|
+
printOut(`\n${prompt}:\n`);
|
|
167
169
|
options.forEach(([key, desc], i) => {
|
|
168
170
|
const label = desc ? `${key} — ${desc}` : key;
|
|
169
|
-
|
|
171
|
+
printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
|
|
170
172
|
});
|
|
171
173
|
const defaultIdx = options.findIndex(([k]) => k === dflt);
|
|
172
174
|
if (defaultIdx < 0) throw new Error(`StopIteration: no option named ${dflt}`);
|
|
173
175
|
const raw = ask('Choose', String(defaultIdx + 1));
|
|
174
|
-
const idx =
|
|
176
|
+
const idx = parseIntStrict(raw);
|
|
175
177
|
if (idx !== null) {
|
|
176
178
|
if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
|
|
177
179
|
} else {
|
|
@@ -359,7 +361,7 @@ function askDoctrines(deployed = null) {
|
|
|
359
361
|
* carrier said none" and the two open the question differently.
|
|
360
362
|
*/
|
|
361
363
|
export function collectSetupLines() {
|
|
362
|
-
|
|
364
|
+
printOut('Geneseed setup — answer a few questions; nothing is written until you confirm.\n');
|
|
363
365
|
const inst = installedDefaults();
|
|
364
366
|
const theme = askChoice('Theme', themeOptions(), inst.theme || defaultTheme());
|
|
365
367
|
const posture = askChoice('Posture', postureOptions(), inst.posture || defaultPosture());
|
|
@@ -383,11 +385,21 @@ export function collectSetupLines() {
|
|
|
383
385
|
// own: the menu the user just answered was built from `doctrineOptions()`, so "they picked
|
|
384
386
|
// everything" must be judged against that same list, not against a second one.
|
|
385
387
|
const allPacks = doctrineOptions().map(([n]) => n);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
388
|
+
// ⚠ PRESERVED, NOT ASKED. The wizard has no per-rule question — five registers is already
|
|
389
|
+
// the ceiling for a prompt sequence somebody answers by holding Enter, and a rule list is a
|
|
390
|
+
// console control, not a terminal one. But it must not be DROPPED either: re-running the
|
|
391
|
+
// wizard on an install that excluded `process 5` would otherwise hand the consent gate back
|
|
392
|
+
// without a word, which is the same silent widening the pack question was fixed for.
|
|
393
|
+
//
|
|
394
|
+
// `null` when there is nothing to preserve rather than `[]`: an empty list makes
|
|
395
|
+
// `setupBuildArgs` spell `--exclude-rules none`, and that string would land in the
|
|
396
|
+
// "About to run:" line every recorded wizard probe compares byte for byte.
|
|
397
|
+
const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
|
|
398
|
+
printOut(`\nAbout to run: geneseed build ${
|
|
399
|
+
setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
|
|
400
|
+
excludeRules).join(' ')}\n`);
|
|
389
401
|
if (!confirm('Proceed?', true)) return null;
|
|
390
|
-
return { theme, posture, mode, doctrines, emit, out, root, footprint };
|
|
402
|
+
return { theme, posture, mode, doctrines, excludeRules, emit, out, root, footprint };
|
|
391
403
|
}
|
|
392
404
|
|
|
393
405
|
// --------------------------------------------------------------------------------------
|
|
@@ -399,14 +411,14 @@ export function collectSetupLines() {
|
|
|
399
411
|
*
|
|
400
412
|
* `21.0.2` gives 21 and the legacy `1.8.0` gives 1, which is never >= 21 — that asymmetry is
|
|
401
413
|
* the whole function. `\p{Nd}` rather than `[0-9]` because Python's `\d` matches any Unicode
|
|
402
|
-
* decimal and JS's does not without the flag, and `
|
|
414
|
+
* decimal and JS's does not without the flag, and `parseIntStrict` reads the group for the same
|
|
403
415
|
* reason. No `java` prints such a banner; matching Python where it is free is cheaper than a
|
|
404
416
|
* comment explaining where it does not.
|
|
405
417
|
*/
|
|
406
418
|
export function javaMajorOk(versionOutput, minimum = 21) {
|
|
407
419
|
const m = /version "(\p{Nd}+)/u.exec(versionOutput);
|
|
408
420
|
if (!m) return false;
|
|
409
|
-
const n =
|
|
421
|
+
const n = parseIntStrict(m[1]);
|
|
410
422
|
return n !== null && n >= minimum;
|
|
411
423
|
}
|
|
412
424
|
|
|
@@ -415,14 +427,14 @@ export function javaMajorOk(versionOutput, minimum = 21) {
|
|
|
415
427
|
* for itself. Today that is a JDK 21+ for `jdtls`; the JS-runtime servers self-download.
|
|
416
428
|
*
|
|
417
429
|
* THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `test_the_cli_reaches_child_process_\
|
|
418
|
-
* only_where_it_is_declared` became a TABLE. `js/doctor.mjs` was the sole allowed importer
|
|
430
|
+
* only_where_it_is_declared` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
|
|
419
431
|
* for one binding and one argv; this is the second, and the port's rule is that the second
|
|
420
432
|
* instance of anything turns a special case into a table cross-checked against the source.
|
|
421
433
|
* The argv is asserted literally there, so an allow-list entry cannot be reused to start an
|
|
422
434
|
* interpreter.
|
|
423
435
|
*/
|
|
424
436
|
export function lspPrereqs() {
|
|
425
|
-
const java =
|
|
437
|
+
const java = which('java');
|
|
426
438
|
let ok = false;
|
|
427
439
|
if (java) {
|
|
428
440
|
try {
|
|
@@ -512,7 +524,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
|
|
|
512
524
|
* sides (`_harness_build.cmd_build`, and `driverMain` here), so the line still names a
|
|
513
525
|
* command the reader can type, and names one that survives the deletion.
|
|
514
526
|
*
|
|
515
|
-
* `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/anim.mjs`, gated by
|
|
527
|
+
* `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/ui/anim.mjs`, gated by
|
|
516
528
|
* `_theme_anim_cases()` in `tests/test_pure_function_parity.py`. The bare `catch` around it
|
|
517
529
|
* is the reference's own, labelled there "cosmetic only — never block a successful install",
|
|
518
530
|
* and it is kept for the same reason: the animation runs AFTER the build has already
|
|
@@ -521,12 +533,12 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
|
|
|
521
533
|
export function setupLines() {
|
|
522
534
|
const sel = collectSetupLines();
|
|
523
535
|
if (!sel) {
|
|
524
|
-
|
|
536
|
+
printOut('[setup] cancelled — nothing written.\n');
|
|
525
537
|
return 0;
|
|
526
538
|
}
|
|
527
539
|
const {
|
|
528
540
|
theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
|
|
529
|
-
doctrines = null,
|
|
541
|
+
doctrines = null, excludeRules = null,
|
|
530
542
|
} = sel;
|
|
531
543
|
if (emit === 'opencode-global') {
|
|
532
544
|
// The build below overwrites the deployed global harness, and the self-improvement loops
|
|
@@ -534,29 +546,29 @@ export function setupLines() {
|
|
|
534
546
|
try {
|
|
535
547
|
const [ipath] = exportImprovements();
|
|
536
548
|
if (ipath) {
|
|
537
|
-
|
|
538
|
-
|
|
549
|
+
printOut(`- local edits found in the deployed harness — saved to ${ipath}\n`);
|
|
550
|
+
printOut(' (hand that file to your agent to back-port them into src/)\n');
|
|
539
551
|
}
|
|
540
552
|
} catch (e) {
|
|
541
|
-
|
|
553
|
+
printOut(`! could not export local edits (${e && e.message}) — continuing.\n`);
|
|
542
554
|
}
|
|
543
555
|
}
|
|
544
556
|
// Same list the question was built from — see `collectSetupLines`. The build that actually
|
|
545
557
|
// runs must carry the pack selection the plan line just previewed, or the wizard would print
|
|
546
558
|
// one command and run another.
|
|
547
559
|
const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
|
|
548
|
-
doctrineOptions().map(([n]) => n));
|
|
549
|
-
|
|
560
|
+
doctrineOptions().map(([n]) => n), excludeRules);
|
|
561
|
+
printOut(`Running: geneseed build ${argv.join(' ')}\n`);
|
|
550
562
|
const rc = driverMain(argv);
|
|
551
563
|
if (rc !== 0) {
|
|
552
|
-
|
|
564
|
+
printErr('[setup] build failed — no harness written (see the output above).\n');
|
|
553
565
|
return rc;
|
|
554
566
|
}
|
|
555
567
|
try {
|
|
556
568
|
playLine(theme, true); // themed install animation (motion → reveal card)
|
|
557
569
|
} catch { /* cosmetic only — never block a successful install */ }
|
|
558
570
|
for (const [kind, text] of setupSummaryLines(theme, emit, out, root, true)) {
|
|
559
|
-
|
|
571
|
+
printOut(`${{ ok: '✓', warn: '!', info: '-' }[kind] ?? '-'} ${text}\n`);
|
|
560
572
|
}
|
|
561
573
|
if (confirm('\nRun a health check (doctor) now?', true)) {
|
|
562
574
|
// Scoped to the theme just installed — no full-sweep noise post-install.
|
|
@@ -574,7 +586,7 @@ export function setupLines() {
|
|
|
574
586
|
*/
|
|
575
587
|
export function cmdSetup() {
|
|
576
588
|
if (!process.stdin.isTTY) {
|
|
577
|
-
|
|
589
|
+
printErr('[setup] needs an interactive terminal. Non-interactive? e.g.:\n'
|
|
578
590
|
+ ' geneseed build --emit opencode-global --theme neutral\n');
|
|
579
591
|
return 1;
|
|
580
592
|
}
|