geneseed 3.13.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 (131) hide show
  1. package/CHANGELOG.md +93 -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 +45 -10
  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 +32 -34
  10. package/bin/geneseed-hook.mjs +28 -18
  11. package/docs/extending.md +5 -5
  12. package/harness.config.json +1 -1
  13. package/js/README.md +28 -15
  14. package/js/build/args.mjs +375 -0
  15. package/js/build/bundle.mjs +10 -10
  16. package/js/build/driver.mjs +145 -560
  17. package/js/build/emit-claude.mjs +5 -6
  18. package/js/build/emit-common.mjs +2 -14
  19. package/js/build/emit-opencode.mjs +12 -8
  20. package/js/build/generate.mjs +21 -14
  21. package/js/build/render.mjs +6 -10
  22. package/js/build/source.mjs +9 -17
  23. package/js/build/stubs.mjs +6 -5
  24. package/js/build/version.mjs +21 -2
  25. package/js/hosts/hooks-context.mjs +432 -0
  26. package/js/hosts/hooks-learn.mjs +275 -0
  27. package/js/hosts/hooks-prims.mjs +108 -0
  28. package/js/hosts/hooks.mjs +50 -977
  29. package/js/hosts/hosts.mjs +31 -25
  30. package/js/hosts/installs.mjs +38 -20
  31. package/js/hosts/link.mjs +75 -26
  32. package/js/hosts/mcp.mjs +12 -48
  33. package/js/hosts/memory-files.mjs +190 -0
  34. package/js/hosts/native.mjs +106 -48
  35. package/js/hosts/opencode.mjs +44 -36
  36. package/js/hosts/settings.mjs +135 -414
  37. package/js/hosts/shim.mjs +375 -0
  38. package/js/inspect/checks-authoring.mjs +21 -828
  39. package/js/inspect/checks-build.mjs +4 -4
  40. package/js/inspect/checks-constitution.mjs +493 -0
  41. package/js/inspect/checks-counts.mjs +242 -0
  42. package/js/inspect/checks-repo.mjs +17 -8
  43. package/js/inspect/diff.mjs +9 -7
  44. package/js/inspect/doctor.mjs +2 -2
  45. package/js/inspect/excludes.mjs +10 -5
  46. package/js/inspect/inventory.mjs +4 -4
  47. package/js/inspect/scan.mjs +1 -1
  48. package/js/inspect/status.mjs +19 -22
  49. package/js/inspect/validate.mjs +50 -37
  50. package/js/lib/fs.mjs +12 -11
  51. package/js/lib/json.mjs +4 -4
  52. package/js/lib/paths.mjs +27 -33
  53. package/js/lib/proc.mjs +3 -3
  54. package/js/lib/prompt.mjs +132 -0
  55. package/js/lib/text.mjs +18 -4
  56. package/js/lib/udiff.mjs +3 -2
  57. package/js/loop/catalog.mjs +5 -3
  58. package/js/loop/cli.mjs +26 -12
  59. package/js/loop/graph.mjs +1 -1
  60. package/js/loop/registry.mjs +10 -15
  61. package/js/loop/score.mjs +1 -1
  62. package/js/loop/state.mjs +35 -11
  63. package/js/maintain/memory.mjs +6 -7
  64. package/js/maintain/migrate.mjs +22 -33
  65. package/js/maintain/setup.mjs +9 -131
  66. package/js/maintain/uninstall.mjs +57 -97
  67. package/js/maintain/update.mjs +115 -34
  68. package/js/ui/anim.mjs +8 -12
  69. package/js/ui/cli.mjs +37 -43
  70. package/js/ui/menu.mjs +7 -13
  71. package/js/ui/tui.mjs +14 -123
  72. package/js/web/actions.mjs +109 -403
  73. package/js/web/activity.mjs +1 -1
  74. package/js/web/api.mjs +41 -725
  75. package/js/web/catalog.mjs +527 -0
  76. package/js/web/daemon.mjs +21 -3
  77. package/js/web/docs.mjs +29 -72
  78. package/js/web/handler.mjs +51 -27
  79. package/js/web/jobs.mjs +24 -2
  80. package/js/web/routes.mjs +61 -6
  81. package/js/web/server.mjs +4 -4
  82. package/js/web/state.mjs +138 -0
  83. package/js/web/user-files.mjs +339 -0
  84. package/package.json +1 -1
  85. package/web/dist/assets/Activity-kpWxd70F.js +1 -0
  86. package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
  87. package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
  88. package/web/dist/assets/Docs-BHyaggFy.js +5 -0
  89. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  90. package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
  91. package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
  92. package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
  93. package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
  94. package/web/dist/assets/Library-MBxadRzn.js +1 -0
  95. package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
  96. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  97. package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
  98. package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
  99. package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
  100. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  101. package/web/dist/assets/api-CxgfYu18.js +1 -0
  102. package/web/dist/assets/drawable-BbWQxe82.js +1 -0
  103. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  104. package/web/dist/assets/index-ujKG1ZNf.js +2 -0
  105. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  106. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  107. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  108. package/web/dist/index.html +5 -6
  109. package/web/src/pages/Laws.jsx +0 -1
  110. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  111. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  112. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  113. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  114. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  115. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  116. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  117. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  118. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  119. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  120. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  121. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  122. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  123. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  124. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  125. package/web/dist/assets/api-D_ixdyEY.js +0 -1
  126. package/web/dist/assets/drawable-C_x5xncq.js +0 -1
  127. package/web/dist/assets/index-CMPgezFm.js +0 -2
  128. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  129. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  130. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  131. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -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`
@@ -47,31 +47,28 @@ import {
47
47
  } from 'node:fs';
48
48
  import path from 'node:path';
49
49
 
50
- import { hookRunnerEntry } from '../build/driver.mjs';
51
- import { confirm } from './setup.mjs';
50
+ import { hookRunnerEntry } from '../hosts/shim.mjs';
51
+ import { confirm } from '../lib/prompt.mjs';
52
52
  import {
53
53
  claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
54
54
  installState,
55
- registeredTargets, readMaybe, DISABLED_STASH,
55
+ registeredTargets, DISABLED_STASH,
56
56
  } from '../hosts/installs.mjs';
57
57
  import {
58
- GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
58
+ CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
59
59
  } from '../hosts/hosts.mjs';
60
- import { mcpCommented } from '../hosts/mcp.mjs';
60
+ import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
61
61
  import {
62
- managedBlockRead, managedBlockRemove, managedBlockWrite,
63
- mergeClaudeSettings, opencodeTarget, readJsonc,
62
+ atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
+ loadJsonObject, mergeClaudeSettings, opencodeTarget,
64
64
  settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
65
65
  } from '../hosts/settings.mjs';
66
66
  import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
- import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
67
+ import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
68
68
  import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
69
69
 
70
70
  const hostSpec = (host) => HOSTS.find((h) => h.host === host);
71
71
 
72
- /** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
73
- const CLAUDE_STYLE = ['claude', 'bob', 'openclaude'];
74
-
75
72
  /**
76
73
  * `shutil.rmtree(p, ignore_errors=True)`.
77
74
  *
@@ -154,11 +151,15 @@ function pruneAncestors(start, stop) {
154
151
  * failure strings) differ in the root and in the label, which are the two arguments. The
155
152
  * `victim.is_file()` guard is load-bearing rather than defensive: a manifest entry naming a
156
153
  * directory is SKIPPED, not recursed into, and `removed` counts only what actually went.
154
+ *
155
+ * `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
156
+ * on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
157
+ * then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
157
158
  */
158
159
  function unlinkOwned(base, owned, label = '') {
159
160
  let removed = 0;
160
161
  const failed = [];
161
- for (const rel of owned) {
162
+ for (const rel of ownedWithin(base, owned)) {
162
163
  const victim = path.join(base, rel);
163
164
  try {
164
165
  if (isFile(victim)) {
@@ -177,6 +178,23 @@ function unlinkOwned(base, owned, label = '') {
177
178
  return [removed, failed];
178
179
  }
179
180
 
181
+ /**
182
+ * The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
183
+ * `owned` goes through (uninstall, both deactivates).
184
+ *
185
+ * `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
186
+ * boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
187
+ * `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
188
+ * `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
189
+ * concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
190
+ * on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
191
+ * besides, which is why the guard runs on the resolved path rather than the lexical one.
192
+ */
193
+ function ownedWithin(base, rels) {
194
+ const rroot = resolvePath(base);
195
+ return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
196
+ }
197
+
180
198
  /** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
181
199
  function warnSurvivors(failed) {
182
200
  printErr('[uninstall] WARN: could not remove '
@@ -203,13 +221,9 @@ const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
203
221
  */
204
222
  export function unmergeOpencodeJson(p, entry) {
205
223
  const target = opencodeTarget(p);
206
- if (!existsSync(target)) return false;
207
- let cfg;
208
- let hadComments;
209
- const text = readMaybe(target);
210
- if (text === null) return false; // the Python's `except OSError: return False`
211
- [cfg, hadComments] = readJsonc(text);
212
- if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) return false;
224
+ // Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
225
+ const { state, data: cfg, hadComments } = loadJsonObject(target);
226
+ if (state !== 'ok') return false;
213
227
  const instr = cfg.instructions;
214
228
  if (!Array.isArray(instr) || !instr.includes(entry)) return false;
215
229
  if (path.extname(target) === '.jsonc' && hadComments) {
@@ -218,7 +232,7 @@ export function unmergeOpencodeJson(p, entry) {
218
232
  return false;
219
233
  }
220
234
  cfg.instructions = instr.filter((i) => i !== entry);
221
- writeText(target, `${jsonDumpsIndent(cfg)}\n`);
235
+ atomicWriteJson(target, cfg);
222
236
  return true;
223
237
  }
224
238
 
@@ -269,8 +283,7 @@ function claudeMdPath(cfg, managed) {
269
283
 
270
284
  /** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
271
285
  function managedOf(man) {
272
- const mg = man.managed;
273
- return typeof mg === 'object' && mg !== null && !Array.isArray(mg) ? mg : {};
286
+ return isDict(man.managed) ? man.managed : {};
274
287
  }
275
288
 
276
289
  /** A manifest's `owned` list, or `[]`. */
@@ -339,22 +352,6 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
339
352
  return { removed, unmerged, archived, failed };
340
353
  }
341
354
 
342
- /**
343
- * `_harness_mcp._mcp_load`, reduced to the OpenCode arm.
344
- *
345
- * The Claude arm (`.mcp.json`, `~/.claude.json`, parsed STRICTLY — see `mcpLoad` in
346
- * js/hosts/mcp.mjs for why) lives with the MCP catalog. `installAgentEntry` is this port's only caller and passes
347
- * no host, so porting the branch would ship an unreachable arm with no cell and no partition
348
- * to be part of — the criterion this port decides keep-vs-delete by.
349
- */
350
- function mcpLoadOpencode(p) {
351
- if (!existsSync(p)) return {};
352
- const text = readMaybe(p);
353
- if (text === null) return {};
354
- const [data] = readJsonc(text);
355
- return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {};
356
- }
357
-
358
355
  /**
359
356
  * `_harness_mcp._install_agent_entry` — the `instructions` entry to drop.
360
357
  *
@@ -367,7 +364,7 @@ export function installAgentEntry(root, kind) {
367
364
  if (kind === 'global') {
368
365
  return path.join(root, 'AGENT.md').split(path.sep).join('/');
369
366
  }
370
- const cfg = mcpLoadOpencode(opencodeTarget(path.join(root, 'opencode.json')));
367
+ const cfg = mcpLoad(opencodeTarget(path.join(root, 'opencode.json')));
371
368
  return installAgentEntryOf(cfg.instructions);
372
369
  }
373
370
 
@@ -377,7 +374,7 @@ export function installAgentEntry(root, kind) {
377
374
  * A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
378
375
  * that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
379
376
  * seed but whose answer then only differs on one platform, in one branch, with no other
380
- * 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.
381
378
  */
382
379
  export function installAgentEntryOf(instr) {
383
380
  if (Array.isArray(instr)) {
@@ -645,22 +642,6 @@ function printOtherHostHits(root, removedHost) {
645
642
  }
646
643
  }
647
644
 
648
- /**
649
- * `_harness_setup._confirm`, and the only interactive read this verb makes.
650
- *
651
- * MOVED TO `js/maintain/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
652
- * `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
653
- * body stayed here for one phase because a helper with one caller is not shared yet; the
654
- * rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
655
- * licenses the move.
656
- *
657
- * UNREACHABLE FROM EVERY CELL, restated because moving it does not change that: `cmdUninstall`
658
- * only reaches it when stdin is a TTY, and the harness gives every cell a pipe. What the
659
- * cells DO gate is the branch beside it — the non-interactive refusal — which is why that one
660
- * has an `expect` naming its wording. It is now gated positively too, by the stdin-seeded
661
- * corpus P5i added for the wizard.
662
- */
663
-
664
645
  /**
665
646
  * `_harness_mcp.cmd_uninstall`.
666
647
  *
@@ -690,7 +671,7 @@ export function cmdUninstall(args) {
690
671
  const data = installDataDir(root, host, scope);
691
672
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
692
673
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
693
- if (['claude', 'bob', 'openclaude'].includes(host)) {
674
+ if (CLAUDE_STYLE.includes(host)) {
694
675
  printOut('[uninstall] removes: agents/, skills/, markers, the '
695
676
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
696
677
  + 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
@@ -751,9 +732,10 @@ export function cmdUninstall(args) {
751
732
  // ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
752
733
  // puts every earlier move back and reports, leaving the install fully `active`; a port that
753
734
  // merely stopped on the failure would leave a half-gutted config dir that neither state
754
- // answers for. `tests/web_golden.py`'s `install/a-deactivate-whose-second-move-collides…`
755
- // drives it: a manifest naming a file AND its parent directory is the only shape a seeded
756
- // 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.
757
739
 
758
740
  /**
759
741
  * `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
@@ -810,17 +792,7 @@ function installRelive(root) {
810
792
  return isFile(path.join(root, 'AGENT.md'));
811
793
  }
812
794
 
813
- /**
814
- * `_harness_mcp._install_move_list` — the rels to move aside, `_within`-guarded.
815
- *
816
- * `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
817
- * boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
818
- * `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
819
- * `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
820
- * concatenated it into `<root>/C:/evil.md`, which passes containment and would then be MOVED.
821
- * `path.resolve` has pathlib's semantics for exactly this case, and collapses `..` besides,
822
- * which is why the guard runs on the resolved path rather than the lexical one.
823
- */
795
+ /** `_harness_mcp._install_move_list` — the rels to move aside, `ownedWithin`-guarded. */
824
796
  function installMoveList(root, kind) {
825
797
  let rels;
826
798
  if (kind === 'project') {
@@ -828,8 +800,7 @@ function installMoveList(root, kind) {
828
800
  } else {
829
801
  rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
830
802
  }
831
- const rroot = resolvePath(root);
832
- return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
803
+ return ownedWithin(root, rels);
833
804
  }
834
805
 
835
806
  /**
@@ -844,16 +815,11 @@ function installMoveList(root, kind) {
844
815
  */
845
816
  function installReaddEntry(target, entry) {
846
817
  if (!existsSync(target)) {
847
- mkdirSync(path.dirname(target), { recursive: true });
848
- writeText(target, `${jsonDumpsIndent({
849
- $schema: 'https://opencode.ai/config.json', instructions: [entry],
850
- })}\n`);
818
+ atomicWriteJson(target, { $schema: 'https://opencode.ai/config.json', instructions: [entry] });
851
819
  return true;
852
820
  }
853
- let raw;
854
- try { raw = readText(target); } catch { return false; } // except OSError
855
- const [cfg, hadComments] = readJsonc(raw);
856
- if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
821
+ const { state, data: cfg, hadComments } = loadJsonObject(target);
822
+ if (state !== 'ok') return false; // unreadable (except OSError), or not an object
857
823
  const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
858
824
  if (indexOfDeepEqual(instr, entry) >= 0) return false;
859
825
  if (path.extname(target) === '.jsonc' && hadComments) {
@@ -862,7 +828,7 @@ function installReaddEntry(target, entry) {
862
828
  return false;
863
829
  }
864
830
  cfg.instructions = [...instr, entry];
865
- writeText(target, `${jsonDumpsIndent(cfg)}\n`);
831
+ atomicWriteJson(target, cfg);
866
832
  return true;
867
833
  }
868
834
 
@@ -961,7 +927,7 @@ function restoreAll(stash, base, skip = null) {
961
927
  * The config edit is the LAST step and the only non-move mutation, so a move failure rolls
962
928
  * back cleanly with the `instructions` entry still intact. The prune climbs from each moved
963
929
  * file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
964
- * 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.
965
931
  */
966
932
  export function installDeactivate(root, host = 'opencode', scope = 'global') {
967
933
  if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);
@@ -1067,16 +1033,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1067
1033
  const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1068
1034
  : excludedRulesOfDir(cfg);
1069
1035
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1070
- const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1036
+ const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1071
1037
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
1072
1038
  // `and data` — a manifest that did not parse is not one to write back.
1073
1039
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1074
1040
  managed.settings_hooks = claims;
1075
1041
  data.managed = managed;
1076
- const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
1077
1042
  try {
1078
- writeText(tmp, `${jsonDumpsIndent(data)}\n`);
1079
- renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
1043
+ atomicWriteJson(path.join(cfg, GLOBAL_MANIFEST), data);
1080
1044
  } catch { /* except OSError: pass */ }
1081
1045
  }
1082
1046
  }
@@ -1093,9 +1057,7 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1093
1057
  const man = claudeReadManifest(cfg);
1094
1058
  const managed = managedOf(man);
1095
1059
  const stash = path.join(cfg, DISABLED_STASH, host);
1096
- const rroot = resolvePath(cfg);
1097
- const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
1098
- && within(resolvePath(path.resolve(rroot, r)), rroot));
1060
+ const rels = ownedWithin(cfg, ownedOf(man).filter((r) => r !== VERSION_MARKER));
1099
1061
  const { done, failed } = moveAll(cfg, stash, rels);
1100
1062
  if (failed) {
1101
1063
  cleanHostStash(cfg, host);
@@ -1129,9 +1091,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1129
1091
  const stash = path.join(cfg, DISABLED_STASH, host);
1130
1092
  const blockFile = path.join(stash, '_claude_md_block.txt');
1131
1093
  const reliveManifest = claudeReadManifest(cfg);
1132
- // `.get("managed") or {}` — an `or`, not an isinstance guard, and the difference is
1133
- // observable on a manifest whose `managed` is `null`.
1134
- const reliveManaged = reliveManifest.managed || {};
1094
+ const reliveManaged = managedOf(reliveManifest);
1135
1095
  let relive = managedBlockRead(claudeMdPath(cfg, reliveManaged)) !== null;
1136
1096
  // Bob GLOBAL writes no managed AGENTS.md block — its preamble carrier is the owned
1137
1097
  // `rules/geneseed.md`, so a live copy of THAT is the relive signal there. Without this arm
@@ -1149,7 +1109,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1149
1109
  }
1150
1110
  const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
1151
1111
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1152
- const managed = claudeReadManifest(cfg).managed || {};
1112
+ const managed = managedOf(claudeReadManifest(cfg));
1153
1113
  // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
1154
1114
  // `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
1155
1115
  // Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
@@ -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).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;