geneseed 3.1.3 → 3.2.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 +70 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/bin/build-driver.mjs +127 -79
- package/bin/geneseed-cli.mjs +42 -84
- package/bin/geneseed-hook.mjs +5 -2
- package/harness.config.json +1 -1
- package/js/README.md +1 -10
- package/js/build/emit-claude.mjs +4 -5
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +13 -13
- package/js/build/source.mjs +7 -6
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/hosts/hooks.mjs +3 -3
- package/js/hosts/hosts.mjs +2 -4
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/native.mjs +37 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +33 -110
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +4 -10
- package/js/inspect/inventory.mjs +37 -55
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +1 -1
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +90 -78
- package/js/maintain/update.mjs +2 -2
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +133 -224
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +66 -111
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +78 -97
- package/js/web/jobs.mjs +124 -165
- package/js/web/routes.mjs +73 -152
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/process.md +4 -2
- package/src/laws/universal.md +55 -43
- package/src/skills/clarify.md +1 -1
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/ingest.md +2 -1
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
- package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
- package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
- package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
- package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
- package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
- package/web/dist/assets/Harness-vRYs11kX.js +1 -0
- package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
- package/web/dist/assets/Library-CjC7BAWN.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
- package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
- package/web/dist/assets/Skills-BvatJGfe.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
- package/web/dist/assets/index-BnRWQbZb.js +12 -0
- package/web/dist/assets/index-Bx3I7cRW.js +2 -0
- package/web/dist/assets/index-D0f9tJlA.css +1 -0
- package/web/dist/assets/index-DJOhOwwR.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +42 -71
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/inspect/registry.mjs
CHANGED
|
@@ -13,25 +13,22 @@
|
|
|
13
13
|
* compares `installs.json` byte-for-byte, so a changed path or a dropped prune fails cells
|
|
14
14
|
* rather than passing quietly.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync } from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
|
|
20
20
|
import { expanduser } from '../hosts/hosts.mjs';
|
|
21
|
-
import { writeText } from '../lib/fs.mjs';
|
|
21
|
+
import { writeText, isFile, isDir } from '../lib/fs.mjs';
|
|
22
22
|
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
23
23
|
import { toPlatformPath } from '../lib/paths.mjs';
|
|
24
24
|
|
|
25
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
26
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
27
|
-
|
|
28
25
|
/** `_install_registry._path()` — `$XDG_CONFIG_HOME/geneseed/installs.json`. */
|
|
29
|
-
|
|
26
|
+
function registryPath() {
|
|
30
27
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
31
28
|
return path.join(base, 'geneseed', 'installs.json');
|
|
32
29
|
}
|
|
33
30
|
|
|
34
|
-
|
|
31
|
+
function registryLoad() {
|
|
35
32
|
try {
|
|
36
33
|
const data = parseJson(readFileSync(registryPath(), 'utf8'));
|
|
37
34
|
return Array.isArray(data) ? data.map(String) : [];
|
package/js/inspect/scan.mjs
CHANGED
|
@@ -12,7 +12,14 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { SRC } from '../build/source.mjs';
|
|
14
14
|
import { comparePaths, normcase } from '../lib/paths.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { isDir, isFile } from '../lib/fs.mjs';
|
|
16
|
+
import { has } from '../lib/json.mjs';
|
|
17
|
+
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
// `isDir`/`isFile`/`has` are owned by `js/lib` now (single owner across `hosts/`, `build/`,
|
|
20
|
+
// `inspect/` and `web/`) — re-exported here so this module's own existing callers keep
|
|
21
|
+
// importing them from `./scan.mjs` unchanged.
|
|
22
|
+
export { isDir, isFile, has };
|
|
16
23
|
|
|
17
24
|
// --------------------------------------------------------------------------------------
|
|
18
25
|
// _harness_core's scanning primitives
|
|
@@ -48,9 +55,6 @@ export function within(child, parent) {
|
|
|
48
55
|
return p.length <= c.length && p.every((seg, i) => c[i] === seg);
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
export const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
52
|
-
export const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
53
|
-
|
|
54
58
|
/** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
|
|
55
59
|
export function globSorted(dir, filter) {
|
|
56
60
|
let names;
|
|
@@ -87,17 +91,6 @@ export const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1
|
|
|
87
91
|
/** `sorted(probs)` — SORTED, not deduped, which is what `_ran` fills a group with. */
|
|
88
92
|
export const sortedProblems = (xs) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
89
93
|
|
|
90
|
-
/**
|
|
91
|
-
* `k in d` for a Python dict — OWN keys only.
|
|
92
|
-
*
|
|
93
|
-
* Every membership test in this file reads a key that came out of a JSON document or a source
|
|
94
|
-
* filename, and a bare `k in obj` would answer true for `constructor`, `toString` and the rest
|
|
95
|
-
* of `Object.prototype`. A theme defining a `toString` voice token, or a skill file named
|
|
96
|
-
* `valueOf.md`, would then be reported as missing by the reference and not by this port — a
|
|
97
|
-
* divergence no cell can reach and no reviewer would look for.
|
|
98
|
-
*/
|
|
99
|
-
export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
|
|
100
|
-
|
|
101
94
|
/** `_harness_build._src_stems` — spec stems under `src/<folder>`, minus `_`-scaffolds. */
|
|
102
95
|
export function srcStems(folder) {
|
|
103
96
|
const d = path.join(SRC, folder);
|
package/js/inspect/status.mjs
CHANGED
|
@@ -242,7 +242,7 @@ const ANSI_CODES = {
|
|
|
242
242
|
* short-circuits on the first, NO_COLOR and TERM are never read through a pipe, which is
|
|
243
243
|
* why `golden.cell_env` clearing them is insurance rather than a fix.
|
|
244
244
|
*/
|
|
245
|
-
|
|
245
|
+
function colorEnabled() {
|
|
246
246
|
return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
|
|
247
247
|
&& process.env.TERM !== 'dumb';
|
|
248
248
|
}
|
package/js/lib/fs.mjs
CHANGED
|
@@ -193,3 +193,37 @@ export function withPlatformNewlines(fn) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* `Path.is_file()` / `Path.is_dir()` — false for a missing path, never a throw.
|
|
198
|
+
*
|
|
199
|
+
* SINGLE OWNER. Six modules (`js/inspect/scan.mjs` plus five more under `hosts/`, `inspect/`,
|
|
200
|
+
* `build/`, `web/` and `bin/`) each carried a private `(p) => { try {...} catch { return
|
|
201
|
+
* false; } }` copy of one or both; this is now the one definition, and `scan.mjs` re-exports
|
|
202
|
+
* it for its own existing callers rather than owning a copy itself. `js/hosts/hooks.mjs` is
|
|
203
|
+
* the deliberate exception — the hot path keeps its own inline copy rather than pay an import.
|
|
204
|
+
*
|
|
205
|
+
* `statSync`'s Node default THROWS for a missing path; `throwIfNoEntry: false` (Node >= 15.3)
|
|
206
|
+
* is the one option that turns ONLY that case into `undefined`, and still throws for anything
|
|
207
|
+
* else — a permission failure, a path with a non-directory ancestor (`ENOTDIR`). Every copy
|
|
208
|
+
* this replaces used a bare `try/catch` instead, which swallowed ALL of those alike into the
|
|
209
|
+
* same `false`. That is a real, deliberate narrowing: a permission failure silently read as
|
|
210
|
+
* "not there" was the wrong answer to preserve, not a byte worth keeping.
|
|
211
|
+
*/
|
|
212
|
+
export function isFile(p) {
|
|
213
|
+
return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function isDir(p) {
|
|
217
|
+
return statSync(p, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `except OSError` — an fs failure carries a `code`; anything else is a bug, so rethrow.
|
|
222
|
+
*
|
|
223
|
+
* SINGLE OWNER of a one-line predicate `js/hosts/settings.mjs`, `js/maintain/uninstall.mjs`
|
|
224
|
+
* and `js/inspect/excludes.mjs` each declared privately, byte-identically.
|
|
225
|
+
*/
|
|
226
|
+
export function isOsError(e) {
|
|
227
|
+
return Boolean(e) && typeof e.code === 'string';
|
|
228
|
+
}
|
|
229
|
+
|
package/js/lib/json.mjs
CHANGED
|
@@ -217,7 +217,7 @@ export function deepEquals(a, b) {
|
|
|
217
217
|
const kx = Object.keys(x);
|
|
218
218
|
const ky = Object.keys(y);
|
|
219
219
|
return kx.length === ky.length
|
|
220
|
-
&& kx.every((k) => Object.
|
|
220
|
+
&& kx.every((k) => Object.hasOwn(y, k) && deepEquals(x[k], y[k]));
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
/** `value in list` and `list.index(value)` under `deepEquals` \u2014 Python's `in`, not `includes`. */
|
|
@@ -225,6 +225,33 @@ export function indexOfDeepEqual(arr, value) {
|
|
|
225
225
|
return arr.findIndex((v) => deepEquals(v, value));
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* `dict.get(key)` / `k in d` / `isinstance(x, dict)` \u2014 Python dict semantics, OWN properties
|
|
230
|
+
* only, for a value that came out of `parseJson`.
|
|
231
|
+
*
|
|
232
|
+
* SINGLE OWNER of three helpers that had accumulated private copies in `js/hosts/settings.mjs`,
|
|
233
|
+
* `js/build/emit-common.mjs`, `js/inspect/scan.mjs` (`has` only) and `js/inspect/excludes.mjs`.
|
|
234
|
+
* They live beside `parseJson` rather than beside `js/lib/fs.mjs`'s filesystem primitives
|
|
235
|
+
* because every caller is reading a manifest, a theme or an overrides file \u2014 `obj['constructor']`
|
|
236
|
+
* or `obj['toString']` answering `Object.prototype`'s member instead of `undefined` is the same
|
|
237
|
+
* hazard `JsonNumber` and `deepEquals` above exist to guard against for the same values.
|
|
238
|
+
*
|
|
239
|
+
* They cannot live in `hosts/`, `build/` or `inspect/` themselves: callers on BOTH sides of the
|
|
240
|
+
* one-way dependency line those three sit on (`js/README.md`) need all three, and a copy in any
|
|
241
|
+
* one of them would force the others to import up the graph to reach it.
|
|
242
|
+
*/
|
|
243
|
+
export function get(obj, key) {
|
|
244
|
+
return Object.hasOwn(obj, key) ? obj[key] : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function has(obj, key) {
|
|
248
|
+
return Object.hasOwn(obj, key);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function isDict(v) {
|
|
252
|
+
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
253
|
+
}
|
|
254
|
+
|
|
228
255
|
/**
|
|
229
256
|
* `json.dumps(s)` for a STRING, with Python's default `ensure_ascii=True`.
|
|
230
257
|
*
|
package/js/lib/paths.mjs
CHANGED
|
@@ -80,16 +80,6 @@ export function within(child, parent) {
|
|
|
80
80
|
return p.length <= c.length && p.every((seg, i) => c[i] === seg);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
* `str(PurePath(s))` — separators normalised to the platform's, `.` components dropped,
|
|
85
|
-
* duplicate separators collapsed, a trailing one removed.
|
|
86
|
-
*
|
|
87
|
-
* `path.normalize` is NOT this: it also collapses `a/../b` to `b`, and `PurePath` keeps the
|
|
88
|
-
* `..` because a symlinked `a` makes those two different directories. The distinction is
|
|
89
|
-
* live in `js/inspect/excludes.mjs`, where a hand-edited `excludes.json` entry is compared against a
|
|
90
|
-
* RESOLVED repo path — Python never matches a `..` entry there, and a normalising port would
|
|
91
|
-
* match it and unwire a file the reference would have left alone.
|
|
92
|
-
*/
|
|
93
83
|
/**
|
|
94
84
|
* `str(Path(x))` — and it is PLATFORM-SHAPED, because `Path` is.
|
|
95
85
|
*
|
package/js/lib/udiff.mjs
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* have its index hoisted to the front of an object literal (the P3a hazard). Nothing here
|
|
41
41
|
* iterates `b2j`, so the hoist would be invisible until the day something did.
|
|
42
42
|
*/
|
|
43
|
-
function chainB(b
|
|
43
|
+
function chainB(b) {
|
|
44
44
|
const b2j = new Map();
|
|
45
45
|
for (let i = 0; i < b.length; i += 1) {
|
|
46
46
|
const cur = b2j.get(b[i]);
|
|
@@ -48,7 +48,7 @@ function chainB(b, autojunk) {
|
|
|
48
48
|
else cur.push(i);
|
|
49
49
|
}
|
|
50
50
|
const n = b.length;
|
|
51
|
-
if (
|
|
51
|
+
if (n >= 200) {
|
|
52
52
|
const ntest = Math.floor(n / 100) + 1;
|
|
53
53
|
for (const [elt, idxs] of [...b2j]) {
|
|
54
54
|
if (idxs.length > ntest) b2j.delete(elt);
|
|
@@ -135,8 +135,8 @@ function matchingBlocks(a, b, b2j) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** `SequenceMatcher.get_opcodes` — [tag, i1, i2, j1, j2]. */
|
|
138
|
-
|
|
139
|
-
const b2j = chainB(b
|
|
138
|
+
function opcodes(a, b) {
|
|
139
|
+
const b2j = chainB(b);
|
|
140
140
|
let i = 0;
|
|
141
141
|
let j = 0;
|
|
142
142
|
const answer = [];
|
|
@@ -159,8 +159,8 @@ export function opcodes(a, b, { autojunk = true } = {}) {
|
|
|
159
159
|
* Returns a list rather than a generator: the Python's caller is a `for` loop that consumes
|
|
160
160
|
* it once, and a lazy JS equivalent would buy nothing but a second shape to reason about.
|
|
161
161
|
*/
|
|
162
|
-
|
|
163
|
-
let codes = opcodes(a, b
|
|
162
|
+
function groupedOpcodes(a, b, n = 3) {
|
|
163
|
+
let codes = opcodes(a, b);
|
|
164
164
|
if (!codes.length) codes = [['equal', 0, 1, 0, 1]];
|
|
165
165
|
if (codes[0][0] === 'equal') {
|
|
166
166
|
const [tag, i1, i2, j1, j2] = codes[0];
|
|
@@ -261,15 +261,10 @@ export function unifiedDiff(a, b, {
|
|
|
261
261
|
const LINE_BOUNDARY = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
|
|
262
262
|
|
|
263
263
|
export function splitLines(text) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
out.push(text.slice(last, m.index));
|
|
270
|
-
last = m.index + m[0].length;
|
|
271
|
-
m = LINE_BOUNDARY.exec(text);
|
|
272
|
-
}
|
|
273
|
-
if (last < text.length) out.push(text.slice(last));
|
|
264
|
+
// `String.split` doesn't touch the shared regex's `lastIndex`, so no reset is needed —
|
|
265
|
+
// and a text ending exactly on a boundary is the ONLY way `split` can hand back a
|
|
266
|
+
// trailing empty string here, which is exactly the case `splitlines()` drops.
|
|
267
|
+
const out = text.split(LINE_BOUNDARY);
|
|
268
|
+
if (out.at(-1) === '') out.pop();
|
|
274
269
|
return out;
|
|
275
270
|
}
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -27,14 +27,12 @@
|
|
|
27
27
|
* `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
|
|
28
28
|
* index line are two halves of one format, and the half that writes lives there.
|
|
29
29
|
*/
|
|
30
|
-
import { readdirSync,
|
|
30
|
+
import { readdirSync, unlinkSync } from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
|
|
33
33
|
import { memoryDropIndex } from '../hosts/hooks.mjs';
|
|
34
34
|
import { resolveMemoryDir } from '../hosts/hosts.mjs';
|
|
35
|
-
import { printOut, printErr } from '../lib/fs.mjs';
|
|
36
|
-
|
|
37
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
35
|
+
import { printOut, printErr, isFile } from '../lib/fs.mjs';
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
38
|
* The two names the store owns rather than holds: the index the agent reads at session
|
package/js/maintain/migrate.mjs
CHANGED
|
@@ -65,7 +65,7 @@ function hookSettingsFile(root, host, scope) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
|
|
68
|
-
|
|
68
|
+
function hookCommandsOf(file) {
|
|
69
69
|
// `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
|
|
70
70
|
// comma is the one shape no machine in this project writes, and strict JSON rejects it —
|
|
71
71
|
// which would classify a wired install as `none` and make `migrate` skip the machine
|
|
@@ -89,7 +89,7 @@ export function hookCommandsOf(file) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** `<GENESEED_HOME>/.migrated` — the ROOT the last successful migration installed from. */
|
|
92
|
-
|
|
92
|
+
function stampPath() { return path.join(shimHome(), '.migrated'); }
|
|
93
93
|
|
|
94
94
|
/** Where the pre-migration copies live while the re-emit runs. */
|
|
95
95
|
function stashDir() { return path.join(shimHome(), '.migrate-backup'); }
|
|
@@ -101,7 +101,7 @@ function stashDir() { return path.join(shimHome(), '.migrate-backup'); }
|
|
|
101
101
|
* no rollback. That ordering is the cheapest atomicity there is and it is why the survey is a
|
|
102
102
|
* separate pass rather than a check inside the loop.
|
|
103
103
|
*/
|
|
104
|
-
|
|
104
|
+
function migrateSurvey() {
|
|
105
105
|
const rows = [];
|
|
106
106
|
const commands = [];
|
|
107
107
|
const unrecognised = [];
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* byte-identical in every cell of the matrix, so only the allow-list refutes it.
|
|
43
43
|
*/
|
|
44
44
|
import {
|
|
45
|
-
existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync,
|
|
45
|
+
existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync,
|
|
46
46
|
unlinkSync,
|
|
47
47
|
} from 'node:fs';
|
|
48
48
|
import path from 'node:path';
|
|
@@ -63,12 +63,10 @@ import {
|
|
|
63
63
|
opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
|
|
64
64
|
unwireClaudeExcludes, unwireClaudeSettings,
|
|
65
65
|
} from '../hosts/settings.mjs';
|
|
66
|
-
import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
66
|
+
import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
|
|
67
67
|
import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
|
|
68
68
|
import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
|
|
69
69
|
|
|
70
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
71
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
72
70
|
const hostSpec = (host) => HOSTS.find((h) => h.host === host);
|
|
73
71
|
|
|
74
72
|
/** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
|
|
@@ -240,10 +238,12 @@ export function unmergeOpencodeJson(p, entry) {
|
|
|
240
238
|
* copy path back.
|
|
241
239
|
*/
|
|
242
240
|
export function archiveStore(store) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
// `toLocaleString('sv-SE')` — LOCAL time, matching the old hand-rolled stamp
|
|
242
|
+
// (`getHours()`/`getMinutes()`/`getSeconds()` are all local-time getters, unlike
|
|
243
|
+
// `toISOString()`'s UTC). sv-SE's default format is already zero-padded
|
|
244
|
+
// "YYYY-MM-DD HH:MM:SS"; stripping the punctuation and reinserting one dash reproduces
|
|
245
|
+
// the exact `YYYYMMDD-HHMMSS` shape the padStart version built by hand.
|
|
246
|
+
const stamp = new Date().toLocaleString('sv-SE').replace(/\D/g, '').replace(/^(\d{8})/, '$1-');
|
|
247
247
|
const dest = path.join(path.dirname(store), `archived-${path.basename(store)}`, stamp);
|
|
248
248
|
mkdirSync(path.dirname(dest), { recursive: true });
|
|
249
249
|
renameSync(store, dest);
|
|
@@ -293,11 +293,12 @@ export function claudeUninstall(cfg, archiveMemory) {
|
|
|
293
293
|
const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
|
|
294
294
|
if (failed.length) warnSurvivors(failed);
|
|
295
295
|
const hooks = managed.settings_hooks || [];
|
|
296
|
-
const
|
|
297
|
-
|
|
296
|
+
const sf = settingsFile(cfg, managed);
|
|
297
|
+
const unwired = unwireClaudeSettings(sf, hooks);
|
|
298
|
+
unwireClaudeExcludes(sf, managed.settings_excludes || []);
|
|
298
299
|
// The unwire is VERIFIED, not assumed: a commented settings file is never rewritten, so a
|
|
299
300
|
// supposedly-uninstalled repo could keep firing Geneseed's hooks. Loud, never fatal.
|
|
300
|
-
settingsIntegrityCheck(
|
|
301
|
+
settingsIntegrityCheck(sf, managed, 'absent');
|
|
301
302
|
// Always EXCISE, never whole-file delete: even where Geneseed created CLAUDE.md the user
|
|
302
303
|
// may have added prose since. The file goes only if the excision leaves it empty.
|
|
303
304
|
managedBlockRemove(claudeMdPath(cfg, managed));
|
|
@@ -401,7 +402,7 @@ export function installAgentEntryOf(instr) {
|
|
|
401
402
|
* describing a file that no longer exists, which the fallback would then paper over with the
|
|
402
403
|
* canonical spelling — and a bundle sub-dir layout would be left wired.
|
|
403
404
|
*/
|
|
404
|
-
|
|
405
|
+
function opencodeProjectUninstall(root) {
|
|
405
406
|
const entry = installAgentEntry(root, 'project');
|
|
406
407
|
let removed = 0;
|
|
407
408
|
let failed = [];
|
|
@@ -436,7 +437,7 @@ export function opencodeProjectUninstall(root) {
|
|
|
436
437
|
}
|
|
437
438
|
|
|
438
439
|
/** `_harness_mcp._install_data_dir` — where the manifest and the runtime stores live. */
|
|
439
|
-
|
|
440
|
+
function installDataDir(root, host = 'opencode', scope = 'global') {
|
|
440
441
|
if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
|
|
441
442
|
return path.join(root, hostSpec(host).projectMarker);
|
|
442
443
|
}
|
|
@@ -607,7 +608,7 @@ export function uninstallResolve(targetArg) {
|
|
|
607
608
|
* config dir. The registry is the only place a project install outside the cwd can be
|
|
608
609
|
* rediscovered from, and the just-removed root is excluded.
|
|
609
610
|
*/
|
|
610
|
-
|
|
611
|
+
function survivingProjectInstalls(removedRoot) {
|
|
611
612
|
const rroot = resolvePath(removedRoot);
|
|
612
613
|
const out = [];
|
|
613
614
|
for (const [host, scope, root] of registeredTargets()) {
|
|
@@ -619,7 +620,7 @@ export function survivingProjectInstalls(removedRoot) {
|
|
|
619
620
|
}
|
|
620
621
|
|
|
621
622
|
/** `_harness_mcp._print_surviving_project_inventory` — informational, never a cascade. */
|
|
622
|
-
|
|
623
|
+
function printSurvivingProjectInventory(removedRoot) {
|
|
623
624
|
const survivors = survivingProjectInstalls(removedRoot);
|
|
624
625
|
if (!survivors.length) return;
|
|
625
626
|
printOut(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
|
|
@@ -636,7 +637,7 @@ export function printSurvivingProjectInventory(removedRoot) {
|
|
|
636
637
|
* A repo can carry `.opencode/`, `.claude/` and `.bob/` side by side and `uninstall` only ever
|
|
637
638
|
* removes the one it resolved to, so a repeat run needs to know there is more to do.
|
|
638
639
|
*/
|
|
639
|
-
|
|
640
|
+
function printOtherHostHits(root, removedHost) {
|
|
640
641
|
for (const spec of HOSTS) {
|
|
641
642
|
if (spec.host !== removedHost && projectQualifies(root, spec.host)) {
|
|
642
643
|
printOut(`[uninstall] also found ${spec.host}:project here — run \`harness `
|
|
@@ -759,9 +760,6 @@ export function cmdUninstall(args) {
|
|
|
759
760
|
// drives it: a manifest naming a file AND its parent directory is the only shape a seeded
|
|
760
761
|
// world can use to make the SECOND move collide.
|
|
761
762
|
|
|
762
|
-
/** `e` is an OSError rather than a programming fault — Node tags fs errors with `code`. */
|
|
763
|
-
const isOsError = (e) => Boolean(e) && typeof e.code === 'string';
|
|
764
|
-
|
|
765
763
|
/**
|
|
766
764
|
* `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
|
|
767
765
|
*
|
|
@@ -849,7 +847,7 @@ function installMoveList(root, kind) {
|
|
|
849
847
|
* tells the operator what to add rather than failing the whole reactivate. That asymmetry is
|
|
850
848
|
* the reference's and is gated per side.
|
|
851
849
|
*/
|
|
852
|
-
|
|
850
|
+
function installReaddEntry(target, entry) {
|
|
853
851
|
if (!existsSync(target)) {
|
|
854
852
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
855
853
|
writeText(target, `${jsonDumpsIndent({
|
|
@@ -900,6 +898,68 @@ function stashFiles(stash) {
|
|
|
900
898
|
/** `rel.as_posix()` for a path already known to be relative. */
|
|
901
899
|
const asPosix = (rel) => rel.split(path.sep).join('/');
|
|
902
900
|
|
|
901
|
+
/**
|
|
902
|
+
* The move-with-rollback loop `installDeactivate` and `claudeDeactivate` each wrote out
|
|
903
|
+
* inline: move every `rel` from `srcBase` to `dstBase`, and on the first failure put every
|
|
904
|
+
* earlier move back onto `srcBase` before reporting it. All-or-nothing is the property
|
|
905
|
+
* (see the P6i note above `moveTree`), so a caught failure never leaves a partial move
|
|
906
|
+
* standing even though this function itself takes no side action on failure beyond the
|
|
907
|
+
* rollback — cleaning up the now-empty stash is the caller's, because the two hosts name
|
|
908
|
+
* their stash differently (`rmtreeQuiet(stash)` plain, `cleanHostStash(cfg, host)` tagged).
|
|
909
|
+
*
|
|
910
|
+
* Returns `{ done }` on a clean run, or `{ done, failed }` once a move has failed and been
|
|
911
|
+
* rolled back — `done` stays the moved-so-far list either way, which is what `rolled_back:
|
|
912
|
+
* done.length` in both callers reports.
|
|
913
|
+
*/
|
|
914
|
+
function moveAll(srcBase, dstBase, rels) {
|
|
915
|
+
const done = [];
|
|
916
|
+
for (const rel of rels) {
|
|
917
|
+
const src = path.join(srcBase, rel);
|
|
918
|
+
if (!existsSync(src)) continue; // a manifest entry already gone — nothing to move
|
|
919
|
+
try {
|
|
920
|
+
moveTree(src, path.join(dstBase, rel));
|
|
921
|
+
done.push(rel);
|
|
922
|
+
} catch (e) {
|
|
923
|
+
if (!isOsError(e)) throw e;
|
|
924
|
+
for (const r of [...done].reverse()) {
|
|
925
|
+
try { renameSync(path.join(dstBase, r), path.join(srcBase, r)); } catch { /* OSError */ }
|
|
926
|
+
}
|
|
927
|
+
return { done, failed: [`${rel} (${e.message})`] };
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return { done };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The stash-restore loop `installReactivate` and `claudeReactivate` each wrote out inline:
|
|
935
|
+
* move every file under `stash` back onto `base`, by its relative path. `skip`, when given,
|
|
936
|
+
* is one relative-posix path to leave untouched — `claudeReactivate`'s
|
|
937
|
+
* `_claude_md_block.txt`, which holds the managed block's TEXT and is restored separately by
|
|
938
|
+
* `managedBlockWrite` rather than a raw file move, because it is not a file that belongs
|
|
939
|
+
* verbatim at that relative path.
|
|
940
|
+
*
|
|
941
|
+
* NEVER deletes the stash while anything is unrestored: a destination collision is a
|
|
942
|
+
* leftover, reported rather than clobbered, so a retry has something left to restore from.
|
|
943
|
+
*/
|
|
944
|
+
function restoreAll(stash, base, skip = null) {
|
|
945
|
+
const leftovers = [];
|
|
946
|
+
let moved = 0;
|
|
947
|
+
for (const src of stashFiles(stash)) {
|
|
948
|
+
const rel = path.relative(stash, src);
|
|
949
|
+
if (skip && asPosix(rel) === skip) continue;
|
|
950
|
+
const dst = path.join(base, rel);
|
|
951
|
+
if (existsSync(dst)) {
|
|
952
|
+
// NEVER delete the stash while anything is unrestored — skip, keep, report.
|
|
953
|
+
leftovers.push(asPosix(rel));
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
957
|
+
renameSync(src, dst);
|
|
958
|
+
moved += 1;
|
|
959
|
+
}
|
|
960
|
+
return { leftovers, moved };
|
|
961
|
+
}
|
|
962
|
+
|
|
903
963
|
/**
|
|
904
964
|
* `_harness_mcp._install_deactivate` — turn an OpenCode install off without deleting a byte.
|
|
905
965
|
*
|
|
@@ -923,21 +983,10 @@ export function installDeactivate(root, host = 'opencode', scope = 'global') {
|
|
|
923
983
|
+ 'Disable by hand or convert it to plain .json first.' };
|
|
924
984
|
}
|
|
925
985
|
const stash = path.join(root, DISABLED_STASH);
|
|
926
|
-
const done
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
try {
|
|
931
|
-
moveTree(src, path.join(stash, rel));
|
|
932
|
-
done.push(rel);
|
|
933
|
-
} catch (e) {
|
|
934
|
-
if (!isOsError(e)) throw e;
|
|
935
|
-
for (const r of [...done].reverse()) {
|
|
936
|
-
try { renameSync(path.join(stash, r), path.join(root, r)); } catch { /* OSError */ }
|
|
937
|
-
}
|
|
938
|
-
rmtreeQuiet(stash);
|
|
939
|
-
return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
|
|
940
|
-
}
|
|
986
|
+
const { done, failed } = moveAll(root, stash, installMoveList(root, kind));
|
|
987
|
+
if (failed) {
|
|
988
|
+
rmtreeQuiet(stash);
|
|
989
|
+
return { ok: false, failed, rolled_back: done.length };
|
|
941
990
|
}
|
|
942
991
|
unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
|
|
943
992
|
for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
|
|
@@ -962,20 +1011,7 @@ export function installReactivate(root, host = 'opencode', scope = 'global') {
|
|
|
962
1011
|
return { ok: true,
|
|
963
1012
|
note: 'install was re-created while disabled; discarded the stashed snapshot' };
|
|
964
1013
|
}
|
|
965
|
-
const leftovers =
|
|
966
|
-
let moved = 0;
|
|
967
|
-
for (const src of stashFiles(stash)) {
|
|
968
|
-
const rel = path.relative(stash, src);
|
|
969
|
-
const dst = path.join(root, rel);
|
|
970
|
-
if (existsSync(dst)) {
|
|
971
|
-
// NEVER delete the stash while anything is unrestored — skip, keep, report.
|
|
972
|
-
leftovers.push(asPosix(rel));
|
|
973
|
-
continue;
|
|
974
|
-
}
|
|
975
|
-
mkdirSync(path.dirname(dst), { recursive: true });
|
|
976
|
-
renameSync(src, dst);
|
|
977
|
-
moved += 1;
|
|
978
|
-
}
|
|
1014
|
+
const { leftovers, moved } = restoreAll(stash, root);
|
|
979
1015
|
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
980
1016
|
const kind = installKind(root) || 'global';
|
|
981
1017
|
installReaddEntry(target, installAgentEntry(root, kind));
|
|
@@ -1065,21 +1101,10 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
|
|
|
1065
1101
|
const rroot = resolvePath(cfg);
|
|
1066
1102
|
const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
|
|
1067
1103
|
&& within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
1068
|
-
const done =
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
try {
|
|
1073
|
-
moveTree(src, path.join(stash, rel));
|
|
1074
|
-
done.push(rel);
|
|
1075
|
-
} catch (e) {
|
|
1076
|
-
if (!isOsError(e)) throw e;
|
|
1077
|
-
for (const r of [...done].reverse()) {
|
|
1078
|
-
try { renameSync(path.join(stash, r), path.join(cfg, r)); } catch { /* OSError */ }
|
|
1079
|
-
}
|
|
1080
|
-
cleanHostStash(cfg, host);
|
|
1081
|
-
return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
|
|
1082
|
-
}
|
|
1104
|
+
const { done, failed } = moveAll(cfg, stash, rels);
|
|
1105
|
+
if (failed) {
|
|
1106
|
+
cleanHostStash(cfg, host);
|
|
1107
|
+
return { ok: false, failed, rolled_back: done.length };
|
|
1083
1108
|
}
|
|
1084
1109
|
unwireClaudeSettings(settingsFile(cfg, managed), managed.settings_hooks || []);
|
|
1085
1110
|
unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
|
|
@@ -1127,20 +1152,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
|
|
|
1127
1152
|
return { ok: true,
|
|
1128
1153
|
note: 'install was re-created while disabled; discarded the stashed snapshot' };
|
|
1129
1154
|
}
|
|
1130
|
-
const leftovers =
|
|
1131
|
-
let moved = 0;
|
|
1132
|
-
for (const src of stashFiles(stash)) {
|
|
1133
|
-
const rel = path.relative(stash, src);
|
|
1134
|
-
if (asPosix(rel) === '_claude_md_block.txt') continue; // restored separately, below
|
|
1135
|
-
const dst = path.join(cfg, rel);
|
|
1136
|
-
if (existsSync(dst)) {
|
|
1137
|
-
leftovers.push(asPosix(rel));
|
|
1138
|
-
continue;
|
|
1139
|
-
}
|
|
1140
|
-
mkdirSync(path.dirname(dst), { recursive: true });
|
|
1141
|
-
renameSync(src, dst);
|
|
1142
|
-
moved += 1;
|
|
1143
|
-
}
|
|
1155
|
+
const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
|
|
1144
1156
|
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
1145
1157
|
const managed = claudeReadManifest(cfg).managed || {};
|
|
1146
1158
|
// ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
|
package/js/maintain/update.mjs
CHANGED
|
@@ -383,7 +383,7 @@ export async function measureUpstream(log = null) {
|
|
|
383
383
|
return ['ready', behind, ''];
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
|
|
386
|
+
const DOCTOR_LEGEND = [
|
|
387
387
|
'[geneseed] doctor problem legend — what the lines above mean / how to fix:',
|
|
388
388
|
" • 'dead link' → a skill/agent body links a sibling as <dir>/<name>.md; use the BARE <name>.md (source bug)",
|
|
389
389
|
" • 'unresolved token' → a {{TOKEN}} is missing from a theme; add it to ALL theme JSONs",
|
|
@@ -600,7 +600,7 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
|
|
|
600
600
|
* emit-marker rebuild only covers THIS checkout's own bundle; without this pass a
|
|
601
601
|
* claude-global or bob install keeps serving the OLD render after every upgrade, silently.
|
|
602
602
|
*/
|
|
603
|
-
|
|
603
|
+
async function rebuildInstalls(here, log) {
|
|
604
604
|
log('[geneseed] refreshing every active install (rebuild-all) ...');
|
|
605
605
|
await drainStdout();
|
|
606
606
|
const proc = spawnSync(process.execPath,
|
package/js/ui/anim.mjs
CHANGED
package/js/ui/menu.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { which } from '../lib/paths.mjs';
|
|
|
42
42
|
import { startDaemon } from '../web/daemon.mjs';
|
|
43
43
|
|
|
44
44
|
/** `_menu_help` — the off-TTY arm, and the fallback both TTY arms end in. */
|
|
45
|
-
|
|
45
|
+
function menuHelp() {
|
|
46
46
|
printOut('Geneseed — no interactive menu here. Get started with: geneseed setup\n');
|
|
47
47
|
printOut('Other commands: bootstrap · update · build · doctor · diff · tui · web\n');
|
|
48
48
|
printOut('On a VT-capable terminal, a bare `./geneseed` opens the interactive menu of these.\n');
|
package/js/ui/tui.mjs
CHANGED
|
@@ -83,26 +83,6 @@ export const GLYPH = glyphs(TUI_ASCII);
|
|
|
83
83
|
// — measured, it disagrees on 897 codepoints across 229 ranges, because a spacing mark is
|
|
84
84
|
// `Mc` with combining class 0 and a great many `Mn` have class 0 too. So the tables below
|
|
85
85
|
// are the port's own, and no dependency was added to get them.
|
|
86
|
-
//
|
|
87
|
-
// A HAND-CARRIED UNICODE TABLE IS THE KIND OF THING THAT IS RIGHT UNTIL IT IS NOT, so the
|
|
88
|
-
// gate over it is not a case list. `tests/__snapshots__/dwidth.json` holds the whole
|
|
89
|
-
// U+0000..U+2FFFF sweep run-length encoded — 196 608 inputs, and a range this table gets wrong
|
|
90
|
-
// has nowhere to hide — replayed by `tests/snapshot/pure_snapshot.test.mjs`. It was RECORDED
|
|
91
|
-
// from Python's live `unicodedata` while that was still here; it cannot be re-recorded now, so
|
|
92
|
-
// a run that goes red is a change in these tables and never a change in the reference.
|
|
93
|
-
//
|
|
94
|
-
// THE VERSION IS DECLARED, not left in a comment, and that changed on the day the gate fired
|
|
95
|
-
// for real. `python3.14` ships unidata 16.0.0, which assigned U+0897 a combining class these
|
|
96
|
-
// tables (built from 15.1.0) do not carry — so the sweep went red naming a codepoint, exactly
|
|
97
|
-
// as designed, on a machine whose only difference was the interpreter. That is not a defect
|
|
98
|
-
// in either implementation and it must not read as one: the sweep is a comparison against a
|
|
99
|
-
// SPECIFIC Unicode version, and a comparison is meaningless against a different one. So the
|
|
100
|
-
// version is a constant, `tests/__snapshots__/dwidth.json` records the version its runs were
|
|
101
|
-
// measured at, and `tests/snapshot/pure_snapshot.test.mjs` asserts the two still agree —
|
|
102
|
-
// reading the constant both by import and by regex out of this file, so the declaration
|
|
103
|
-
// cannot be satisfied by deleting it. There is no interpreter left to pin: CI now FAILS if
|
|
104
|
-
// python is findable at all.
|
|
105
|
-
export const DWIDTH_UNIDATA = '15.1.0';
|
|
106
86
|
|
|
107
87
|
//: East_Asian_Width W or F, below U+1F000 — above it the reference's own `>= 0x1F000` rule
|
|
108
88
|
//: already answers 2, so the table stops where that rule takes over.
|
|
@@ -458,6 +438,9 @@ export function tuiEntries(inv) {
|
|
|
458
438
|
* an empty state. `_TUI_INV_EMPTY` in `tests/test_pure_function_parity.py` is the corpus
|
|
459
439
|
* entry that surfaced it. **P7c must not reproduce the crash when it ports `_tui_loop`** —
|
|
460
440
|
* the fix is one `or []` at the call site, and it belongs in the same change as the port.
|
|
441
|
+
*
|
|
442
|
+
* Its only consumer with direct assertions is `tests/unit/setup.test.mjs`; tests/fixtures/pure_probe.mjs
|
|
443
|
+
* also dispatches it to exercise wizard/web_first test scenarios.
|
|
461
444
|
*/
|
|
462
445
|
export function detailLines(kind, label, data) {
|
|
463
446
|
// The three constitutional tiers share one treatment — the label, a blank, then the body —
|