geneseed 3.14.0 → 3.16.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 (94) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-context.js +45 -11
  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/docs/concepts/exclusions.md +21 -9
  12. package/docs/guides/install-scripted.md +1 -1
  13. package/docs/guides/project-context.md +1 -1
  14. package/docs/token-footprint.md +10 -7
  15. package/harness.config.json +1 -1
  16. package/js/README.md +9 -5
  17. package/js/build/args.mjs +38 -6
  18. package/js/build/bundle.mjs +10 -10
  19. package/js/build/driver.mjs +16 -14
  20. package/js/build/emit-claude.mjs +3 -2
  21. package/js/build/emit-common.mjs +2 -14
  22. package/js/build/emit-opencode.mjs +12 -8
  23. package/js/build/generate.mjs +26 -16
  24. package/js/build/render.mjs +34 -4
  25. package/js/build/source.mjs +43 -7
  26. package/js/build/stubs.mjs +6 -5
  27. package/js/build/version.mjs +21 -2
  28. package/js/cli-table.json +15 -0
  29. package/js/hosts/hooks-context.mjs +85 -34
  30. package/js/hosts/hooks-prims.mjs +2 -2
  31. package/js/hosts/hosts.mjs +24 -26
  32. package/js/hosts/installs.mjs +58 -16
  33. package/js/hosts/link.mjs +75 -26
  34. package/js/hosts/memory-files.mjs +13 -4
  35. package/js/hosts/native.mjs +106 -48
  36. package/js/hosts/opencode.mjs +46 -36
  37. package/js/hosts/shim.mjs +4 -2
  38. package/js/inspect/diff.mjs +9 -7
  39. package/js/inspect/excludes.mjs +10 -5
  40. package/js/inspect/status.mjs +20 -22
  41. package/js/inspect/validate.mjs +5 -3
  42. package/js/lib/fs.mjs +12 -11
  43. package/js/lib/json.mjs +4 -4
  44. package/js/lib/paths.mjs +27 -33
  45. package/js/lib/proc.mjs +3 -3
  46. package/js/lib/prompt.mjs +132 -0
  47. package/js/lib/text.mjs +18 -4
  48. package/js/lib/udiff.mjs +3 -2
  49. package/js/maintain/memory.mjs +5 -6
  50. package/js/maintain/migrate.mjs +4 -3
  51. package/js/maintain/setup.mjs +20 -135
  52. package/js/maintain/uninstall.mjs +10 -9
  53. package/js/maintain/update.mjs +115 -34
  54. package/js/ui/anim.mjs +8 -12
  55. package/js/ui/cli.mjs +29 -35
  56. package/js/ui/menu.mjs +7 -13
  57. package/js/ui/tui.mjs +14 -123
  58. package/js/web/actions.mjs +87 -389
  59. package/js/web/activity.mjs +1 -1
  60. package/js/web/api.mjs +33 -717
  61. package/js/web/catalog.mjs +527 -0
  62. package/js/web/daemon.mjs +21 -3
  63. package/js/web/handler.mjs +51 -27
  64. package/js/web/jobs.mjs +24 -2
  65. package/js/web/routes.mjs +61 -6
  66. package/js/web/server.mjs +4 -4
  67. package/js/web/state.mjs +138 -0
  68. package/js/web/user-files.mjs +339 -0
  69. package/package.json +1 -1
  70. package/src/AGENT.md.tmpl +1 -1
  71. package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
  72. package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
  73. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  74. package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
  75. package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
  76. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  77. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  78. package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
  79. package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
  80. package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
  81. package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
  82. package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
  83. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  84. package/web/dist/assets/api-CxgfYu18.js +1 -0
  85. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  86. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  87. package/web/dist/index.html +2 -2
  88. package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
  89. package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
  90. package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
  91. package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
  92. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  93. package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
  94. package/web/dist/assets/index-KHt3wE_K.js +0 -2
@@ -31,17 +31,17 @@
31
31
  * and a cell's stdin is a pipe. So the whole wizard is unreachable from the acceptance
32
32
  * matrix in the same way `_confirm` was in P5h — except that here it is not one branch, it
33
33
  * is the verb. The `setup/*` cells therefore gate the REFUSAL, absolutely and on both sides,
34
- * and everything past it is gated as a CORPUS in `tests/test_pure_function_parity.py`.
34
+ * and everything past it is gated as a CORPUS in `tests/unit/wizard.test.mjs`.
35
35
  *
36
36
  * The corpus is a new shape and the brief predicted the reason: a corpus over a stdin reader
37
- * needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.{py,mjs}` are run with stdin
38
- * redirected from a file of answers and their WHOLE stdout compared — so the prompts, the
37
+ * needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.mjs` is run with stdin
38
+ * redirected from a file of answers and its WHOLE stdout compared — so the prompts, the
39
39
  * numbered menus, the default markers, the `About to run:` line and the returned selection
40
40
  * are all one byte comparison. That is a stricter gate than a cell would have been, because
41
41
  * a cell could not have varied the answers at all.
42
42
  */
43
43
  import { spawnSync } from 'node:child_process';
44
- import { existsSync, readSync } from 'node:fs';
44
+ import { existsSync } from 'node:fs';
45
45
  import path from 'node:path';
46
46
 
47
47
  import { main as driverMain, resolveOut } from '../build/driver.mjs';
@@ -57,132 +57,10 @@ import {
57
57
  } from '../hosts/installs.mjs';
58
58
  import { printOut, printErr } from '../lib/fs.mjs';
59
59
  import { which } from '../lib/paths.mjs';
60
+ import { ask, askChoice, confirm } from '../lib/prompt.mjs';
60
61
  import { parseIntStrict } from '../lib/text.mjs';
61
62
  import { NO_WINDOW } from '../lib/proc.mjs';
62
63
 
63
- // --------------------------------------------------------------------------------------
64
- // the three readers
65
- // --------------------------------------------------------------------------------------
66
-
67
- /**
68
- * `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
69
- *
70
- * WHY IT READS A BYTE AT A TIME. Node has no synchronous line read: `readFileSync(0)` blocks
71
- * until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
72
- * until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
73
- * `input()` does, and it is the only synchronous shape that stops where Enter stops. The
74
- * body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`; `setup` is the second caller,
75
- * so it moved to where the Python's own owner is — `_harness_setup` — and `confirm` is built
76
- * on it here exactly as `_confirm` is built on `_ask` there.
77
- *
78
- * The EOF branch is the one a piped caller sees, and it agrees with Python by two different
79
- * routes that happen to produce one answer: `input()` RAISES `EOFError` and the `except`
80
- * returns the default, while a zero-length read here leaves `line` empty and `|| dflt` does
81
- * the same. A partial line with no trailing newline is not EOF to either — Python returns
82
- * the characters and so does this.
83
- *
84
- * `.trim()` is `str.strip()` in every case but U+FEFF, which JS counts as whitespace and
85
- * Python does not; that is a standing item of this port and not this function's to settle.
86
- */
87
- export function ask(prompt, dflt = '') {
88
- const suffix = dflt ? ` [${dflt}]` : '';
89
- // `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
90
- // translate, and it must NOT gain one.
91
- process.stdout.write(`${prompt}${suffix}: `);
92
- // `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
93
- // catches `EOFError` and returns the default, which is what the empty string produces
94
- // here through `|| dflt`. The two routes to one answer are the docblock's own point.
95
- const ans = (readLine() ?? '').trim();
96
- return ans || dflt;
97
- }
98
-
99
- /**
100
- * `input(prompt)` with EOF told apart from an empty line — `null` where Python RAISES.
101
- *
102
- * `ask` above cannot express that difference and does not need to: its EOF and its empty
103
- * answer both mean "take the default". `_web_server.serve`'s npm prompt is the first
104
- * caller where they differ, and they differ dangerously — `""` is in its accepted set, so
105
- * a port that read EOF as an empty line would answer YES to "run npm install now?" on
106
- * every non-interactive run. The reference catches `(EOFError, KeyboardInterrupt)` and
107
- * answers "n"; this returns null so the caller can.
108
- *
109
- * No `.trim()`: `input()` does not strip, and both callers strip for themselves.
110
- */
111
- export function promptLine(prompt) {
112
- process.stdout.write(prompt);
113
- return readLine();
114
- }
115
-
116
- /**
117
- * Fd 0 to the first `\n`, dropping a `\r` before it — the CRLF a Windows console sends.
118
- *
119
- * THE BYTES ARE COLLECTED AND DECODED ONCE, and that is a fix rather than a style. The
120
- * version this arrived as decoded EACH BYTE on its own — `buf.toString('utf8', 0, n)` inside
121
- * the loop — which turns every multi-byte character into two or three U+FFFD. It was
122
- * invisible for a phase because its only caller was `confirm`, whose answer is `y` or `n`;
123
- * the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
124
- * first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
125
- * are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
126
- */
127
- function readLine() {
128
- const buf = Buffer.alloc(1);
129
- const bytes = [];
130
- let eof = false;
131
- for (;;) {
132
- let n = 0;
133
- try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
134
- if (n === 0) { eof = true; break; }
135
- if (buf[0] === 0x0a) break;
136
- if (buf[0] !== 0x0d) bytes.push(buf[0]);
137
- }
138
- // NULL ONLY FOR THE EMPTY EOF, which is the one case `input()` raises on. A partial line
139
- // with no trailing newline is not EOF to Python — it returns the characters — and the
140
- // `bytes.length` test is what keeps that arm returning a string here too.
141
- if (eof && bytes.length === 0) return null;
142
- return Buffer.from(bytes).toString('utf8');
143
- }
144
-
145
- /** `_harness_setup._confirm` — `_ask` with a Y/n suffix; the default on an empty answer. */
146
- export function confirm(prompt, dflt = true) {
147
- const ans = ask(`${prompt} (${dflt ? 'Y/n' : 'y/N'})`).toLowerCase();
148
- return ans === '' ? dflt : ans[0] === 'y';
149
- }
150
-
151
- /**
152
- * `_harness_setup._ask_choice` — a numbered menu; the chosen KEY, or the default.
153
- *
154
- * THREE THINGS ABOUT THE FALLBACK ORDER, all of them observable and none of them obvious:
155
- *
156
- * * The key match lives in Python's `except ValueError`, so an answer that PARSES as an
157
- * integer never reaches it. `99` against a five-option menu returns the default; it does
158
- * not go looking for an option literally named `99`.
159
- * * An in-range index wins, a parsed out-of-range index falls to the default, and only an
160
- * UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
161
- * case from the third, and it is not `Number()` — see its own docblock.
162
- * * The default's index is computed with `next(...)` and no fallback, so a default that is
163
- * not in `options` raises `StopIteration` on the reference. That is reachable (a
164
- * deployed install can carry a theme this checkout no longer ships) and it is mirrored
165
- * as a throw rather than smoothed over: the two implementations must fail in the same
166
- * place, and a silent `-1` here would be a divergence dressed as robustness.
167
- */
168
- export function askChoice(prompt, options, dflt) {
169
- printOut(`\n${prompt}:\n`);
170
- options.forEach(([key, desc], i) => {
171
- const label = desc ? `${key} — ${desc}` : key;
172
- printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
173
- });
174
- const defaultIdx = options.findIndex(([k]) => k === dflt);
175
- if (defaultIdx < 0) throw new Error(`StopIteration: no option named ${dflt}`);
176
- const raw = ask('Choose', String(defaultIdx + 1));
177
- const idx = parseIntStrict(raw);
178
- if (idx !== null) {
179
- if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
180
- } else {
181
- for (const [key] of options) if (raw === key) return key;
182
- }
183
- return dflt;
184
- }
185
-
186
64
  // --------------------------------------------------------------------------------------
187
65
  // the option tables
188
66
  // --------------------------------------------------------------------------------------
@@ -372,7 +250,7 @@ export function collectSetupLines() {
372
250
  const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
373
251
  const doctrines = askDoctrines(inst.doctrines);
374
252
  const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
375
- const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS.map(([k, d]) => [k, d]),
253
+ const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS,
376
254
  inst.footprint || 'lean');
377
255
  let out = null;
378
256
  let root = null;
@@ -399,11 +277,18 @@ export function collectSetupLines() {
399
277
  // `setupBuildArgs` spell `--exclude-rules none`, and that string would land in the
400
278
  // "About to run:" line every recorded wizard probe compares byte for byte.
401
279
  const excludeRules = inst.excludeRules?.length ? inst.excludeRules : null;
280
+ // Skills left out are preserved the same way, and elide when there are none.
281
+ const excludeSkills = inst.excludeSkills?.length ? inst.excludeSkills : null;
402
282
  printOut(`\nAbout to run: geneseed build ${
403
283
  setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines, allPacks,
404
- excludeRules).join(' ')}\n`);
284
+ excludeRules, undefined, excludeSkills).join(' ')}\n`);
405
285
  if (!confirm('Proceed?', true)) return null;
406
- return { theme, posture, mode, doctrines, excludeRules, emit, out, root, footprint };
286
+ // The key only when there is something to carry, so a selection that excludes no skill is
287
+ // the same object it was before the flag existed.
288
+ return {
289
+ theme, posture, mode, doctrines, excludeRules, ...(excludeSkills ? { excludeSkills } : {}),
290
+ emit, out, root, footprint,
291
+ };
407
292
  }
408
293
 
409
294
  // --------------------------------------------------------------------------------------
@@ -430,8 +315,8 @@ export function javaMajorOk(versionOutput, minimum = 21) {
430
315
  * `_harness_setup._lsp_prereqs` — `(label, present, hint)` for what OpenCode cannot install
431
316
  * for itself. Today that is a JDK 21+ for `jdtls`; the JS-runtime servers self-download.
432
317
  *
433
- * THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `test_the_cli_reaches_child_process_\
434
- * only_where_it_is_declared` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
318
+ * THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `ALLOWED_SPAWNS` in
319
+ * `tests/unit/hook_cli.test.mjs` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
435
320
  * for one binding and one argv; this is the second, and the port's rule is that the second
436
321
  * instance of anything turns a special case into a table cross-checked against the source.
437
322
  * The argv is asserted literally there, so an allow-list entry cannot be reused to start an
@@ -572,7 +457,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
572
457
  * command the reader can type, and names one that survives the deletion.
573
458
  *
574
459
  * `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/ui/anim.mjs`, gated by
575
- * `_theme_anim_cases()` in `tests/test_pure_function_parity.py`. The bare `catch` around it
460
+ * the theme-animation jobs in `tests/unit/wizard.test.mjs`. The bare `catch` around it
576
461
  * is the reference's own, labelled there "cosmetic only — never block a successful install",
577
462
  * and it is kept for the same reason: the animation runs AFTER the build has already
578
463
  * succeeded, so nothing it can do may change the exit code.
@@ -585,7 +470,7 @@ export function setupLines() {
585
470
  }
586
471
  const {
587
472
  theme, emit, out, root, footprint = 'lean', posture = 'peer', mode = 'direct',
588
- doctrines = null, excludeRules = null,
473
+ doctrines = null, excludeRules = null, excludeSkills = null,
589
474
  } = sel;
590
475
  if (emit === 'opencode-global') {
591
476
  // The build below overwrites the deployed global harness, and the self-improvement loops
@@ -604,7 +489,7 @@ export function setupLines() {
604
489
  // runs must carry the pack selection the plan line just previewed, or the wizard would print
605
490
  // one command and run another.
606
491
  const argv = setupBuildArgs(theme, emit, out, root, footprint, posture, mode, doctrines,
607
- doctrineOptions().map(([n]) => n), excludeRules);
492
+ doctrineOptions().map(([n]) => n), excludeRules, undefined, excludeSkills);
608
493
  printOut(`Running: geneseed build ${argv.join(' ')}\n`);
609
494
  const rc = driverMain(argv);
610
495
  if (rc !== 0) {
@@ -17,9 +17,9 @@
17
17
  * 2. AN EMPTY DIRECTORY WAS INVISIBLE TO EVERY CELL. The ancestor-climb prune is a quarter of
18
18
  * this verb and `_snapshot` could not see it: a port that unlinked the files and left
19
19
  * `skills/<name>/` behind was byte-identical everywhere. Closed by a `<dirs>` column in
20
- * `tests/harness_golden.py`'s snapshot rather than a per-cell expectation, because one
21
- * entry closes it for all 219 cells written before it existed — `cmdTheme`'s statement
22
- * order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
20
+ * the CLI matrix's snapshot (`tests/helpers/cli_golden.mjs`) rather than a per-cell
21
+ * expectation, because one entry closes it for all 219 cells written before it existed —
22
+ * `cmdTheme`'s statement order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
23
23
  * expectation kind, `expect_absent_files`.
24
24
  * 3. `--archive-memory` TOUCHES THE MEMORY STORE, the one thing here a user cannot rebuild.
25
25
  * It is why `_archive_store` MOVES and never deletes, and why the Python's `memory=delete`
@@ -48,7 +48,7 @@ import {
48
48
  import path from 'node:path';
49
49
 
50
50
  import { hookRunnerEntry } from '../hosts/shim.mjs';
51
- import { confirm } from './setup.mjs';
51
+ import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
54
  installState,
@@ -374,7 +374,7 @@ export function installAgentEntry(root, kind) {
374
374
  * A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
375
375
  * that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
376
376
  * seed but whose answer then only differs on one platform, in one branch, with no other
377
- * observable effect. `tests/test_pure_function_parity.py` drives the list directly.
377
+ * observable effect. The former Python corpus drove the list directly; nothing does now.
378
378
  */
379
379
  export function installAgentEntryOf(instr) {
380
380
  if (Array.isArray(instr)) {
@@ -732,9 +732,10 @@ export function cmdUninstall(args) {
732
732
  // ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
733
733
  // puts every earlier move back and reports, leaving the install fully `active`; a port that
734
734
  // merely stopped on the failure would leave a half-gutted config dir that neither state
735
- // answers for. `tests/web_golden.py`'s `install/a-deactivate-whose-second-move-collides…`
736
- // drives it: a manifest naming a file AND its parent directory is the only shape a seeded
737
- // world can use to make the SECOND move collide.
735
+ // answers for. `tests/unit/web_api.test.mjs`'s `deactivate rolls back on a failed move`
736
+ // drives it with a file planted where the stash dir belongs, which fails the FIRST move; the
737
+ // former web matrix's SECOND-move collision (a manifest naming a file AND its parent
738
+ // directory) has no successor.
738
739
 
739
740
  /**
740
741
  * `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
@@ -926,7 +927,7 @@ function restoreAll(stash, base, skip = null) {
926
927
  * The config edit is the LAST step and the only non-move mutation, so a move failure rolls
927
928
  * back cleanly with the `instructions` entry still intact. The prune climbs from each moved
928
929
  * file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
929
- * snapshot can see, and the reason `tests/web_golden.py` grew a `<dirs>` column in P6i.
930
+ * snapshot can see, and the reason the former web matrix grew a `<dirs>` column in P6i.
930
931
  */
931
932
  export function installDeactivate(root, host = 'opencode', scope = 'global') {
932
933
  if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Self-update: `git pull` the install's own origin, validate, rebuild.
3
3
  *
4
- * The verb this whole port exists for. Every other subcommand can be Python for one more
5
- * release; `upgrade` is the one operation a "no Python needed" install cannot delegate,
6
- * because a Node install that shelled out to the Python harness to update itself
7
- * would have Python as a hard dependency of the only command a user MUST be able to run.
4
+ * The one operation a user MUST always be able to run: every other verb can be broken by a bad
5
+ * release, and this is the verb that fetches the fix.
8
6
  *
9
7
  * WHY THIS MODULE SPAWNS, AND WHY THE ARGUMENT IS NOT THE JOB RUNNER'S. `js/web/jobs.mjs`
10
8
  * re-executes this program for ISOLATION — a synchronous verb in the daemon's event loop
@@ -15,21 +13,23 @@
15
13
  * the modules this process loaded before the merge — it would validate the old source, pass,
16
14
  * and then render the old source into the bundle, reporting success. That is not a slower
17
15
  * version of the right answer, it is the wrong one. So `runDoctor`, `rebuildBundle` and
18
- * `rebuildInstalls` each start a fresh interpreter over the tree as it now is, exactly as the
19
- * reference does, and `_ALLOWED_SPAWNS` in `tests/test_hook_cli_parity.py` carries the argv.
16
+ * `rebuildInstalls` each start a fresh interpreter over the tree as it now is, and
17
+ * `ALLOWED_SPAWNS` in `tests/unit/hook_cli.test.mjs` carries the argv. The same reason is why
18
+ * the two modules this file reaches lazily (`js/build/generate.mjs`, `js/web/daemon.mjs`) are
19
+ * loaded BEFORE the pull and not at their call sites: an `import()` after the merge would mix
20
+ * pulled files with the ones already cached, and a renamed export kills the upgrade mid-way.
20
21
  *
21
22
  * `git` is the fourth spawn and the plainest: there is no git library in the Node stdlib,
22
23
  * and the port will not vendor one. Every git call goes through the `gitRun` seam —
23
24
  * which-guarded, credential-redacted, and it never throws.
24
25
  *
25
- * WHAT THE GATE CANNOT SEE, stated rather than implied. `upgrade/*` in
26
- * `tests/harness_golden.py` runs each arm against a private clone of a bare origin the
27
- * fixture builds, so no cell can reach a network — which is a safety property and a blind
26
+ * WHAT THE GATE CANNOT SEE, stated rather than implied. `tests/unit/update_git.test.mjs` and
27
+ * `tests/unit/update_flow.test.mjs` run each arm against a private clone of a bare origin the
28
+ * fixture builds, so no test can reach a network — which is a safety property and a blind
28
29
  * spot at once: `git fetch --progress` over a local path prints NOTHING, so
29
30
  * `fetchStreaming`'s reader, its phase filter and its 15s heartbeat are reachable in every
30
- * cell and varied in none. The phase filter is gated as a corpus in
31
- * `tests/test_pure_function_parity.py`; the heartbeat and the hard deadline are gated by
32
- * neither and are declared in the P8a handoff.
31
+ * run and varied in none. The phase filter is gated over a recorded transcript in
32
+ * `tests/unit/update_git.test.mjs`; the heartbeat and the hard deadline are gated by nothing.
33
33
  *
34
34
  * ALSO HERE, SINCE P8b: `syncSelf`/`cmdSyncSelf` and the bootstrap step runner
35
35
  * (`cmdBootstrap`, `bootstrapPlain`) at the bottom of this file. Still NOT here: `main`, the
@@ -47,10 +47,9 @@ import { ROOT } from '../build/source.mjs';
47
47
  import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
48
48
  import { opencodeConfigDir } from '../hosts/hosts.mjs';
49
49
  import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
50
- import { toPlatformPath, which } from '../lib/paths.mjs';
50
+ import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
51
51
  import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
52
52
  import { NO_WINDOW } from '../lib/proc.mjs';
53
- import { bounceDaemonIfRunning } from '../web/daemon.mjs';
54
53
 
55
54
  /**
56
55
  * `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
@@ -248,8 +247,14 @@ export function countOccurrences(s) {
248
247
  export function killTree(child) {
249
248
  try {
250
249
  if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
251
- else spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)],
252
- { encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
250
+ else {
251
+ // `which`, never a bare name (a bare name resolves against the cwd first on Windows);
252
+ // no taskkill on PATH falls through to the `child.kill()` below.
253
+ const taskkill = which('taskkill');
254
+ if (!taskkill) throw new Error('taskkill not on PATH');
255
+ spawnSync(taskkill, ['/F', '/T', '/PID', String(child.pid)],
256
+ { encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
257
+ }
253
258
  } catch {
254
259
  try { child.kill(); } catch { /* already gone */ }
255
260
  }
@@ -262,7 +267,7 @@ export function killTree(child) {
262
267
  * times and universal-newline decoding turns every `\r` repaint into its own line, so
263
268
  * without this a 30-second fetch writes a thousand lines into the install log. No cell can
264
269
  * reach it — a local-path fetch prints nothing at all — so
265
- * `tests/test_pure_function_parity.py` runs it over a recorded fetch transcript instead.
270
+ * `tests/unit/update_git.test.mjs` runs it over a recorded fetch transcript instead.
266
271
  *
267
272
  * Returns `[linesToLog, lastPhase]`, splitting the reference's loop from its side effects.
268
273
  */
@@ -366,13 +371,19 @@ export async function fetchStreaming(log = null) {
366
371
 
367
372
  /**
368
373
  * `_measure_upstream(log)` — Phase B: fetch (streamed), then classify. Returns
369
- * `[code, behind, err]` with `code` ∈ {ready, fetch_failed, unrelated, diverged, uptodate}.
374
+ * `[code, behind, err]` with `code` ∈ {ready, fetch_failed, no_upstream, unrelated, diverged,
375
+ * uptodate}.
376
+ *
377
+ * `no_upstream` HERE, AFTER PREFLIGHT SAID THERE WAS ONE: the fetch itself can take it away.
378
+ * An upstream branch deleted on the remote, fetched with `fetch.prune`, leaves `@{u}` naming
379
+ * nothing — both counts then fail, and read as two zeros they said "already up to date".
370
380
  */
371
381
  export async function measureUpstream(log = null) {
372
382
  const [rc, err] = await fetchStreaming(log);
373
383
  if (rc !== 0) return ['fetch_failed', 0, err];
374
- const [, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
375
- const [, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
384
+ const [arc, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
385
+ const [brc, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
386
+ if (arc !== 0 || brc !== 0) return ['no_upstream', 0, ''];
376
387
  const ahead = countOccurrences(aheadRaw);
377
388
  const behind = countOccurrences(behindRaw);
378
389
  if (ahead > 0) {
@@ -419,16 +430,31 @@ export function runDoctor(cand) {
419
430
  return [r.status === 0, universal(r.stdout) + universal(r.stderr)];
420
431
  }
421
432
 
422
- /** `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback. */
433
+ /**
434
+ * `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback.
435
+ *
436
+ * `collision` ONLY WHEN GIT SAYS SO. A fast-forward can fail for other reasons — a lock file, a
437
+ * permission, a merge refused outright — and calling every one an untracked-file collision
438
+ * sent the user hunting for a file that is not there, with an info exit (3) over an error.
439
+ *
440
+ * THE ROLLBACK IS `reset --keep`, NEVER `--hard`. The doctor window lasts up to 300 s, and an
441
+ * edit made in the checkout meanwhile is a tracked edit `--hard` would destroy without a word.
442
+ * `--keep` refuses instead when such an edit touches a file the rollback must change; that
443
+ * refusal is reported as itself — the user is left on the pulled commit, with the command to
444
+ * finish the rollback — rather than as a rollback that did not happen.
445
+ */
423
446
  export function pullAndValidate(log) {
424
447
  const [rc0, old] = gitRun(['rev-parse', 'HEAD']);
425
448
  if (rc0 !== 0 || !old) return [false, 'not_git', 'could not read HEAD'];
426
449
  log('[geneseed] fast-forwarding to upstream ...');
427
450
  const [rc1, , err] = gitRun(['merge', '--ff-only', '@{u}'], { timeout: 60 });
428
451
  if (rc1 !== 0) {
429
- return [false, 'collision',
430
- 'Update blocked — a new upstream file collides with a local untracked '
431
- + 'file. Move or remove it, then update.\n' + err];
452
+ if (/would be overwritten/.test(err)) {
453
+ return [false, 'collision',
454
+ 'Update blocked — a new upstream file collides with a local untracked '
455
+ + 'file. Move or remove it, then update.\n' + err];
456
+ }
457
+ return [false, 'merge_failed', `Update failed — could not fast-forward to upstream.\n${err}`];
432
458
  }
433
459
  const [rc2, pulled] = gitRun(['log', '--oneline', '--no-decorate', '-20', `${old}..HEAD`]);
434
460
  if (rc2 === 0 && pulled) {
@@ -439,8 +465,15 @@ export function pullAndValidate(log) {
439
465
  const [passed, output] = runDoctor(ROOT);
440
466
  log(output.replace(/\n+$/, ''));
441
467
  if (!passed) {
442
- gitRun(['reset', '--hard', old], { timeout: 60 });
468
+ const [rc3, , rerr] = gitRun(['reset', '--keep', old], { timeout: 60 });
443
469
  for (const line of DOCTOR_LEGEND) log(line);
470
+ if (rc3 !== 0) {
471
+ return [false, 'doctor_fail',
472
+ 'the pulled source FAILS validation, and the rollback was REFUSED — a file it must '
473
+ + 'restore was edited while the doctor ran, and those edits were kept. You are still '
474
+ + `on the pulled commit; save your edits, then roll back with: git reset --keep ${old}\n`
475
+ + rerr];
476
+ }
444
477
  return [false, 'doctor_fail',
445
478
  'the pulled source FAILS validation — rolled back to the previous commit. '
446
479
  + 'Fix the problems listed above.'];
@@ -505,8 +538,9 @@ export function markerTheme(cfg, out) {
505
538
  }
506
539
 
507
540
  /**
508
- * `_config_theme(here)` — the theme the LOCAL `harness.config.json` asks for, captured
509
- * before SYNC overwrites it with upstream's. Fallback only; a bundle marker still wins.
541
+ * `_config_theme(here)` — the theme the checkout's `harness.config.json` asks for. Fallback
542
+ * only; a bundle marker still wins. Read before the pull, but nothing local is at stake: the
543
+ * file is tracked and `preflight` refuses a dirty tree, so the value read is the committed one.
510
544
  */
511
545
  export function configTheme(here) {
512
546
  try {
@@ -518,11 +552,16 @@ export function configTheme(here) {
518
552
  /**
519
553
  * `_migrate_stray_bundle` — move host state (context.json, memory/) from an OLD in-folder
520
554
  * bundle to the canonical sibling `out` BEFORE rebuilding, then drop the stray.
555
+ *
556
+ * "IS `out` THE STRAY ITSELF?" IS A PATH COMPARISON, NOT A STRING ONE. `GENESEED_OUT` is typed
557
+ * by a user, so on Windows `c:\x\Geneseed\harness` is the same directory as the stray and was a
558
+ * different string: the rescue was skipped as "nothing to move into", and the `rmSync` at the
559
+ * bottom then deleted the LIVE bundle — memory/ and context.json with it.
521
560
  */
522
561
  export function migrateStrayBundle(here, out, log) {
523
562
  const stray = path.join(String(here), 'Harness');
524
563
  const outStr = String(out);
525
- if (outStr === stray) return;
564
+ if (comparePaths(path.resolve(outStr), path.resolve(stray)) === 0) return;
526
565
  try { if (!statSync(stray).isDirectory()) return; } catch { return; }
527
566
  mkdirSync(outStr, { recursive: true });
528
567
  const ctx = path.join(stray, 'context.json');
@@ -559,15 +598,40 @@ async function drainStdout() {
559
598
  await new Promise((resolve) => { process.stdout.write('', resolve); });
560
599
  }
561
600
 
601
+ /**
602
+ * The flags that re-emit the install at `dir` on its OWN axes — footprint, posture, mode,
603
+ * trust, doctrine packs, excluded rules — or `[]` when no build ever wrote a marker there.
604
+ *
605
+ * WITHOUT THEM THE REBUILD RESETS THE INSTALL. The generator's defaults are not the install's:
606
+ * a missing `--footprint` is `lean`, and posture, mode, packs and excluded rules come from
607
+ * `harness.config.json`. So a `full`, mentor, narrowed or rule-excluded install was re-emitted
608
+ * as the config default — markers included — and `rebuild-all` right after reads those
609
+ * rewritten markers back through `installProfile`, so the loss stuck. `installProfile` is the
610
+ * one reader of an install's axes; this takes its answer rather than growing a second one.
611
+ *
612
+ * Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
613
+ * may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
614
+ * is given no out and no root.
615
+ */
616
+ export async function installAxes(dir, { installProfile, setupBuildArgs }) {
617
+ if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
618
+ const p = installProfile('opencode', 'global', String(dir));
619
+ return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
620
+ undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
621
+ }
622
+
562
623
  /**
563
624
  * `_rebuild_bundle` — render the bundle from the (already-updated) source and bounce a
564
- * running web daemon. Returns the build subprocess returncode (0 = ok).
625
+ * running web daemon. Returns the build subprocess returncode (0 = ok). `axes` is
626
+ * `installAxes` of the target, read before the pull; `bounce` is the daemon module's
627
+ * `bounceDaemonIfRunning`, loaded before the pull for the reason the file header gives.
565
628
  */
566
- export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
629
+ export async function rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounce) {
567
630
  const buildArgs = ['--out', String(out)];
568
631
  if (theme) buildArgs.push('--theme', theme);
569
632
  if (emit === 'opencode') buildArgs.push('--emit', 'opencode', '--root', String(rootDir));
570
633
  else if (emit === 'opencode-global') buildArgs.push('--emit', 'opencode-global');
634
+ buildArgs.push(...axes);
571
635
  log(`[geneseed] rebuilding bundle -> ${out} (theme: ${theme || 'config default'}, emit: ${emit}) ...`);
572
636
  await drainStdout();
573
637
  // THE GENERATOR, RE-EXECUTED. `js/build/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
@@ -580,7 +644,7 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
580
644
  // If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
581
645
  // take effect — otherwise the open PWA keeps hitting the old code. The web-job exception
582
646
  // (`upgrade/the-web-job-marker-...` gates it) lives with the helper.
583
- await bounceDaemonIfRunning(theme, log);
647
+ await bounce(theme, log);
584
648
  return 0;
585
649
  }
586
650
 
@@ -636,6 +700,12 @@ export async function upgrade(ref = null, themeArg = null) {
636
700
  return p.kind === 'info' ? 3 : 1;
637
701
  }
638
702
 
703
+ // Loaded and read BEFORE the pull — see the file header for why the imports cannot wait, and
704
+ // `installAxes` for why the target's own axes must be carried into the rebuild.
705
+ const [generate, { bounceDaemonIfRunning }] = await Promise.all([
706
+ import('../build/generate.mjs'), import('../web/daemon.mjs')]);
707
+ const axes = await installAxes(emit === 'opencode-global' ? cfg : out, generate);
708
+
639
709
  log(`[geneseed] fetching from origin (git: ${which('git') || 'git'}, `
640
710
  + `timeout: ${fetchTimeout()}s) ...`);
641
711
  const [code, behind, err] = await measureUpstream(log);
@@ -647,6 +717,10 @@ export async function upgrade(ref = null, themeArg = null) {
647
717
  + 'Kerberos credentials). Restart it from that terminal: `geneseed web restart`.');
648
718
  return 1;
649
719
  }
720
+ if (code === 'no_upstream') {
721
+ log(`[geneseed] ${PRE_MSG.no_upstream[1]}`);
722
+ return 3;
723
+ }
650
724
  if (code === 'unrelated') {
651
725
  log('[geneseed] Upstream history was rewritten; back up local work, then re-clone '
652
726
  + 'or `git reset --hard @{u}`.');
@@ -676,7 +750,8 @@ export async function upgrade(ref = null, themeArg = null) {
676
750
  } catch (e) {
677
751
  log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
678
752
  }
679
- let rc = await rebuildBundle(here, out, theme, emit, rootDir, log);
753
+ let rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes,
754
+ bounceDaemonIfRunning);
680
755
  if (rc !== 0) {
681
756
  log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
682
757
  return 1;
@@ -886,13 +961,19 @@ function reexec(argv) {
886
961
  * `flushExportNotes()` runs BEFORE the handoff, deliberately: the re-exec replaces this process
887
962
  * and the update step's own improvements notice has scrolled past (or, under the reference's
888
963
  * curses arm, died with the alternate screen).
964
+ *
965
+ * A FAILED UPDATE STILL EXITS 1 AFTER THE HANDOFF. `setup` still runs — the user asked for it,
966
+ * and it can repair what the update could not — but its exit code used to become bootstrap's,
967
+ * so a script saw 0 over an update that never happened. `handoff` is the seam a test needs:
968
+ * the real `setup` exits 1 off a terminal, which would make the two answers indistinguishable.
889
969
  */
890
- export async function cmdBootstrap(args) {
970
+ export async function cmdBootstrap(args, { handoff = reexec } = {}) {
891
971
  const failed = await bootstrapPlain();
892
972
  flushExportNotes();
893
973
  if (!args.noSetup) {
894
- return reexec([process.execPath,
974
+ const rc = handoff([process.execPath,
895
975
  path.join(String(ROOT), 'bin', 'geneseed-cli.mjs'), 'setup']);
976
+ return failed ? 1 : rc;
896
977
  }
897
978
  // Scripted `bootstrap --no-setup` must not exit 0 over a failed update.
898
979
  return failed ? 1 : 0;
package/js/ui/anim.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * P7c, and the one leftover of that phase that is NOT the declared curses panel. P5i ported
5
5
  * `_setup_lines` and left this out with a note — "belongs to P7 with the rest of the terminal
6
- * layer" — and the rest of the terminal layer turned out to be the panel, which stays
7
- * declared (`tests/test_tui_boundary.py` says why, and asserts it). This module is the part
6
+ * layer" — and the rest of the terminal layer turned out to be the panel, which was rejected
7
+ * (`tests/unit/no_panel.test.mjs` asserts it stays gone). This module is the part
8
8
  * that has nothing to do with curses: it draws on the ORDINARY terminal, with cursor-up
9
9
  * escapes and a sleep, and its only caller is a function that already crossed.
10
10
  *
@@ -23,16 +23,12 @@
23
23
  * needed — which is fortunate, because it is a `child_process` spawn and this port is barred
24
24
  * from those. The consequence is that the reference's `except Exception: return False` arm
25
25
  * around it has no counterpart here. That arm needs `cmd.exe` to be unlaunchable on a machine
26
- * that has already reached an interactive wizard, and `anim_ok` is compared over ten
27
- * environments in `tests/test_pure_function_parity.py` — none of which can produce it on
28
- * either side.
26
+ * that has already reached an interactive wizard.
29
27
  *
30
- * WHAT GATES IT. `tests/__snapshots__/primitives/{posix,win32}.json`, replayed by
31
- * `tests/snapshot/pure_snapshot.test.mjs`: `art_for` over every key plus the prototype-chain
32
- * names, `_place`/`_tile`/`_height` over their arithmetic, `anim_ok` over its whole decision
33
- * table, and `play_line` itself over 24 environments — captured on the reference side through
34
- * a real `TextIOWrapper`, so the CRLF translation is inside the recording rather than outside
35
- * it. There is no recorder left, so a red run means these functions moved, never the corpus.
28
+ * WHAT GATES IT. Little, now: the recorded snapshot corpus that replayed `art_for`, `_place`,
29
+ * `_tile`, `_height`, `anim_ok` and `play_line` is gone with the reference.
30
+ * `tests/unit/wizard.test.mjs` asserts the wizard still plays the animation, and in the right
31
+ * place; `tests/unit/no_panel.test.mjs` that this file is the one allowed its cursor escapes.
36
32
  */
37
33
  import { printOut } from '../lib/fs.mjs';
38
34
  import { parseIntStrict, codePointLength, padEndToWidth } from '../lib/text.mjs';
@@ -235,7 +231,7 @@ export function place(row, x, width) {
235
231
  * Python turns into an empty string and `String.repeat` turns into a RangeError, hence the
236
232
  * clamp. And `phase % len(tile)` is non-negative in Python for a positive divisor while
237
233
  * JavaScript keeps the sign of the dividend, so a negative phase indexes off the front of the
238
- * string instead of wrapping to the end. `_spin` had the same trap in P7b.
234
+ * string instead of wrapping to the end.
239
235
  */
240
236
  export function tile(tileStr, width, phase = 0) {
241
237
  if (!tileStr) return '';