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.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. package/web/dist/assets/index-dHi-7M-F.js +0 -2
@@ -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, statSync } from 'node:fs';
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
- export function registryPath() {
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
- export function registryLoad() {
31
+ function registryLoad() {
35
32
  try {
36
33
  const data = parseJson(readFileSync(registryPath(), 'utf8'));
37
34
  return Array.isArray(data) ? data.map(String) : [];
@@ -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 { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
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);
@@ -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
- export function colorEnabled() {
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.prototype.hasOwnProperty.call(y, k) && deepEquals(x[k], y[k]));
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, autojunk) {
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 (autojunk && n >= 200) {
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
- export function opcodes(a, b, { autojunk = true } = {}) {
139
- const b2j = chainB(b, autojunk);
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
- export function groupedOpcodes(a, b, n = 3, opts) {
163
- let codes = opcodes(a, b, opts);
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
- const out = [];
265
- let last = 0;
266
- LINE_BOUNDARY.lastIndex = 0;
267
- let m = LINE_BOUNDARY.exec(text);
268
- while (m !== null) {
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
  }
@@ -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, statSync, unlinkSync } from 'node:fs';
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
@@ -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
- export function hookCommandsOf(file) {
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
- export function stampPath() { return path.join(shimHome(), '.migrated'); }
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
- export function migrateSurvey() {
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, statSync,
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
- const now = new Date();
244
- const p2 = (n) => String(n).padStart(2, '0');
245
- const stamp = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}`
246
- + `-${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
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 unwired = unwireClaudeSettings(settingsFile(cfg, managed), hooks);
297
- unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
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(settingsFile(cfg, managed), managed, 'absent');
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
- export function opencodeProjectUninstall(root) {
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
- export function installDataDir(root, host = 'opencode', scope = 'global') {
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
- export function survivingProjectInstalls(removedRoot) {
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
- export function printSurvivingProjectInventory(removedRoot) {
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
- export function printOtherHostHits(root, removedHost) {
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
- export function installReaddEntry(target, entry) {
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 = []; // rels already moved, for rollback
927
- for (const rel of installMoveList(root, kind)) {
928
- const src = path.join(root, rel);
929
- if (!existsSync(src)) continue; // a manifest entry already gone — nothing to move
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
- for (const rel of rels) {
1070
- const src = path.join(cfg, rel);
1071
- if (!existsSync(src)) continue;
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.
@@ -383,7 +383,7 @@ export async function measureUpstream(log = null) {
383
383
  return ['ready', behind, ''];
384
384
  }
385
385
 
386
- export const DOCTOR_LEGEND = [
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
- export async function rebuildInstalls(here, log) {
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
@@ -37,7 +37,7 @@
37
37
  import { printOut } from '../lib/fs.mjs';
38
38
  import { parseIntStrict, codePointLength, padEndToWidth } from '../lib/text.mjs';
39
39
 
40
- export const ART = {
40
+ const ART = {
41
41
  imperial: {
42
42
  title: '+ ADEPTUS ASTARTES +',
43
43
  sprite: [
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
- export function menuHelp() {
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 —