geneseed 3.1.3 → 3.3.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 (150) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +24 -14
  3. package/SETUP.md +38 -7
  4. package/SHIPPED.md +6 -5
  5. package/adapters/bob/README.md +40 -10
  6. package/adapters/claude-code/README.md +5 -0
  7. package/adapters/claude-code/settings.json +8 -1
  8. package/adapters/copilot/README.md +50 -24
  9. package/adapters/opencode/README.md +5 -1
  10. package/adapters/opencode/plugins/geneseed-guard.js +40 -6
  11. package/bin/build-driver.mjs +143 -89
  12. package/bin/geneseed-cli.mjs +63 -106
  13. package/bin/geneseed-hook.mjs +26 -16
  14. package/docs/candidate-hosts.md +74 -0
  15. package/docs/extending.md +6 -5
  16. package/docs/token-footprint.md +32 -21
  17. package/docs/web-ui.md +5 -13
  18. package/harness.config.json +1 -1
  19. package/js/README.md +5 -15
  20. package/js/build/emit-claude.mjs +26 -12
  21. package/js/build/emit-common.mjs +63 -114
  22. package/js/build/emit-opencode.mjs +74 -66
  23. package/js/build/generate.mjs +4 -6
  24. package/js/build/render.mjs +50 -32
  25. package/js/build/source.mjs +29 -8
  26. package/js/build/stubs.mjs +25 -33
  27. package/js/build/themes.mjs +1 -1
  28. package/js/build/version.mjs +1 -3
  29. package/js/cli-table.json +84 -0
  30. package/js/hosts/hooks.mjs +243 -81
  31. package/js/hosts/hosts.mjs +57 -5
  32. package/js/hosts/installs.mjs +35 -28
  33. package/js/hosts/link.mjs +2 -2
  34. package/js/hosts/mcp.mjs +3 -1
  35. package/js/hosts/native.mjs +54 -19
  36. package/js/hosts/opencode.mjs +39 -28
  37. package/js/hosts/settings.mjs +206 -115
  38. package/js/inspect/checks-authoring.mjs +5 -4
  39. package/js/inspect/checks-build.mjs +1 -1
  40. package/js/inspect/diff.mjs +1 -3
  41. package/js/inspect/doctor.mjs +59 -44
  42. package/js/inspect/excludes.mjs +5 -11
  43. package/js/inspect/inventory.mjs +38 -56
  44. package/js/inspect/registry.mjs +4 -7
  45. package/js/inspect/scan.mjs +8 -15
  46. package/js/inspect/status.mjs +51 -3
  47. package/js/lib/fs.mjs +34 -0
  48. package/js/lib/json.mjs +28 -1
  49. package/js/lib/paths.mjs +0 -10
  50. package/js/lib/udiff.mjs +11 -16
  51. package/js/maintain/memory.mjs +2 -4
  52. package/js/maintain/migrate.mjs +3 -3
  53. package/js/maintain/uninstall.mjs +127 -88
  54. package/js/maintain/update.mjs +4 -4
  55. package/js/ui/anim.mjs +1 -1
  56. package/js/ui/menu.mjs +1 -1
  57. package/js/ui/tui.mjs +3 -20
  58. package/js/web/actions.mjs +230 -217
  59. package/js/web/activity.mjs +71 -89
  60. package/js/web/api.mjs +130 -226
  61. package/js/web/daemon.mjs +92 -110
  62. package/js/web/docs.mjs +59 -126
  63. package/js/web/handler.mjs +94 -123
  64. package/js/web/jobs.mjs +116 -178
  65. package/js/web/routes.mjs +47 -192
  66. package/js/web/server.mjs +47 -82
  67. package/package.json +1 -1
  68. package/registry.json +14 -0
  69. package/src/AGENT.md.tmpl +55 -16
  70. package/src/agents/explorer.md +2 -1
  71. package/src/doctrines/README.md +3 -3
  72. package/src/doctrines/process.md +4 -2
  73. package/src/doctrines/rigor.md +13 -0
  74. package/src/laws/universal.md +133 -43
  75. package/src/ontology/universal.md +38 -13
  76. package/src/skills/_template.md +5 -1
  77. package/src/skills/bruno-collection-generator.md +63 -0
  78. package/src/skills/bruno-test-writer.md +62 -0
  79. package/src/skills/clarify.md +1 -1
  80. package/src/skills/crash-course.md +1 -0
  81. package/src/skills/decode.md +1 -0
  82. package/src/skills/drill.md +1 -0
  83. package/src/skills/feynman.md +1 -0
  84. package/src/skills/forge-mcp.md +2 -1
  85. package/src/skills/fresh-eyes.md +1 -1
  86. package/src/skills/herdr.md +1 -0
  87. package/src/skills/ingest.md +2 -1
  88. package/src/skills/learning-path.md +1 -0
  89. package/src/skills/opencode-theme.md +1 -0
  90. package/src/skills/parallel-agents.md +1 -1
  91. package/src/skills/ponytail.md +2 -2
  92. package/src/skills/research.md +4 -2
  93. package/themes/_TEMPLATE.json +5 -0
  94. package/themes/biker.json +5 -0
  95. package/themes/commentator.json +5 -0
  96. package/themes/cyberpunk.json +5 -0
  97. package/themes/gamer.json +5 -0
  98. package/themes/imperial.json +5 -0
  99. package/themes/joker.json +5 -0
  100. package/themes/marvin.json +5 -0
  101. package/themes/mean.json +5 -0
  102. package/themes/military.json +5 -0
  103. package/themes/neutral.json +5 -0
  104. package/themes/pirate.json +5 -0
  105. package/themes/sports.json +5 -0
  106. package/themes/verstappen.json +5 -0
  107. package/themes/wizard.json +5 -0
  108. package/web/dist/assets/Activity-BfVcLrda.js +1 -0
  109. package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
  110. package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
  111. package/web/dist/assets/Diff-BIjxXx46.js +5 -0
  112. package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
  113. package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
  114. package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
  115. package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
  116. package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
  117. package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
  118. package/web/dist/assets/Library-Dao3T9na.js +1 -0
  119. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
  120. package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
  121. package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
  122. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
  123. package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
  124. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
  125. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
  126. package/web/dist/assets/index-BEZvPvpF.js +1 -0
  127. package/web/dist/assets/index-DNQE5ydy.js +10 -0
  128. package/web/dist/assets/index-DzdQjvM1.css +1 -0
  129. package/web/dist/assets/index-PmQGRS1X.js +2 -0
  130. package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
  131. package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
  132. package/web/dist/index.html +2 -2
  133. package/web/src/pages/Laws.jsx +119 -130
  134. package/js/web/graph.mjs +0 -0
  135. package/web/dist/assets/Activity-B49fE_ug.js +0 -1
  136. package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
  137. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  138. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  139. package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
  140. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  141. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  142. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  143. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  144. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  145. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  146. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  147. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  148. package/web/dist/assets/index-DP035wPa.js +0 -12
  149. package/web/dist/assets/index-dHi-7M-F.js +0 -2
  150. package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/lib/udiff.mjs CHANGED
@@ -40,7 +40,7 @@
40
40
  * have its index hoisted to the front of an object literal (the P3a hazard). Nothing here
41
41
  * iterates `b2j`, so the hoist would be invisible until the day something did.
42
42
  */
43
- function chainB(b, autojunk) {
43
+ function chainB(b) {
44
44
  const b2j = new Map();
45
45
  for (let i = 0; i < b.length; i += 1) {
46
46
  const cur = b2j.get(b[i]);
@@ -48,7 +48,7 @@ function chainB(b, autojunk) {
48
48
  else cur.push(i);
49
49
  }
50
50
  const n = b.length;
51
- if (autojunk && n >= 200) {
51
+ if (n >= 200) {
52
52
  const ntest = Math.floor(n / 100) + 1;
53
53
  for (const [elt, idxs] of [...b2j]) {
54
54
  if (idxs.length > ntest) b2j.delete(elt);
@@ -135,8 +135,8 @@ function matchingBlocks(a, b, b2j) {
135
135
  }
136
136
 
137
137
  /** `SequenceMatcher.get_opcodes` — [tag, i1, i2, j1, j2]. */
138
- export function opcodes(a, b, { autojunk = true } = {}) {
139
- const b2j = chainB(b, autojunk);
138
+ function opcodes(a, b) {
139
+ const b2j = chainB(b);
140
140
  let i = 0;
141
141
  let j = 0;
142
142
  const answer = [];
@@ -159,8 +159,8 @@ export function opcodes(a, b, { autojunk = true } = {}) {
159
159
  * Returns a list rather than a generator: the Python's caller is a `for` loop that consumes
160
160
  * it once, and a lazy JS equivalent would buy nothing but a second shape to reason about.
161
161
  */
162
- export function groupedOpcodes(a, b, n = 3, opts) {
163
- let codes = opcodes(a, b, opts);
162
+ function groupedOpcodes(a, b, n = 3) {
163
+ let codes = opcodes(a, b);
164
164
  if (!codes.length) codes = [['equal', 0, 1, 0, 1]];
165
165
  if (codes[0][0] === 'equal') {
166
166
  const [tag, i1, i2, j1, j2] = codes[0];
@@ -261,15 +261,10 @@ export function unifiedDiff(a, b, {
261
261
  const LINE_BOUNDARY = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
262
262
 
263
263
  export function splitLines(text) {
264
- const out = [];
265
- let last = 0;
266
- LINE_BOUNDARY.lastIndex = 0;
267
- let m = LINE_BOUNDARY.exec(text);
268
- while (m !== null) {
269
- out.push(text.slice(last, m.index));
270
- last = m.index + m[0].length;
271
- m = LINE_BOUNDARY.exec(text);
272
- }
273
- if (last < text.length) out.push(text.slice(last));
264
+ // `String.split` doesn't touch the shared regex's `lastIndex`, so no reset is needed —
265
+ // and a text ending exactly on a boundary is the ONLY way `split` can hand back a
266
+ // trailing empty string here, which is exactly the case `splitlines()` drops.
267
+ const out = text.split(LINE_BOUNDARY);
268
+ if (out.at(-1) === '') out.pop();
274
269
  return out;
275
270
  }
@@ -27,14 +27,12 @@
27
27
  * `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
28
28
  * index line are two halves of one format, and the half that writes lives there.
29
29
  */
30
- import { readdirSync, statSync, unlinkSync } from 'node:fs';
30
+ import { readdirSync, unlinkSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
 
33
33
  import { memoryDropIndex } from '../hosts/hooks.mjs';
34
34
  import { resolveMemoryDir } from '../hosts/hosts.mjs';
35
- import { printOut, printErr } from '../lib/fs.mjs';
36
-
37
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
35
+ import { printOut, printErr, isFile } from '../lib/fs.mjs';
38
36
 
39
37
  /**
40
38
  * The two names the store owns rather than holds: the index the agent reads at session
@@ -65,7 +65,7 @@ function hookSettingsFile(root, host, scope) {
65
65
  }
66
66
 
67
67
  /** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
68
- export function hookCommandsOf(file) {
68
+ function hookCommandsOf(file) {
69
69
  // `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
70
70
  // comma is the one shape no machine in this project writes, and strict JSON rejects it —
71
71
  // which would classify a wired install as `none` and make `migrate` skip the machine
@@ -89,7 +89,7 @@ export function hookCommandsOf(file) {
89
89
  }
90
90
 
91
91
  /** `<GENESEED_HOME>/.migrated` — the ROOT the last successful migration installed from. */
92
- export function stampPath() { return path.join(shimHome(), '.migrated'); }
92
+ function stampPath() { return path.join(shimHome(), '.migrated'); }
93
93
 
94
94
  /** Where the pre-migration copies live while the re-emit runs. */
95
95
  function stashDir() { return path.join(shimHome(), '.migrate-backup'); }
@@ -101,7 +101,7 @@ function stashDir() { return path.join(shimHome(), '.migrate-backup'); }
101
101
  * no rollback. That ordering is the cheapest atomicity there is and it is why the survey is a
102
102
  * separate pass rather than a check inside the loop.
103
103
  */
104
- export function migrateSurvey() {
104
+ function migrateSurvey() {
105
105
  const rows = [];
106
106
  const commands = [];
107
107
  const unrecognised = [];
@@ -42,7 +42,7 @@
42
42
  * byte-identical in every cell of the matrix, so only the allow-list refutes it.
43
43
  */
44
44
  import {
45
- existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync,
45
+ existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync,
46
46
  unlinkSync,
47
47
  } from 'node:fs';
48
48
  import path from 'node:path';
@@ -59,16 +59,15 @@ import {
59
59
  } from '../hosts/hosts.mjs';
60
60
  import { mcpCommented } from '../hosts/mcp.mjs';
61
61
  import {
62
- managedBlockRead, managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
63
- opencodeTarget, readJsonc, settingsIntegrityCheck, wireClaudeExcludes,
64
- unwireClaudeExcludes, unwireClaudeSettings,
62
+ copilotIntegrityCheck, managedBlockRead, managedBlockRemove, managedBlockWrite,
63
+ mergeClaudeSettings, mergeCopilotSettings, opencodeTarget, readJsonc,
64
+ settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
65
+ unwireCopilotSettings,
65
66
  } from '../hosts/settings.mjs';
66
- import { printOut, printErr, readText, writeText } from '../lib/fs.mjs';
67
+ import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
67
68
  import { indexOfDeepEqual, jsonDumps, jsonDumpsIndent, deepEquals } from '../lib/json.mjs';
68
69
  import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
69
70
 
70
- const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
71
- const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
72
71
  const hostSpec = (host) => HOSTS.find((h) => h.host === host);
73
72
 
74
73
  /** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
@@ -240,10 +239,12 @@ export function unmergeOpencodeJson(p, entry) {
240
239
  * copy path back.
241
240
  */
242
241
  export function archiveStore(store) {
243
- const now = new Date();
244
- const p2 = (n) => String(n).padStart(2, '0');
245
- const stamp = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}`
246
- + `-${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
242
+ // `toLocaleString('sv-SE')` — LOCAL time, matching the old hand-rolled stamp
243
+ // (`getHours()`/`getMinutes()`/`getSeconds()` are all local-time getters, unlike
244
+ // `toISOString()`'s UTC). sv-SE's default format is already zero-padded
245
+ // "YYYY-MM-DD HH:MM:SS"; stripping the punctuation and reinserting one dash reproduces
246
+ // the exact `YYYYMMDD-HHMMSS` shape the padStart version built by hand.
247
+ const stamp = new Date().toLocaleString('sv-SE').replace(/\D/g, '').replace(/^(\d{8})/, '$1-');
247
248
  const dest = path.join(path.dirname(store), `archived-${path.basename(store)}`, stamp);
248
249
  mkdirSync(path.dirname(dest), { recursive: true });
249
250
  renameSync(store, dest);
@@ -293,11 +294,16 @@ export function claudeUninstall(cfg, archiveMemory) {
293
294
  const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
294
295
  if (failed.length) warnSurvivors(failed);
295
296
  const hooks = managed.settings_hooks || [];
296
- const unwired = unwireClaudeSettings(settingsFile(cfg, managed), hooks);
297
- unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
297
+ const sf = settingsFile(cfg, managed);
298
+ // Two shapes, two unwires: a Copilot install records `copilot_hooks` (event → one hook)
299
+ // and never `settings_hooks`, so exactly one of these does anything.
300
+ const unwired = unwireClaudeSettings(sf, hooks)
301
+ | unwireCopilotSettings(sf, managed.copilot_hooks || []);
302
+ unwireClaudeExcludes(sf, managed.settings_excludes || []);
303
+ copilotIntegrityCheck(sf, managed.copilot_hooks || [], 'absent');
298
304
  // The unwire is VERIFIED, not assumed: a commented settings file is never rewritten, so a
299
305
  // supposedly-uninstalled repo could keep firing Geneseed's hooks. Loud, never fatal.
300
- settingsIntegrityCheck(settingsFile(cfg, managed), managed, 'absent');
306
+ settingsIntegrityCheck(sf, managed, 'absent');
301
307
  // Always EXCISE, never whole-file delete: even where Geneseed created CLAUDE.md the user
302
308
  // may have added prose since. The file goes only if the excision leaves it empty.
303
309
  managedBlockRemove(claudeMdPath(cfg, managed));
@@ -401,7 +407,7 @@ export function installAgentEntryOf(instr) {
401
407
  * describing a file that no longer exists, which the fallback would then paper over with the
402
408
  * canonical spelling — and a bundle sub-dir layout would be left wired.
403
409
  */
404
- export function opencodeProjectUninstall(root) {
410
+ function opencodeProjectUninstall(root) {
405
411
  const entry = installAgentEntry(root, 'project');
406
412
  let removed = 0;
407
413
  let failed = [];
@@ -436,7 +442,7 @@ export function opencodeProjectUninstall(root) {
436
442
  }
437
443
 
438
444
  /** `_harness_mcp._install_data_dir` — where the manifest and the runtime stores live. */
439
- export function installDataDir(root, host = 'opencode', scope = 'global') {
445
+ function installDataDir(root, host = 'opencode', scope = 'global') {
440
446
  if (scope === 'project' && CLAUDE_STYLE.includes(host)) {
441
447
  return path.join(root, hostSpec(host).projectMarker);
442
448
  }
@@ -607,7 +613,7 @@ export function uninstallResolve(targetArg) {
607
613
  * config dir. The registry is the only place a project install outside the cwd can be
608
614
  * rediscovered from, and the just-removed root is excluded.
609
615
  */
610
- export function survivingProjectInstalls(removedRoot) {
616
+ function survivingProjectInstalls(removedRoot) {
611
617
  const rroot = resolvePath(removedRoot);
612
618
  const out = [];
613
619
  for (const [host, scope, root] of registeredTargets()) {
@@ -619,7 +625,7 @@ export function survivingProjectInstalls(removedRoot) {
619
625
  }
620
626
 
621
627
  /** `_harness_mcp._print_surviving_project_inventory` — informational, never a cascade. */
622
- export function printSurvivingProjectInventory(removedRoot) {
628
+ function printSurvivingProjectInventory(removedRoot) {
623
629
  const survivors = survivingProjectInstalls(removedRoot);
624
630
  if (!survivors.length) return;
625
631
  printOut(`[uninstall] ${survivors.length} project install(s) remain — the global removal `
@@ -636,7 +642,7 @@ export function printSurvivingProjectInventory(removedRoot) {
636
642
  * A repo can carry `.opencode/`, `.claude/` and `.bob/` side by side and `uninstall` only ever
637
643
  * removes the one it resolved to, so a repeat run needs to know there is more to do.
638
644
  */
639
- export function printOtherHostHits(root, removedHost) {
645
+ function printOtherHostHits(root, removedHost) {
640
646
  for (const spec of HOSTS) {
641
647
  if (spec.host !== removedHost && projectQualifies(root, spec.host)) {
642
648
  printOut(`[uninstall] also found ${spec.host}:project here — run \`harness `
@@ -691,9 +697,10 @@ export function cmdUninstall(args) {
691
697
  const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
692
698
  printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
693
699
  if (host === 'copilot') {
694
- printOut('[uninstall] removes: agents/, skills/, markers, and the '
695
- + `${hostSpec(host).agentFile} managed block (Copilot has no `
696
- + 'settings.json/hooks to unwire; your own .github files are kept).\n');
700
+ printOut('[uninstall] removes: agents/, skills/, markers, the '
701
+ + `${hostSpec(host).agentFile} managed block, and Geneseed's `
702
+ + '~/.copilot/settings.json hooks on a global install (your own keys/hooks and '
703
+ + '.github files are kept).\n');
697
704
  } else if (host === 'claude' || host === 'bob') {
698
705
  printOut('[uninstall] removes: agents/, skills/, markers, the '
699
706
  + `${hostSpec(host).agentFile} managed block, and Geneseed's `
@@ -759,9 +766,6 @@ export function cmdUninstall(args) {
759
766
  // drives it: a manifest naming a file AND its parent directory is the only shape a seeded
760
767
  // world can use to make the SECOND move collide.
761
768
 
762
- /** `e` is an OSError rather than a programming fault — Node tags fs errors with `code`. */
763
- const isOsError = (e) => Boolean(e) && typeof e.code === 'string';
764
-
765
769
  /**
766
770
  * `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
767
771
  *
@@ -849,7 +853,7 @@ function installMoveList(root, kind) {
849
853
  * tells the operator what to add rather than failing the whole reactivate. That asymmetry is
850
854
  * the reference's and is gated per side.
851
855
  */
852
- export function installReaddEntry(target, entry) {
856
+ function installReaddEntry(target, entry) {
853
857
  if (!existsSync(target)) {
854
858
  mkdirSync(path.dirname(target), { recursive: true });
855
859
  writeText(target, `${jsonDumpsIndent({
@@ -900,6 +904,68 @@ function stashFiles(stash) {
900
904
  /** `rel.as_posix()` for a path already known to be relative. */
901
905
  const asPosix = (rel) => rel.split(path.sep).join('/');
902
906
 
907
+ /**
908
+ * The move-with-rollback loop `installDeactivate` and `claudeDeactivate` each wrote out
909
+ * inline: move every `rel` from `srcBase` to `dstBase`, and on the first failure put every
910
+ * earlier move back onto `srcBase` before reporting it. All-or-nothing is the property
911
+ * (see the P6i note above `moveTree`), so a caught failure never leaves a partial move
912
+ * standing even though this function itself takes no side action on failure beyond the
913
+ * rollback — cleaning up the now-empty stash is the caller's, because the two hosts name
914
+ * their stash differently (`rmtreeQuiet(stash)` plain, `cleanHostStash(cfg, host)` tagged).
915
+ *
916
+ * Returns `{ done }` on a clean run, or `{ done, failed }` once a move has failed and been
917
+ * rolled back — `done` stays the moved-so-far list either way, which is what `rolled_back:
918
+ * done.length` in both callers reports.
919
+ */
920
+ function moveAll(srcBase, dstBase, rels) {
921
+ const done = [];
922
+ for (const rel of rels) {
923
+ const src = path.join(srcBase, rel);
924
+ if (!existsSync(src)) continue; // a manifest entry already gone — nothing to move
925
+ try {
926
+ moveTree(src, path.join(dstBase, rel));
927
+ done.push(rel);
928
+ } catch (e) {
929
+ if (!isOsError(e)) throw e;
930
+ for (const r of [...done].reverse()) {
931
+ try { renameSync(path.join(dstBase, r), path.join(srcBase, r)); } catch { /* OSError */ }
932
+ }
933
+ return { done, failed: [`${rel} (${e.message})`] };
934
+ }
935
+ }
936
+ return { done };
937
+ }
938
+
939
+ /**
940
+ * The stash-restore loop `installReactivate` and `claudeReactivate` each wrote out inline:
941
+ * move every file under `stash` back onto `base`, by its relative path. `skip`, when given,
942
+ * is one relative-posix path to leave untouched — `claudeReactivate`'s
943
+ * `_claude_md_block.txt`, which holds the managed block's TEXT and is restored separately by
944
+ * `managedBlockWrite` rather than a raw file move, because it is not a file that belongs
945
+ * verbatim at that relative path.
946
+ *
947
+ * NEVER deletes the stash while anything is unrestored: a destination collision is a
948
+ * leftover, reported rather than clobbered, so a retry has something left to restore from.
949
+ */
950
+ function restoreAll(stash, base, skip = null) {
951
+ const leftovers = [];
952
+ let moved = 0;
953
+ for (const src of stashFiles(stash)) {
954
+ const rel = path.relative(stash, src);
955
+ if (skip && asPosix(rel) === skip) continue;
956
+ const dst = path.join(base, rel);
957
+ if (existsSync(dst)) {
958
+ // NEVER delete the stash while anything is unrestored — skip, keep, report.
959
+ leftovers.push(asPosix(rel));
960
+ continue;
961
+ }
962
+ mkdirSync(path.dirname(dst), { recursive: true });
963
+ renameSync(src, dst);
964
+ moved += 1;
965
+ }
966
+ return { leftovers, moved };
967
+ }
968
+
903
969
  /**
904
970
  * `_harness_mcp._install_deactivate` — turn an OpenCode install off without deleting a byte.
905
971
  *
@@ -923,21 +989,10 @@ export function installDeactivate(root, host = 'opencode', scope = 'global') {
923
989
  + 'Disable by hand or convert it to plain .json first.' };
924
990
  }
925
991
  const stash = path.join(root, DISABLED_STASH);
926
- const done = []; // rels already moved, for rollback
927
- for (const rel of installMoveList(root, kind)) {
928
- const src = path.join(root, rel);
929
- if (!existsSync(src)) continue; // a manifest entry already gone — nothing to move
930
- try {
931
- moveTree(src, path.join(stash, rel));
932
- done.push(rel);
933
- } catch (e) {
934
- if (!isOsError(e)) throw e;
935
- for (const r of [...done].reverse()) {
936
- try { renameSync(path.join(stash, r), path.join(root, r)); } catch { /* OSError */ }
937
- }
938
- rmtreeQuiet(stash);
939
- return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
940
- }
992
+ const { done, failed } = moveAll(root, stash, installMoveList(root, kind));
993
+ if (failed) {
994
+ rmtreeQuiet(stash);
995
+ return { ok: false, failed, rolled_back: done.length };
941
996
  }
942
997
  unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
943
998
  for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
@@ -962,20 +1017,7 @@ export function installReactivate(root, host = 'opencode', scope = 'global') {
962
1017
  return { ok: true,
963
1018
  note: 'install was re-created while disabled; discarded the stashed snapshot' };
964
1019
  }
965
- const leftovers = [];
966
- let moved = 0;
967
- for (const src of stashFiles(stash)) {
968
- const rel = path.relative(stash, src);
969
- const dst = path.join(root, rel);
970
- if (existsSync(dst)) {
971
- // NEVER delete the stash while anything is unrestored — skip, keep, report.
972
- leftovers.push(asPosix(rel));
973
- continue;
974
- }
975
- mkdirSync(path.dirname(dst), { recursive: true });
976
- renameSync(src, dst);
977
- moved += 1;
978
- }
1020
+ const { leftovers, moved } = restoreAll(stash, root);
979
1021
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
980
1022
  const kind = installKind(root) || 'global';
981
1023
  installReaddEntry(target, installAgentEntry(root, kind));
@@ -1013,9 +1055,27 @@ function cleanHostStash(cfg, host = 'claude') {
1013
1055
  * file and swallows its own OSError, exactly as the reference does — a failed bookkeeping
1014
1056
  * write must not fail the reactivate that has already moved every file back.
1015
1057
  */
1016
- function remergeClaudeHooks(cfg, root = cfg) {
1058
+ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1017
1059
  const data = claudeReadManifest(cfg);
1018
1060
  const managed = managedOf(data);
1061
+ // A Copilot install: its own merge, its own claim key, and none of the pack/exclude axes
1062
+ // below — the Copilot gate is not pack-conditional (see `copilotHooks`). Without this
1063
+ // branch a Copilot reactivate would write CLAUDE-shaped hook groups into
1064
+ // ~/.copilot/settings.json, which Copilot cannot read.
1065
+ if (Array.isArray(managed.copilot_hooks)) {
1066
+ const [, claims] = mergeCopilotSettings(settingsFile(cfg, managed), managed.copilot_hooks,
1067
+ hookRunnerEntry());
1068
+ if (!deepEquals(claims, managed.copilot_hooks) && Object.keys(data).length > 0) {
1069
+ managed.copilot_hooks = claims;
1070
+ data.managed = managed;
1071
+ const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
1072
+ try {
1073
+ writeText(tmp, `${jsonDumpsIndent(data)}\n`);
1074
+ renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
1075
+ } catch { /* except OSError: pass */ }
1076
+ }
1077
+ return;
1078
+ }
1019
1079
  // `hookOpts` is the one argument `mergeClaudeSettings` will not default, and it is right
1020
1080
  // not to: there is no computable fallback for the runner/entry the shim bakes. The
1021
1081
  // emitter's own originator is imported rather than restated — a reactivate that wired a
@@ -1036,8 +1096,9 @@ function remergeClaudeHooks(cfg, root = cfg) {
1036
1096
  // NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
1037
1097
  const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1038
1098
  : excludedRulesOfDir(cfg);
1099
+ // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1039
1100
  const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1040
- managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded);
1101
+ managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host);
1041
1102
  // `and data` — a manifest that did not parse is not one to write back.
1042
1103
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1043
1104
  managed.settings_hooks = claims;
@@ -1065,27 +1126,18 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1065
1126
  const rroot = resolvePath(cfg);
1066
1127
  const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
1067
1128
  && within(resolvePath(path.resolve(rroot, r)), rroot));
1068
- const done = [];
1069
- for (const rel of rels) {
1070
- const src = path.join(cfg, rel);
1071
- if (!existsSync(src)) continue;
1072
- try {
1073
- moveTree(src, path.join(stash, rel));
1074
- done.push(rel);
1075
- } catch (e) {
1076
- if (!isOsError(e)) throw e;
1077
- for (const r of [...done].reverse()) {
1078
- try { renameSync(path.join(stash, r), path.join(cfg, r)); } catch { /* OSError */ }
1079
- }
1080
- cleanHostStash(cfg, host);
1081
- return { ok: false, failed: [`${rel} (${e.message})`], rolled_back: done.length };
1082
- }
1129
+ const { done, failed } = moveAll(cfg, stash, rels);
1130
+ if (failed) {
1131
+ cleanHostStash(cfg, host);
1132
+ return { ok: false, failed, rolled_back: done.length };
1083
1133
  }
1084
1134
  unwireClaudeSettings(settingsFile(cfg, managed), managed.settings_hooks || []);
1135
+ unwireCopilotSettings(settingsFile(cfg, managed), managed.copilot_hooks || []);
1085
1136
  unwireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1086
1137
  // The same integrity check the uninstall path runs: a deactivate that silently failed to
1087
1138
  // unwire would leave hooks firing in a repo the user believes is off.
1088
1139
  settingsIntegrityCheck(settingsFile(cfg, managed), managed, 'absent');
1140
+ copilotIntegrityCheck(settingsFile(cfg, managed), managed.copilot_hooks || [], 'absent');
1089
1141
  const cm = claudeMdPath(cfg, managed);
1090
1142
  const block = managedBlockRead(cm);
1091
1143
  mkdirSync(stash, { recursive: true }); // presence == disabled, even if nothing moved
@@ -1122,25 +1174,12 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1122
1174
  && isFile(path.join(cfg, 'rules', 'geneseed.md'));
1123
1175
  }
1124
1176
  if (relive) {
1125
- remergeClaudeHooks(cfg, root); // ensure the hooks are present (and the claims exact)
1177
+ remergeClaudeHooks(cfg, root, host); // ensure the hooks are present (and the claims exact)
1126
1178
  cleanHostStash(cfg, host);
1127
1179
  return { ok: true,
1128
1180
  note: 'install was re-created while disabled; discarded the stashed snapshot' };
1129
1181
  }
1130
- const leftovers = [];
1131
- let moved = 0;
1132
- for (const src of stashFiles(stash)) {
1133
- const rel = path.relative(stash, src);
1134
- if (asPosix(rel) === '_claude_md_block.txt') continue; // restored separately, below
1135
- const dst = path.join(cfg, rel);
1136
- if (existsSync(dst)) {
1137
- leftovers.push(asPosix(rel));
1138
- continue;
1139
- }
1140
- mkdirSync(path.dirname(dst), { recursive: true });
1141
- renameSync(src, dst);
1142
- moved += 1;
1143
- }
1182
+ const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
1144
1183
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1145
1184
  const managed = claudeReadManifest(cfg).managed || {};
1146
1185
  // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
@@ -1153,7 +1192,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1153
1192
  if (existsSync(blockFile)) {
1154
1193
  managedBlockWrite(claudeMdPath(cfg, managed), readText(blockFile));
1155
1194
  }
1156
- remergeClaudeHooks(cfg, root);
1195
+ remergeClaudeHooks(cfg, root, host);
1157
1196
  wireClaudeExcludes(settingsFile(cfg, managed), managed.settings_excludes || []);
1158
1197
  cleanHostStash(cfg, host);
1159
1198
  return { ok: true, kind: host, moved };
@@ -33,8 +33,8 @@
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
36
- * launchers' self-heal entry. `NOT_PORTED_ACTIONS` in `js/web/jobs.mjs` is empty now — every
37
- * action row crosses. `parseOrigin` is ported in full here even though only `upgrade`
36
+ * launchers' self-heal entry. Every web action row crosses — see `js/web/jobs.mjs`.
37
+ * `parseOrigin` is ported in full here even though only `upgrade`
38
38
  * needs its fallback, because P8c's docs `about` kind needs the slug half and splitting one
39
39
  * pure function across two phases is how a second classifier gets written.
40
40
  */
@@ -383,7 +383,7 @@ export async function measureUpstream(log = null) {
383
383
  return ['ready', behind, ''];
384
384
  }
385
385
 
386
- export const DOCTOR_LEGEND = [
386
+ const DOCTOR_LEGEND = [
387
387
  '[geneseed] doctor problem legend — what the lines above mean / how to fix:',
388
388
  " • 'dead link' → a skill/agent body links a sibling as <dir>/<name>.md; use the BARE <name>.md (source bug)",
389
389
  " • 'unresolved token' → a {{TOKEN}} is missing from a theme; add it to ALL theme JSONs",
@@ -600,7 +600,7 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
600
600
  * emit-marker rebuild only covers THIS checkout's own bundle; without this pass a
601
601
  * claude-global or bob install keeps serving the OLD render after every upgrade, silently.
602
602
  */
603
- export async function rebuildInstalls(here, log) {
603
+ async function rebuildInstalls(here, log) {
604
604
  log('[geneseed] refreshing every active install (rebuild-all) ...');
605
605
  await drainStdout();
606
606
  const proc = spawnSync(process.execPath,
package/js/ui/anim.mjs CHANGED
@@ -37,7 +37,7 @@
37
37
  import { printOut } from '../lib/fs.mjs';
38
38
  import { parseIntStrict, codePointLength, padEndToWidth } from '../lib/text.mjs';
39
39
 
40
- export const ART = {
40
+ const ART = {
41
41
  imperial: {
42
42
  title: '+ ADEPTUS ASTARTES +',
43
43
  sprite: [
package/js/ui/menu.mjs CHANGED
@@ -42,7 +42,7 @@ import { which } from '../lib/paths.mjs';
42
42
  import { startDaemon } from '../web/daemon.mjs';
43
43
 
44
44
  /** `_menu_help` — the off-TTY arm, and the fallback both TTY arms end in. */
45
- export function menuHelp() {
45
+ function menuHelp() {
46
46
  printOut('Geneseed — no interactive menu here. Get started with: geneseed setup\n');
47
47
  printOut('Other commands: bootstrap · update · build · doctor · diff · tui · web\n');
48
48
  printOut('On a VT-capable terminal, a bare `./geneseed` opens the interactive menu of these.\n');
package/js/ui/tui.mjs CHANGED
@@ -83,26 +83,6 @@ export const GLYPH = glyphs(TUI_ASCII);
83
83
  // — measured, it disagrees on 897 codepoints across 229 ranges, because a spacing mark is
84
84
  // `Mc` with combining class 0 and a great many `Mn` have class 0 too. So the tables below
85
85
  // are the port's own, and no dependency was added to get them.
86
- //
87
- // A HAND-CARRIED UNICODE TABLE IS THE KIND OF THING THAT IS RIGHT UNTIL IT IS NOT, so the
88
- // gate over it is not a case list. `tests/__snapshots__/dwidth.json` holds the whole
89
- // U+0000..U+2FFFF sweep run-length encoded — 196 608 inputs, and a range this table gets wrong
90
- // has nowhere to hide — replayed by `tests/snapshot/pure_snapshot.test.mjs`. It was RECORDED
91
- // from Python's live `unicodedata` while that was still here; it cannot be re-recorded now, so
92
- // a run that goes red is a change in these tables and never a change in the reference.
93
- //
94
- // THE VERSION IS DECLARED, not left in a comment, and that changed on the day the gate fired
95
- // for real. `python3.14` ships unidata 16.0.0, which assigned U+0897 a combining class these
96
- // tables (built from 15.1.0) do not carry — so the sweep went red naming a codepoint, exactly
97
- // as designed, on a machine whose only difference was the interpreter. That is not a defect
98
- // in either implementation and it must not read as one: the sweep is a comparison against a
99
- // SPECIFIC Unicode version, and a comparison is meaningless against a different one. So the
100
- // version is a constant, `tests/__snapshots__/dwidth.json` records the version its runs were
101
- // measured at, and `tests/snapshot/pure_snapshot.test.mjs` asserts the two still agree —
102
- // reading the constant both by import and by regex out of this file, so the declaration
103
- // cannot be satisfied by deleting it. There is no interpreter left to pin: CI now FAILS if
104
- // python is findable at all.
105
- export const DWIDTH_UNIDATA = '15.1.0';
106
86
 
107
87
  //: East_Asian_Width W or F, below U+1F000 — above it the reference's own `>= 0x1F000` rule
108
88
  //: already answers 2, so the table stops where that rule takes over.
@@ -458,6 +438,9 @@ export function tuiEntries(inv) {
458
438
  * an empty state. `_TUI_INV_EMPTY` in `tests/test_pure_function_parity.py` is the corpus
459
439
  * entry that surfaced it. **P7c must not reproduce the crash when it ports `_tui_loop`** —
460
440
  * the fix is one `or []` at the call site, and it belongs in the same change as the port.
441
+ *
442
+ * Its only consumer with direct assertions is `tests/unit/setup.test.mjs`; tests/fixtures/pure_probe.mjs
443
+ * also dispatches it to exercise wizard/web_first test scenarios.
461
444
  */
462
445
  export function detailLines(kind, label, data) {
463
446
  // The three constitutional tiers share one treatment — the label, a blank, then the body —