geneseed 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +23 -9
  4. package/adapters/opencode/plugins/geneseed-guard.js +16 -4
  5. package/adapters/opencode/plugins/geneseed-learn.js +37 -11
  6. package/adapters/opencode/plugins/geneseed-notify.js +37 -19
  7. package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
  8. package/adapters/opencode/workflows/_runtime.js +16 -3
  9. package/bin/geneseed-cli.mjs +22 -20
  10. package/bin/geneseed-hook.mjs +6 -3
  11. package/harness.config.json +1 -1
  12. package/js/README.md +9 -5
  13. package/js/build/args.mjs +4 -3
  14. package/js/build/bundle.mjs +10 -10
  15. package/js/build/driver.mjs +8 -7
  16. package/js/build/emit-claude.mjs +3 -2
  17. package/js/build/emit-common.mjs +2 -14
  18. package/js/build/emit-opencode.mjs +12 -8
  19. package/js/build/generate.mjs +15 -11
  20. package/js/build/render.mjs +1 -1
  21. package/js/build/source.mjs +6 -6
  22. package/js/build/stubs.mjs +6 -5
  23. package/js/build/version.mjs +21 -2
  24. package/js/hosts/hooks-context.mjs +2 -1
  25. package/js/hosts/hooks-prims.mjs +2 -2
  26. package/js/hosts/hosts.mjs +24 -26
  27. package/js/hosts/installs.mjs +33 -14
  28. package/js/hosts/link.mjs +75 -26
  29. package/js/hosts/memory-files.mjs +13 -4
  30. package/js/hosts/native.mjs +106 -48
  31. package/js/hosts/opencode.mjs +44 -36
  32. package/js/hosts/shim.mjs +4 -2
  33. package/js/inspect/diff.mjs +5 -6
  34. package/js/inspect/excludes.mjs +10 -5
  35. package/js/inspect/status.mjs +18 -21
  36. package/js/inspect/validate.mjs +2 -2
  37. package/js/lib/fs.mjs +12 -11
  38. package/js/lib/json.mjs +4 -4
  39. package/js/lib/paths.mjs +27 -33
  40. package/js/lib/proc.mjs +3 -3
  41. package/js/lib/prompt.mjs +132 -0
  42. package/js/lib/text.mjs +18 -4
  43. package/js/lib/udiff.mjs +3 -2
  44. package/js/maintain/memory.mjs +5 -6
  45. package/js/maintain/migrate.mjs +4 -3
  46. package/js/maintain/setup.mjs +9 -131
  47. package/js/maintain/uninstall.mjs +10 -9
  48. package/js/maintain/update.mjs +115 -34
  49. package/js/ui/anim.mjs +8 -12
  50. package/js/ui/cli.mjs +29 -35
  51. package/js/ui/menu.mjs +7 -13
  52. package/js/ui/tui.mjs +14 -123
  53. package/js/web/actions.mjs +82 -386
  54. package/js/web/activity.mjs +1 -1
  55. package/js/web/api.mjs +33 -717
  56. package/js/web/catalog.mjs +527 -0
  57. package/js/web/daemon.mjs +21 -3
  58. package/js/web/handler.mjs +51 -27
  59. package/js/web/jobs.mjs +24 -2
  60. package/js/web/routes.mjs +61 -6
  61. package/js/web/server.mjs +4 -4
  62. package/js/web/state.mjs +138 -0
  63. package/js/web/user-files.mjs +339 -0
  64. package/package.json +1 -1
  65. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  66. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  67. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  68. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  69. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  70. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  71. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  72. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  73. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  74. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  75. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  76. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  77. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  78. package/web/dist/assets/api-CxgfYu18.js +1 -0
  79. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  80. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  81. package/web/dist/index.html +2 -2
  82. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  83. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  84. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  85. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  86. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  87. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  88. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -16,18 +16,21 @@
16
16
  *
17
17
  * TWO STREAMS, AND THE SPLIT IS NOT TIDY. `_warn_if_overrides_stale` prints to STDOUT
18
18
  * while every warning in `js/hosts/native.mjs` goes to stderr. That is the Python's own
19
- * inconsistency and it is reproduced, not corrected: the parity gate compares both
20
- * specification, and `tests/unit/agent_overrides.test.mjs` pins the stale notice to stdout.
21
- * It is safe because this is the
22
- * generator's CLI, which prints progress to stdout by design — unlike a hook path, where
23
- * a stray stdout byte silently disarms a blocking gate.
19
+ * inconsistency and it is reproduced, not corrected: `tests/unit/agent_overrides.test.mjs`
20
+ * pins the stale notice to stdout. It is safe because this is the generator's CLI, which
21
+ * prints progress to stdout by design — unlike a hook path, where a stray stdout byte
22
+ * silently disarms a blocking gate.
24
23
  */
25
24
  import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
26
25
  import path from 'node:path';
27
26
  import { writeText, readText, copyFile } from '../lib/fs.mjs';
28
27
  import { jsonDumps, jsonDumpsIndent, parseJson } from '../lib/json.mjs';
29
28
  import { comparePaths } from '../lib/paths.mjs';
30
- import { descOf, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
29
+ import { sourceReleaseVersion } from '../build/version.mjs';
30
+ import { firstBlockquote, stripSkillBodyLinks, pushOverrideLines } from './native.mjs';
31
+
32
+ // Re-exported for `js/web/api.mjs` and the tests, which import it from here.
33
+ export { sourceReleaseVersion };
31
34
 
32
35
  /** ANSI colour-name -> integer (0-7). Mirrors `_build_emit._ANSI`. */
33
36
  const ANSI = {
@@ -94,9 +97,17 @@ const AGENT_OVERRIDES_STUB = {
94
97
  },
95
98
  };
96
99
 
97
- /** Mirrors `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger. */
98
- const COMMAND_SET = ['commit', 'plan', 'code-review', 'review-response',
99
- 'ship', 'debug', 'research'];
100
+ /**
101
+ * `_build_emit.COMMAND_SET` — the hot skills worth a one-keystroke trigger, as command name ->
102
+ * skill name. A map, not a list, because the two can differ: `code-review` became
103
+ * `geneseed-code-review` (so it no longer shadows a host built-in) while the command stays
104
+ * `/code-review`. As a list the renamed entry missed its skill and was dropped in silence, so a
105
+ * missing skill is now a thrown error in `writeCommandLayer`.
106
+ */
107
+ const COMMAND_SET = {
108
+ commit: 'commit', plan: 'plan', 'code-review': 'geneseed-code-review',
109
+ 'review-response': 'review-response', ship: 'ship', debug: 'debug', research: 'research',
110
+ };
100
111
 
101
112
  /** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
102
113
  const PONYTAIL_COMMAND_BODY =
@@ -113,22 +124,6 @@ function truthyEnv(name) {
113
124
  return ['1', 'on', 'true', 'yes'].includes((process.env[name] || '').toLowerCase());
114
125
  }
115
126
 
116
- /**
117
- * `_build_core.source_release_version`. It reads `cfg.config`, so it takes `cfg` rather than
118
- * importing core — and it stayed HERE when core landed as `js/build/source.mjs`, which is why
119
- * `js/build/version.mjs` reaches back into the OpenCode module for it.
120
- */
121
- export function sourceReleaseVersion(cfg) {
122
- try {
123
- const data = parseJson(readText(cfg.config));
124
- const v = data && typeof data === 'object' && !Array.isArray(data) ? data.version : null;
125
- if (typeof v === 'string' && v) return v;
126
- } catch {
127
- // OSError / JSONDecodeError — degrade to "not newer" rather than raising.
128
- }
129
- return '0.0.0';
130
- }
131
-
132
127
  const GRAY = 8;
133
128
  const GREEN = 2;
134
129
  const RED = 1;
@@ -181,7 +176,6 @@ export function writeTheme(themesDir, themeName, theme) {
181
176
  return dest;
182
177
  }
183
178
 
184
- /** `_build_emit._color_theme_json`. */
185
179
  /**
186
180
  * `_build_emit._PALETTE_ROLES` — every role some slot reads, as a set.
187
181
  *
@@ -192,6 +186,7 @@ export function writeTheme(themesDir, themeName, theme) {
192
186
  */
193
187
  export const PALETTE_ROLES = new Set(Object.values(SLOT_ROLE));
194
188
 
189
+ /** `_build_emit._color_theme_json`. */
195
190
  export function colorThemeJson(palette, transparent) {
196
191
  const t = {};
197
192
  for (const [slot, role] of Object.entries(SLOT_ROLE)) {
@@ -272,8 +267,16 @@ export function ensureAgentOverridesStub(cfg, base) {
272
267
  writeText(dest, `${jsonDumpsIndent(stub, { ensureAscii: false })}\n`);
273
268
  }
274
269
 
275
- /** `_build_emit._write_primary_agent` — the opt-in `mode: primary` orchestrator. */
276
- export function writePrimaryAgent(cfg, agentsDir, overrides) {
270
+ /**
271
+ * `_build_emit._write_primary_agent` — the opt-in `mode: primary` orchestrator.
272
+ *
273
+ * `claim` is `claimer`'s closure from `js/hosts/native.mjs`, and this and the two command
274
+ * writers below take it for the reason `writeNativeLayer` does: their paths are pushed into
275
+ * `owned`, and uninstall deletes what `owned` names — so writing over a user's own
276
+ * `command/commit.md` without the check made uninstall delete it. Default: claim everything,
277
+ * for a caller with no manifest to consult.
278
+ */
279
+ export function writePrimaryAgent(cfg, agentsDir, overrides, claim = () => true) {
277
280
  if (!truthyEnv('GENESEED_PRIMARY') || !existsSync(cfg.primaryAgentSrc)
278
281
  || !statSync(cfg.primaryAgentSrc).isFile()) {
279
282
  return null;
@@ -286,13 +289,14 @@ export function writePrimaryAgent(cfg, agentsDir, overrides) {
286
289
  && overrides.orchestrator) || {};
287
290
  pushOverrideLines(fm, ov);
288
291
  const dest = path.join(agentsDir, 'orchestrator.md');
292
+ if (!claim(dest)) return null;
289
293
  mkdirSync(path.dirname(dest), { recursive: true });
290
294
  writeText(dest, `---\n${fm.join('\n')}\n---\n\n${body}`);
291
295
  return dest;
292
296
  }
293
297
 
294
298
  /** `_build_emit._write_command_layer` — the opt-in /slash commands. */
295
- export function writeCommandLayer(cfg, items, commandDir) {
299
+ export function writeCommandLayer(cfg, items, commandDir, claim = () => true) {
296
300
  if (!truthyEnv('GENESEED_COMMANDS')) return [];
297
301
  const byName = new Map();
298
302
  for (const { text, src } of items) {
@@ -304,12 +308,15 @@ export function writeCommandLayer(cfg, items, commandDir) {
304
308
  }
305
309
  }
306
310
  const written = [];
307
- for (const name of COMMAND_SET) {
308
- const text = byName.get(name);
309
- if (text === undefined) continue;
310
- const desc = descOf(text);
311
- const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
311
+ for (const [name, skill] of Object.entries(COMMAND_SET)) {
312
+ const text = byName.get(skill);
313
+ if (text === undefined) {
314
+ throw new Error(`COMMAND_SET: /${name} runs skill '${skill}', which src/skills/ does not have`);
315
+ }
312
316
  const dest = path.join(commandDir, `${name}.md`);
317
+ if (!claim(dest)) continue;
318
+ const desc = firstBlockquote(text);
319
+ const body = stripSkillBodyLinks(text.replace(/^\n+/, ''));
313
320
  mkdirSync(path.dirname(dest), { recursive: true });
314
321
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${body}`);
315
322
  written.push(dest);
@@ -317,9 +324,10 @@ export function writeCommandLayer(cfg, items, commandDir) {
317
324
  return written;
318
325
  }
319
326
 
320
- /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY. */
321
- export function writePonytailCommand(commandDir) {
327
+ /** `_build_emit._write_ponytail_command` — registered UNCONDITIONALLY; null when the user's. */
328
+ export function writePonytailCommand(commandDir, claim = () => true) {
322
329
  const dest = path.join(commandDir, 'ponytail.md');
330
+ if (!claim(dest)) return null;
323
331
  mkdirSync(path.dirname(dest), { recursive: true });
324
332
  const desc = 'Set the ponytail minimal-code level for the session: lite | full | ultra | off';
325
333
  writeText(dest, `---\ndescription: ${jsonDumps(desc)}\n---\n\n${PONYTAIL_COMMAND_BODY}`);
package/js/hosts/shim.mjs CHANGED
@@ -273,7 +273,8 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
273
273
  * interpreter silently disables every hook in the install. Both halves are gone. `runner` is
274
274
  * `process.execPath` — the node already running this file, which by construction exists —
275
275
  * so there is nothing left to discover and no way for discovery to fail. P4e kept the
276
- * unreachable exit-3 branch because `test_the_node_driver_classifies_every_emit` asserts a
276
+ * unreachable exit-3 branch because 'the driver classifies every emit'
277
+ * (`tests/unit/node_driver.test.mjs`) asserts a
277
278
  * partition it belongs to; nothing asserts a partition over this one, so keeping it would
278
279
  * be keeping code no test can reach for no stated reason.
279
280
  *
@@ -287,7 +288,8 @@ export function hookPrefix({ runner, entry, platform = process.platform } = {})
287
288
  *
288
289
  * That is correct exactly while the two entry points answer the same verbs the same way, so
289
290
  * the gate that used to be a formality is now load-bearing:
290
- * `test_the_entry_carries_exactly_the_verbs_the_emitter_wires` reads the emitter's wiring
291
+ * 'the hook entry carries exactly the verbs the emitter wires' (`tests/unit/hook_cli.test.mjs`)
292
+ * reads the emitter's wiring
291
293
  * and `bin/geneseed-hook.mjs`'s VERBS table and requires them EQUAL — a wired verb the entry
292
294
  * lacks is a dead hook on every install on the machine, not just this one. The alternative
293
295
  * considered and rejected was a per-driver shim path: the path is baked into every already
@@ -22,11 +22,10 @@
22
22
  * * **The diff algorithm.** A cell can produce ONE edited file and see one hunk. It cannot
23
23
  * produce the shapes where `difflib`'s choices differ from any other diff's — ties, the
24
24
  * recursion's alignment, and above all `autojunk`, which only engages at 200 lines and
25
- * changes the hunks on every real harness file. Gated as a corpus —
26
- * `tests/fixtures/pure_probe.mjs`'s `unified_diff` entry, replayed by
27
- * `tests/snapshot/pure_snapshot.test.mjs` against `tests/__snapshots__/primitives/` —
28
- * measured in both directions (28 of the 176 cases there disagree when `autojunk` is
29
- * switched off, so the corpus is not describing a constant).
25
+ * changes the hunks on every real harness file. The former Python suite gated it as a
26
+ * corpus, measured in both directions (28 of its 176 cases disagreed when `autojunk` was
27
+ * switched off). That corpus is gone and no Node test drives `unifiedDiff` directly, so
28
+ * beyond the one hunk the `diff/--full-…` cell sees, nothing pins these shapes today.
30
29
  * * **The timestamp.** `_write_improvements` names the file `improvements-%Y%m%d-%H%M%S.md`
31
30
  * and stamps the report with `datetime.now()`, so the two implementations cannot write the
32
31
  * same bytes across a second boundary — and a cell runs them in sequence, so the boundary
@@ -177,7 +176,7 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
177
176
  // makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
178
177
  posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
179
178
  // The loop skill's preset, or its `Default trust preset:` line reads as edited.
180
- trust: trustOfDir(dir),
179
+ trust: trustOfDir(dir, host),
181
180
  // The excluded rules, or an install built `--exclude-rules "process 5"` reads its
182
181
  // carrier as drift: `expected` would re-state the rule the deployment took away.
183
182
  excludeRules: excludedRulesOfDir(dir),
@@ -78,14 +78,19 @@ const canon = (p) => resolvePath(p);
78
78
  /**
79
79
  * `_same` — is this stored entry the same folder as `repo`?
80
80
  *
81
- * `toPlatformPath` and not `path.normalize`: the latter collapses `a/../b` to `b`, and Python's
82
- * `Path()` does not, so a hand-edited entry carrying `..` matches here and would not match
83
- * there. Getting that backwards makes `exclude remove` unwire a repo the reference left
84
- * alone.
81
+ * Resolved like `repo` itself (`canon`: `..` collapsed, symlinks followed), the one rule the
82
+ * hook's `sovereignBypass` and the OpenCode plugins apply too — so an entry that EXCLUDES a
83
+ * folder is exactly the entry `exclude remove` can take back. It used to compare lexically,
84
+ * mirroring the former Python `Path()`, which made a hand-edited `<repo>/../repo` an entry the
85
+ * plugins honoured, the hook ignored, and `remove` could not find.
85
86
  */
86
87
  function same(stored, repo) {
87
88
  if (typeof stored !== 'string' || !stored) return false;
88
- return normcase(toPlatformPath(stored)) === normcase(repo);
89
+ try {
90
+ return normcase(canon(stored)) === normcase(repo);
91
+ } catch {
92
+ return false; // a `~user` form `expanduser` refuses names no folder
93
+ }
89
94
  }
90
95
 
91
96
  /**
@@ -34,25 +34,26 @@
34
34
  * became the second and third. Nothing about them changed in the move — the 166-cell
35
35
  * acceptance matrix is what licensed attempting it, `status`'s own eleven cells among them.
36
36
  *
37
- * WHAT NO CELL CAN REACH, and where it is gated instead. `tests/harness_golden.py`'s
38
- * status/version section carries the long form; the short form is that ROOT is not
37
+ * WHAT NO CELL CAN REACH, and where it is gated instead. The former Python matrix's
38
+ * status/version section carried the long form; the short form is that ROOT is not
39
39
  * redirectable across a process boundary, so three things here are unreachable from any
40
40
  * cell: `versionVerdict`'s "up to date" (needs a marker holding a fingerprint no cell can
41
41
  * know), `accentFor`'s cyan fallback (an unknown theme is refused upstream by
42
42
  * `effectiveTheme`), and the whole ANSI half of `statusLines` (`_color_enabled` is
43
43
  * `sys.stdout.isatty()`, and every harness captures stdout through a pipe). All three are
44
- * PURE FUNCTIONS of their arguments, so all three are gated as a corpus instead:
45
- * `tests/fixtures/pure_probe.mjs` calls them and `tests/snapshot/pure_snapshot.test.mjs`
46
- * replays that against `tests/__snapshots__/primitives/`. That was a third answer to the
47
- * colour question, which had been posed as a choice between shipping it ungated and not
48
- * shipping it.
44
+ * PURE FUNCTIONS of their arguments, so the former Python suite gated all three as a corpus
45
+ * instead — a third answer to the colour question, which had been posed as a choice between
46
+ * shipping it ungated and not shipping it. That corpus is gone: `tests/unit/lifecycle.test.mjs`
47
+ * now calls `versionVerdict` and both halves of `statusLines` directly, and nothing pins
48
+ * `accentFor`'s fallback.
49
49
  *
50
50
  * `manifestIsClaude` was a FOURTH until wave 2 of the P0/P1 review: it is only reached for
51
51
  * a candidate with no known host, and `ROOT/"Harness"` was ordered ahead of the sandbox's
52
52
  * own. Both ROOT-relative bundle candidates are gone from this walk and from
53
53
  * `installedDefaults`, so the only no-known-host candidate left is `cwd/"Harness"`, which
54
54
  * is inside the sandbox. It is reachable now; no cell seeds a manifest without an emit
55
- * marker, so it is still gated only by the corpus, and that is a gap rather than a wall.
55
+ * marker, so it is gated only by the direct calls in `tests/unit/claude.test.mjs`, and that is
56
+ * a gap rather than a wall.
56
57
  */
57
58
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
58
59
  import path from 'node:path';
@@ -66,19 +67,15 @@ import {
66
67
  } from '../hosts/hosts.mjs';
67
68
  // P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
68
69
  // deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
69
- // three verbs now read them. `defaultTheme`, `manifestIsClaude` and `installedDefaults` are
70
- // re-exported below because `tests/fixtures/pure_probe.mjs` names this module for two of them
71
- // and a corpus that followed the code to its new file would stop testing the caller's view.
70
+ // three verbs now read them.
72
71
  import {
73
- defaultTheme, installedDefaults, installTargets, manifestIsClaude, readJsonMaybe, readMaybe,
72
+ defaultTheme, installedDefaults, installTargets, readJsonMaybe, readMaybe,
74
73
  } from '../hosts/installs.mjs';
75
74
  import { installProfile, rebuildCommand } from '../build/generate.mjs';
76
75
  import { shimDead } from '../hosts/shim.mjs';
77
76
  import { printOut } from '../lib/fs.mjs';
78
77
  import { codePointLength, padEndToWidth } from '../lib/text.mjs';
79
78
 
80
- export { defaultTheme, manifestIsClaude };
81
-
82
79
  // ---- version ---------------------------------------------------------------------------
83
80
 
84
81
  /** `_harness_status._version_verdict`. */
@@ -331,13 +328,13 @@ export function statusLines(d, color = false) {
331
328
  + `(${d.facts} fact${d.facts === 1 ? '' : 's'})`],
332
329
  ['version', `${d.installed_fp || '(none)'} ${DOT} source ${d.source_fp}`],
333
330
  ];
334
- // ⚠ A ROW, NOT A WIDER `components` LINE, AND THE REASON IS THAT THE CORPUS CANNOT BE
335
- // RE-BLESSED. `statusLines` has 30 recorded cases per platform in
336
- // `tests/__snapshots__/primitives/`, every one of them holding the box's exact bytes —
337
- // including `17 agents · 47 skills · 37 laws` — and the recorder was Python and is gone.
338
- // Touching `components` moves all 30; adding a row would too, since the box width is
339
- // measured over `rows`. So the row is CONDITIONAL on a key the recorded arguments do not
340
- // carry, exactly as `agent_md` below already is, and the recording renders unchanged.
331
+ // ⚠ A ROW, NOT A WIDER `components` LINE. When this landed, `statusLines` had 30 recorded
332
+ // cases per platform in the former Python suite's corpus, every one holding the box's exact
333
+ // bytes — including `17 agents · 47 skills · 37 laws` — and no recorder left to re-bless them.
334
+ // Touching `components` would have moved all 30; adding a row would too, since the box width
335
+ // is measured over `rows`. So the row is CONDITIONAL on a key those arguments did not carry,
336
+ // exactly as `agent_md` below already is. The corpus is gone; a caller without the key still
337
+ // gets the box it got before.
341
338
  //
342
339
  // It carries the two tiers `components` cannot: `components` counts the invariants as
343
340
  // `laws`, which is still true, and says nothing about the ontology or the packs.
@@ -149,8 +149,8 @@ export function cmdValidate(args) {
149
149
  // which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
150
150
  // that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
151
151
  // sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
152
- // says `unknown theme 'x'` where the reference says `1`. The corpus in
153
- // `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
152
+ // says `unknown theme 'x'` where the reference says `1`. The former Python
153
+ // cross-implementation corpus is what found it; nothing else could.
154
154
  if (e && e.exitCode !== undefined) {
155
155
  printOut(`[validate-only] render/emit FAILED for theme '${args.theme}' `
156
156
  + `emit '${emit}': ${e.exitCode}\n`);
package/js/lib/fs.mjs CHANGED
@@ -4,9 +4,11 @@
4
4
  * These are definitions, not an adaptation of somebody else's. Every rule below was
5
5
  * originally MEASURED against CPython, because the generator these functions serve had to
6
6
  * emit bytes indistinguishable from a Python one; that second party is gone and the rules
7
- * are not. What holds them now is the recorded corpora under `tests/__snapshots__/`, which
8
- * compare this module's output byte for byte and can never be re-recorded — so a rule here
9
- * is a frozen fact about what this tool emits, and changing one is a product change.
7
+ * are not. The recorded corpora that compared this module's output byte for byte went with
8
+ * the former Python suite; what holds the rules now is `tests/unit/lib_primitives.test.mjs`
9
+ * (`isFile`/`isDir`), `tests/unit/hook_cli.test.mjs`'s platform-newline count (the stream
10
+ * funnels), and for the rest only the per-function provenance below. A rule here is still a
11
+ * frozen fact about what this tool emits, and changing one is a product change.
10
12
  *
11
13
  * ⚠ NONE OF THESE IS THE NODE DEFAULT, AND THAT IS THE ONE THING TO KNOW BEFORE EDITING ONE.
12
14
  * Each is a deliberate divergence from the obvious one-liner, and the obvious one-liner is
@@ -107,10 +109,10 @@ export function copyFile(src, dest) {
107
109
  * its caller different BYTES than the Python one: measured at 176 vs 171 for one `context`
108
110
  * run before this existed.
109
111
  *
110
- * `harness_golden.py` structurally cannot see the difference — `subprocess` decodes with
111
- * universal newlines on the way back, folding both shapes to `\n` before any cell compares
112
- * them, the same shape as the shim's exclusion from `golden.py`. So the gate for it is
113
- * `test_the_two_entry_points_agree_on_stdout_BYTES`, which captures raw bytes. Moved here
112
+ * The CLI matrix structurally cannot see the difference — every cell decodes stdout with
113
+ * universal newlines, folding both shapes to `\n` before anything compares them. So the gate
114
+ * for it is `tests/unit/hook_cli.test.mjs`'s `every newline the entry points write is the
115
+ * platform's`, which counts raw bytes. Moved here
114
116
  * from `js/hosts/hooks.mjs` in P5c, when `js/inspect/excludes.mjs` became the second caller: a translation
115
117
  * that exists twice is a translation that can be fixed once.
116
118
  */
@@ -167,10 +169,9 @@ export function withDiscardableStderr(fn) {
167
169
  * `emitGlobalInto`.
168
170
  *
169
171
  * The item it closes: a plain `--emit files` handed its caller 104 bytes where `python
170
- * build.py` handed over 105, and `tests/golden.py`'s `text=True` capture folds both to the
171
- * same string. `test_the_two_entry_points_agree_on_stdout_BYTES` grew a `build` row (through
172
- * `harness build`, which calls this `main` in-process) and a `rebuild-all` row, and those are
173
- * what can see it.
172
+ * build.py` handed over 105, and a `text=True` capture folds both to the same string.
173
+ * `tests/unit/hook_cli.test.mjs`'s newline test carries a `build` row (through `geneseed
174
+ * build`, which calls this `main` in-process), and that is what can see it.
174
175
  *
175
176
  * A Buffer chunk passes through untouched: `writeText` owns file bytes and nothing on this
176
177
  * path writes binary to a stream, so translating one would be corrupting data rather than
package/js/lib/json.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  * These are definitions, not an adaptation of somebody else's; see `fs.mjs` for why the
6
6
  * measurements against CPython are recorded per function, and why NONE of these is the Node
7
7
  * default — the warning the retired `py` prefix used to carry now lives in each docblock.
8
- * A rule here is a frozen fact about what this tool emits — the corpora under
9
- * `tests/__snapshots__/` compare it byte for byte and can never be re-recorded.
8
+ * A rule here is a frozen fact about what this tool emits — the written-out tables in
9
+ * `tests/unit/lib_primitives.test.mjs` and `tests/unit/settings_jsonc.test.mjs` pin it.
10
10
  *
11
11
  * `JsonNumber` is why this module holds both the parser and the renderers: the wrapper is
12
12
  * created by `parseJson` and read by `formatValue`, `formatRepr`, `isTruthy` and `deepEquals`, so the
@@ -305,8 +305,8 @@ function escapeNonAscii(text) {
305
305
  * Indentation is what makes this tractable: passing `indent` switches Python's
306
306
  * separators from `(', ', ': ')` to `(',', ': ')`, which is exactly what
307
307
  * `JSON.stringify(v, null, 2)` emits. Verified rather than argued \u2014
308
- * `tests/test_opencode_extras_parity.py` compares both variants against Python over a
309
- * corpus of container shapes.
308
+ * the former Python suite compared both variants against Python over a corpus of container
309
+ * shapes; `tests/unit/lib_primitives.test.mjs` now pins only this writer's number spellings.
310
310
  */
311
311
  export function jsonDumpsIndent(value, { ensureAscii = true } = {}) {
312
312
  const text = JSON.stringify(value, null, 2);
package/js/lib/paths.mjs CHANGED
@@ -22,7 +22,7 @@ const { X_OK } = constants;
22
22
  * BMP), and `str.lower()` is not `toLowerCase()` for a handful of characters.
23
23
  *
24
24
  * THE SEPARATOR CONVERSION IS NOT OPTIONAL, and it was missing until the byte-bearing
25
- * corpus in `tests/test_pure_function_parity.py` asked. `ntpath.normcase` is
25
+ * corpus in the former Python parity suite asked. `ntpath.normcase` is
26
26
  * `s.replace('/', '\\').lower()`, and `PurePath._str_normcase` gets the same conversion for
27
27
  * free because `Path` has already done it — so BOTH readings of the reference fold slashes
28
28
  * and this did not. Every sort call site feeds it `path.join` output or a bare `readdir`
@@ -50,7 +50,7 @@ export const normcase = process.platform === 'win32'
50
50
  * a directory depth, and `golden.py` compared CONTENT rather than order. It became visible
51
51
  * the first time a full path list was PRINTED \u2014 `geneseed validate -v` \u2014 against a real
52
52
  * skills tree that happens to contain both `skills/geneseed/` and `skills/geneseed-code-review/`.
53
- * `tests/test_maintainer_tools_parity.py` found it and died with the reference it compared
53
+ * The former Python maintainer-tools test found it and died with the reference it compared
54
54
  * against. What HOLDS it now is `tests/unit/maintainer_tools.test.mjs`, and specifically its
55
55
  * `theme files are visited in THIS platform's Path collation order` test: it re-derives Python's
56
56
  * `sorted(Path)` order for the running platform instead of replaying a recording, so it cannot
@@ -73,10 +73,14 @@ export function comparePaths(a, b) {
73
73
  * `_install_move_list` in `js/maintain/uninstall.mjs` \u2014 a CLI-level module that must not import the
74
74
  * web tree to reach it. Beside `normcase`, which is the thing it is actually about.
75
75
  * `js/web/api.mjs` re-exports it so its existing importers are unchanged.
76
+ *
77
+ * TRAILING SEPARATORS ARE STRIPPED FROM `parent` FIRST. A root is the one path whose canonical
78
+ * spelling ends in one — `D:\` and `/` — and splitting it leaves an empty last segment that no
79
+ * child's segment equals, so nothing was ever `within` a drive root or `/`.
76
80
  */
77
81
  export function within(child, parent) {
78
82
  const c = normcase(child).split(/[\\/]/);
79
- const p = normcase(parent).split(/[\\/]/);
83
+ const p = normcase(parent).replace(/[\\/]+$/, '').split(/[\\/]/);
80
84
  return p.length <= c.length && p.every((seg, i) => c[i] === seg);
81
85
  }
82
86
 
@@ -86,7 +90,7 @@ export function within(child, parent) {
86
90
  * `Path` is `WindowsPath` on Windows and `PosixPath` everywhere else, and the two parsers
87
91
  * disagree about the two things below. A body that is right on one host is therefore wrong
88
92
  * on the other, which is exactly how this one passed a whole Windows suite and failed two
89
- * cases on the first Linux run of `tests/test_pure_function_parity.py`:
93
+ * cases on the first Linux run of the former Python pure-function parity suite:
90
94
  *
91
95
  * * SEPARATORS. `ntpath` treats `\` and `/` alike; `posixpath` treats `\` as an ordinary
92
96
  * filename character. `str(PurePosixPath('C:\\x\\bin\\'))` is that string VERBATIM —
@@ -100,8 +104,8 @@ export function within(child, parent) {
100
104
  * NOT reproduced, and named here rather than left as a gap nobody wrote down: `//` and
101
105
  * `///x` on WINDOWS, where `PureWindowsPath` answers `\\` and `\\\x` and this answers `\`
102
106
  * and `\x`. Those are incomplete UNC prefixes and matching them needs ntpath's share-name
103
- * parsing; both inputs are kept OUT of the corpus in `tests/test_pure_function_parity.py`
104
- * instead of sitting in it wrong.
107
+ * parsing; both inputs were kept OUT of the retired parity corpus instead of sitting in it
108
+ * wrong.
105
109
  *
106
110
  * The root's separators are still rewritten ONE AT A TIME, never `replace(/[\\/]+/g, sep)`:
107
111
  * a UNC root's leading pair is part of it, so collapsing runs turns `//server/share/x` into
@@ -143,7 +147,8 @@ export function toPlatformPath(s) {
143
147
  * `context` cell varies it, and changing it in this phase would be an ungated edit to a verb
144
148
  * that crossed three phases ago. Recorded in the spec's "Still owed" with this reproduction.
145
149
  *
146
- * Gated by a corpus in `tests/test_pure_function_parity.py`: no cell can vary the shape.
150
+ * It was gated by a corpus in the retired Python pure-function parity suite; nothing has
151
+ * replaced it, and no CLI test can vary the shape.
147
152
  */
148
153
  export function isAbsolutePath(s) {
149
154
  if (process.platform !== 'win32') return s.startsWith('/');
@@ -166,27 +171,21 @@ export function isAbsolutePath(s) {
166
171
  * PATH-removal refutation is aimed at, and the reason this is a real lookup rather than one
167
172
  * line of "we are node, we know where node is".
168
173
  *
169
- * The Windows half is the part with behaviour in it, and three of its four rules were
170
- * MEASURED against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
174
+ * The Windows half is the part with behaviour in it, and its suffix rules were MEASURED
175
+ * against CPython 3.13 rather than assumed. `PATHEXT` supplies the suffixes, and the
171
176
  * returned path carries PATHEXT's OWN SPELLING of the extension rather than the file's
172
177
  * (`zzcmd.CMD` for a `zzcmd.cmd` on disk). An extension already in that set means the command
173
- * is spelled in full. An EMPTY PATH entry is not skipped either: `join('', cmd)` is a
174
- * relative path, and Python answers with one.
175
- *
176
- * THE FOURTH RULE IS THE CURRENT DIRECTORY, and it was measured on a machine that could not
177
- * see it. `shutil.which` prepends `os.curdir` to the search — even to a `path=` given
178
- * explicitly — when `_winapi.NeedCurrentDirectoryForExePath` says so, and that Win32 call
179
- * answers TRUE unless `NoDefaultCurrentDirectoryInExePath` is DEFINED in the environment.
180
- * Git Bash and this repo's developer machine both define it, so the reference answered None
181
- * there and the first draft's "the current directory is NOT prepended — it was until 3.12"
182
- * described an environment variable it had mistaken for a version change. GitHub's Windows
183
- * runner does not define it: the reference answered `.\\geneseed.CMD` for a `geneseed` looked
184
- * up beside the repo root, this returned null, and the parity corpus said so on the first CI
185
- * run. The variable is an INPUT, so it is read here rather than baked, and
186
- * `test_the_curdir_rule_agrees_in_both_environment_states` runs the corpus with it both ways.
187
- *
188
- * Gated as a pure function over a seeded directory in `tests/test_pure_function_parity.py`:
189
- * a cell can only ever observe the one answer this machine's PATH gives.
178
+ * is spelled in full.
179
+ *
180
+ * THE CURRENT DIRECTORY IS NEVER SEARCHED IMPLICITLY, and here this departs from
181
+ * `shutil.which` on purpose. The reference prepends `os.curdir` on Windows unless
182
+ * `NoDefaultCurrentDirectoryInExePath` is defined, and answers an EMPTY PATH entry (a
183
+ * trailing `;` is routine) with a cwd-relative path. Every caller SPAWNS what this returns —
184
+ * `git` from `upgrade`, `java` from `setup`, `node` from the doctor, `npm` from the web server
185
+ * — from whatever directory the user is in, so a `git.exe` committed to an untrusted clone ran
186
+ * the moment `geneseed upgrade` was typed inside it. Both routes are closed; a `.` the user
187
+ * wrote into PATH themselves is still honoured, because that is their choice and not ours.
188
+ * Gated in `tests/unit/lib_primitives.test.mjs`.
190
189
  */
191
190
  export function which(cmd, searchPath = null) {
192
191
  const win = process.platform === 'win32';
@@ -209,14 +208,9 @@ export function which(cmd, searchPath = null) {
209
208
  // then spawns. Measured: the corpus case with a posix-spelled directory was the one that
210
209
  // differed, and `path.join` had rewritten the whole path rather than appending to it.
211
210
  const joinRaw = (dir, name) => (
212
- dir === '' || /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
211
+ /[\\/]$/.test(dir) || (win && dir.endsWith(':'))
213
212
  ? dir + name : dir + path.sep + name);
214
- const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter);
215
- // `NeedCurrentDirectoryForExePath(cmd)`: TRUE — so `.` goes in FRONT — unless the variable
216
- // is defined. Only reachable with a separator-free `cmd`, which is the only way execution
217
- // gets this far. Inserted before the dedupe, exactly where `shutil.which` inserts it, so a
218
- // PATH that already names `.` is searched once.
219
- if (win && process.env.NoDefaultCurrentDirectoryInExePath === undefined) dirs.unshift('.');
213
+ const dirs = (searchPath ?? process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
220
214
  const seen = new Set();
221
215
  for (const dir of dirs) {
222
216
  const key = win ? dir.toLowerCase() : dir;
package/js/lib/proc.mjs CHANGED
@@ -11,9 +11,9 @@
11
11
  * that module and six of its spawns used it, so the four spawn sites in `js/inspect/checks-authoring.mjs`,
12
12
  * `js/hosts/hooks.mjs`, `js/maintain/setup.mjs` and `js/hosts/link.mjs` each had to remember the flag on their
13
13
  * own. Two of them did not — and the miss is BYTE-INVISIBLE: a console window is not
14
- * something the CLI prints, so every recorded cell under `tests/__snapshots__/` stays green
15
- * while the windows pop. The property under test never reaches the comparison, which is why
16
- * a recorded corpus cannot be the gate here. A shared constant plus
14
+ * something the CLI prints, so every cell of `tests/golden.mjs --cli` stays green while the
15
+ * windows pop. The property under test never reaches the comparison, which is why a byte
16
+ * comparison cannot be the gate here. A shared constant plus
17
17
  * `tests/unit/spawn_hygiene.test.mjs` is what makes the next spawn site inherit the answer
18
18
  * instead of re-deciding it.
19
19
  *