geneseed 3.13.0 → 3.14.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 (93) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/SHIPPED.md +1 -1
  3. package/adapters/opencode/plugins/geneseed-learn.js +10 -1
  4. package/bin/geneseed-cli.mjs +10 -14
  5. package/bin/geneseed-hook.mjs +22 -15
  6. package/docs/extending.md +5 -5
  7. package/harness.config.json +1 -1
  8. package/js/README.md +19 -10
  9. package/js/build/args.mjs +374 -0
  10. package/js/build/driver.mjs +137 -553
  11. package/js/build/emit-claude.mjs +2 -4
  12. package/js/build/generate.mjs +6 -3
  13. package/js/build/render.mjs +5 -9
  14. package/js/build/source.mjs +3 -11
  15. package/js/hosts/hooks-context.mjs +431 -0
  16. package/js/hosts/hooks-learn.mjs +275 -0
  17. package/js/hosts/hooks-prims.mjs +108 -0
  18. package/js/hosts/hooks.mjs +50 -977
  19. package/js/hosts/hosts.mjs +12 -4
  20. package/js/hosts/installs.mjs +5 -6
  21. package/js/hosts/mcp.mjs +12 -48
  22. package/js/hosts/memory-files.mjs +181 -0
  23. package/js/hosts/settings.mjs +135 -414
  24. package/js/hosts/shim.mjs +373 -0
  25. package/js/inspect/checks-authoring.mjs +21 -828
  26. package/js/inspect/checks-build.mjs +4 -4
  27. package/js/inspect/checks-constitution.mjs +493 -0
  28. package/js/inspect/checks-counts.mjs +242 -0
  29. package/js/inspect/checks-repo.mjs +17 -8
  30. package/js/inspect/diff.mjs +4 -1
  31. package/js/inspect/doctor.mjs +2 -2
  32. package/js/inspect/inventory.mjs +4 -4
  33. package/js/inspect/scan.mjs +1 -1
  34. package/js/inspect/status.mjs +1 -1
  35. package/js/inspect/validate.mjs +48 -35
  36. package/js/loop/catalog.mjs +5 -3
  37. package/js/loop/cli.mjs +26 -12
  38. package/js/loop/graph.mjs +1 -1
  39. package/js/loop/registry.mjs +10 -15
  40. package/js/loop/score.mjs +1 -1
  41. package/js/loop/state.mjs +35 -11
  42. package/js/maintain/memory.mjs +1 -1
  43. package/js/maintain/migrate.mjs +18 -30
  44. package/js/maintain/uninstall.mjs +47 -88
  45. package/js/ui/cli.mjs +12 -12
  46. package/js/web/actions.mjs +29 -19
  47. package/js/web/api.mjs +21 -21
  48. package/js/web/docs.mjs +29 -72
  49. package/package.json +1 -1
  50. package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
  51. package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
  52. package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
  53. package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
  54. package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
  55. package/web/dist/assets/GreenhouseView-6KDjX8da.js +2 -0
  56. package/web/dist/assets/Installs-MWuhNjSm.js +2 -0
  57. package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
  58. package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
  59. package/web/dist/assets/Library-DOkO0TtB.js +1 -0
  60. package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
  61. package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
  62. package/web/dist/assets/OperatorHudView-BPP1dtLz.js +2 -0
  63. package/web/dist/assets/Personal-26VPYkK0.js +1 -0
  64. package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
  65. package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
  66. package/web/dist/assets/{drawable-C_x5xncq.js → drawable-zVVDbsyw.js} +1 -1
  67. package/web/dist/assets/index-Ctp-4poP.css +1 -0
  68. package/web/dist/assets/index-KHt3wE_K.js +2 -0
  69. package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
  70. package/web/dist/assets/sections-GalMS0Zv.js +1 -0
  71. package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
  72. package/web/dist/index.html +4 -5
  73. package/web/src/pages/Laws.jsx +0 -1
  74. package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
  75. package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
  76. package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
  77. package/web/dist/assets/Docs-BzccCllG.js +0 -5
  78. package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
  79. package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
  80. package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
  81. package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
  82. package/web/dist/assets/Laws-BANxq_I_.js +0 -3
  83. package/web/dist/assets/Library-DW4x0fbh.js +0 -1
  84. package/web/dist/assets/Loops-DEBibbBP.js +0 -2
  85. package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
  86. package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
  87. package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
  88. package/web/dist/assets/activity-CObqyxkL.js +0 -1
  89. package/web/dist/assets/index-CMPgezFm.js +0 -2
  90. package/web/dist/assets/index-DMsT4Aa6.css +0 -1
  91. package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  92. package/web/dist/assets/sections-Dg23Hel5.js +0 -1
  93. package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
@@ -7,7 +7,8 @@
7
7
  * WRITER CONVENTIONS, mirrored from `js/inspect/registry.mjs` (`installs.json`'s own registry)
8
8
  * on purpose rather than invented fresh: `jsonDumpsIndent` because these rows are absolute
9
9
  * paths and a username carrying an accent writes one here (ensure_ascii matters); a
10
- * `<file>.tmp` + `renameSync` so a crash mid-write cannot leave `loops.json` half-written where
10
+ * `<file>.tmp` + `renameWithRetry` (LOOP.md's own atomic rename, retried on a transient Windows
11
+ * lock) so a crash mid-write cannot leave `loops.json` half-written where
11
12
  * the NEXT read would see valid-but-truncated JSON instead of either the old file or the new
12
13
  * one; and every public function swallows its own errors — a registry hiccup must never fail
13
14
  * `loop init`, and a corrupt `loops.json` must read back as empty rather than throw. The one
@@ -28,20 +29,20 @@
28
29
  * re-`init`ed) clears it, so a stale finished-window never survives into a loop that is running
29
30
  * again.
30
31
  *
31
- * `recordLoop` and `activeLoops`/`setLoopPreset` never call each other's normalisation by
32
- * accident: both resolve a root through `realpathSync.native` (falling back to `path.resolve`
33
- * for a root that no longer exists) and compare with `normcase` — the same case-folding
34
- * `js/lib/paths.mjs` uses everywhere a Windows path is compared — so a loop launched from a
35
- * differently-cased spelling of the same worktree is still one row, not two.
32
+ * `recordLoop` and `setLoopPreset` resolve the root they are given the same way — `normalize`:
33
+ * `realpathSync.native`, falling back to `path.resolve` for a root that no longer exists — and
34
+ * compare it with `normcase`, the same case-folding `js/lib/paths.mjs` uses everywhere a Windows
35
+ * path is compared, so a loop launched from a differently-cased spelling of the same worktree is
36
+ * still one row, not two. `activeLoops` compares nothing: it reads each row's root as stored.
36
37
  */
37
- import { readFileSync, mkdirSync, realpathSync, renameSync } from 'node:fs';
38
+ import { readFileSync, mkdirSync, realpathSync } from 'node:fs';
38
39
  import path from 'node:path';
39
40
 
40
41
  import { userLoopsDir } from './catalog.mjs';
41
42
  import { writeText, readText, isDir, isFile } from '../lib/fs.mjs';
42
43
  import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
43
44
  import { normcase } from '../lib/paths.mjs';
44
- import { parseLoopFile, writeLoopFile, LOOP_FILE } from './state.mjs';
45
+ import { parseLoopFile, writeLoopFile, renameWithRetry, reviewOf, LOOP_FILE } from './state.mjs';
45
46
  import { PRESETS } from './score.mjs';
46
47
 
47
48
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
@@ -65,7 +66,7 @@ function save(loops) {
65
66
  mkdirSync(path.dirname(file), { recursive: true });
66
67
  const tmp = `${file}.tmp`;
67
68
  writeText(tmp, `${jsonDumpsIndent({ loops })}\n`);
68
- renameSync(tmp, file);
69
+ renameWithRetry(tmp, file);
69
70
  } catch {
70
71
  /* best-effort: a registry hiccup must never fail init or a read */
71
72
  }
@@ -77,12 +78,6 @@ function normalize(dir) {
77
78
 
78
79
  const sameRoot = (a, b) => normcase(a) === normcase(b);
79
80
 
80
- /** The soft-decision iterations of `state.history` — the same shape `state.mjs`'s own
81
- * end-of-run `summary()` reports, so a card and a finished-loop review read the same fields. */
82
- function reviewOf(state) {
83
- return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
84
- }
85
-
86
81
  function runningRow(entry, state) {
87
82
  return {
88
83
  root: entry.root, branch: entry.branch, title: entry.title, started: entry.started,
package/js/loop/score.mjs CHANGED
@@ -29,7 +29,7 @@ export const PRESETS = Object.freeze({
29
29
  export const DEFAULT_PRESET = 'balanced';
30
30
 
31
31
  /** More deleted lines than this in one iteration counts as a code deletion. */
32
- export const DELETION_LINES = 20;
32
+ const DELETION_LINES = 20;
33
33
 
34
34
  const RANK = { silent: 0, soft: 1, blocking: 2 };
35
35
 
package/js/loop/state.mjs CHANGED
@@ -43,9 +43,9 @@ import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
43
43
 
44
44
  const BEGIN = '<!-- loop-state:begin -->';
45
45
  const END = '<!-- loop-state:end -->';
46
- export const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
46
+ const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
47
47
  export const LOOP_FILE = 'LOOP.md';
48
- export const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
48
+ const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
49
49
  const slash = (p) => String(p).replaceAll('\\', '/');
50
50
 
51
51
  export function renderLoopFile(state) {
@@ -101,7 +101,11 @@ export function parseLoopFile(text) {
101
101
  const head = (state) => iterationLoop(state.graph).nodes[0];
102
102
  const fmt = (n) => Number(n).toFixed(1);
103
103
 
104
- /** `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. */
104
+ /**
105
+ * `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. Every
106
+ * field the engine ever writes is listed here, `gateAmends` included (`decideGate` still reads
107
+ * it with `?.`, for a LOOP.md written before it was listed).
108
+ */
105
109
  export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
106
110
  if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
107
111
  const loop = iterationLoop(graph);
@@ -111,7 +115,7 @@ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET,
111
115
  iteration: loop.nodes.includes(graph.start) ? 1 : 0,
112
116
  card: null, validated: false, pendingVerify: false, closing: false, counters: {},
113
117
  resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
114
- notes: [], history: [],
118
+ notes: [], history: [], gateAmends: {},
115
119
  };
116
120
  if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
117
121
  return state;
@@ -127,12 +131,14 @@ function resetUnit(state) {
127
131
  state.current = {}; state.tests = 'n/a';
128
132
  }
129
133
 
134
+ /** The soft-decision iterations of `state.history` — what the end-of-run summary and the
135
+ * registry's Active-tab row both call `review`, so the two read the same fields. */
136
+ export function reviewOf(state) {
137
+ return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
138
+ }
139
+
130
140
  function summary(state) {
131
- return {
132
- iterations: state.history.length,
133
- review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
134
- notes: state.notes,
135
- };
141
+ return { iterations: state.history.length, review: reviewOf(state), notes: state.notes };
136
142
  }
137
143
 
138
144
  export function trailers(state) {
@@ -296,13 +302,31 @@ function filterPorcelain(porcelain) {
296
302
 
297
303
  // Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
298
304
  // it has nowhere to retry back into, so it stops the loop instead of looping to the head.
305
+ // Iteration 0 alone says so: `initState` starts at 0 only when graph.start is outside the
306
+ // iteration loop, and `iteration` only ever counts up from there.
299
307
  function isSetupUnit(state) {
300
- return state.iteration === 0 && !iterationLoop(state.graph).nodes.includes(state.graph.start);
308
+ return state.iteration === 0;
309
+ }
310
+
311
+ const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
312
+
313
+ // A card arrives from the model as JSON (`record --card`), so its shape is checked before
314
+ // anything is stored: a string `writeSet` stored here only throws later, inside `score --diff`,
315
+ // with `pendingVerify` already set — and `score --declared` refuses while that is set, so
316
+ // nothing could get the loop moving again.
317
+ function cardProblem(card) {
318
+ if (typeof card !== 'object' || Array.isArray(card)) return 'the card must be a JSON object';
319
+ if (!isStringList(card.actions)) return "the card's actions must be a list of strings";
320
+ if (!isStringList(card.writeSet)) return "the card's writeSet must be a list of strings";
321
+ if (card.intent != null && typeof card.intent !== 'string') return "the card's intent must be a string";
322
+ return null;
301
323
  }
302
324
 
303
325
  export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
304
326
  assertRunning(state);
305
327
  if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
328
+ const problem = card === null ? null : cardProblem(card);
329
+ if (problem) throw new Error(problem);
306
330
  const from = state.node;
307
331
  const brick = bricks.get(from);
308
332
  if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
@@ -323,7 +347,7 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
323
347
  // `fail` findings) — pushed before the transition below, so it lands in `notes` even when
324
348
  // this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
325
349
  // (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
326
- // `resetUnit`/`finishUnit` the same way the research's fact 1 says a card does not.
350
+ // `resetUnit`/`finishUnit` — which a card does not: `resetUnit` clears it.
327
351
  if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
328
352
  state.visited.push(from);
329
353
 
@@ -30,7 +30,7 @@
30
30
  import { readdirSync, unlinkSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
 
33
- import { memoryDropIndex } from '../hosts/hooks.mjs';
33
+ import { memoryDropIndex } from '../hosts/memory-files.mjs';
34
34
  import { resolveMemoryDir } from '../hosts/hosts.mjs';
35
35
  import { printOut, printErr, isFile } from '../lib/fs.mjs';
36
36
 
@@ -35,17 +35,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:
35
35
  import path from 'node:path';
36
36
 
37
37
  import { main as driverMain } from '../build/driver.mjs';
38
- import { PACK_ORDER, ROOT } from '../build/source.mjs';
38
+ import { ROOT } from '../build/source.mjs';
39
+ import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
40
+ import { EMIT_HOST_SCOPE, installState, installTargets, readMaybe } from '../hosts/installs.mjs';
41
+ import { installProfile } from '../build/generate.mjs';
39
42
  import {
40
- EMIT_HOST_SCOPE, defaultMode, defaultPosture, defaultTheme, doctrinesForBuild,
41
- footprintOfDir, installState, installTargets, modeOfDir, postureOfDir, readMaybe, themeOfDir,
42
- trustOfDir,
43
- } from '../hosts/installs.mjs';
44
- import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
45
- import { DEFAULT_PRESET } from '../loop/score.mjs';
46
- import {
47
- autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
48
- } from '../hosts/settings.mjs';
43
+ autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
44
+ } from '../hosts/shim.mjs';
45
+ import { loadJsonObject } from '../hosts/settings.mjs';
49
46
  import { writeText, printOut, printErr } from '../lib/fs.mjs';
50
47
 
51
48
  /** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
@@ -61,21 +58,20 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
61
58
  * host-parity rule asks for.
62
59
  */
63
60
  function hookSettingsFile(root, host, scope) {
64
- if (!['claude', 'bob', 'openclaude'].includes(host)) return null;
61
+ if (!CLAUDE_STYLE.includes(host)) return null;
65
62
  const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
66
63
  return path.join(root, name);
67
64
  }
68
65
 
69
66
  /** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
70
67
  function hookCommandsOf(file) {
71
- // `readJsonc`, NOT `JSON.parse`. A hand-edited settings.json with comments and a trailing
72
- // comma is the one shape no machine in this project writes, and strict JSON rejects it —
68
+ // `loadJsonObject` (JSONC), NOT `JSON.parse`. A hand-edited settings.json with comments and a
69
+ // trailing comma is the one shape no machine in this project writes, and strict JSON rejects it —
73
70
  // which would classify a wired install as `none` and make `migrate` skip the machine
74
71
  // silently. `mergeClaudeSettings` already reads through comments for exactly this reason.
75
72
  // Found by the JSONC corpus fixture; a generated fixture would have hidden it.
76
- let loaded;
77
- try { [loaded] = readJsonc(readFileSync(file, 'utf8')); } catch { return []; }
78
- if (!loaded || typeof loaded !== 'object') return [];
73
+ const { state, data: loaded } = loadJsonObject(file);
74
+ if (state !== 'ok') return [];
79
75
  const hooks = loaded.hooks;
80
76
  if (!hooks || typeof hooks !== 'object') return [];
81
77
  const out = [];
@@ -210,21 +206,13 @@ export function cmdMigrate(args = {}) {
210
206
  try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
211
207
  };
212
208
 
213
- // Step 3 — re-emit, in each install's OWN six values. Same reads as `cmdRebuildAll`, and
214
- // the pack selection is the sixth: leaving it out re-emitted a narrowed install at whatever
215
- // `harness.config.json` said, which WIDENS a constitution its owner had cut down (and, the
216
- // other way, can strip the consent gate). `null` — a pre-marker carrier, which on a MIGRATION
217
- // is the common case and not the rare one — resolves to ALL packs through
218
- // `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
209
+ // Step 3 — re-emit, in each install's OWN values: `installProfile`, the one reading
210
+ // `rebuild-all` and `status` share. This loop hand-copied it once and drifted twice — it
211
+ // passed the excluded rules as `null` (re-admitting every rule an owner switched off,
212
+ // `process 5` among them) and dropped `--config-dir`, so a global found through
213
+ // `CLAUDE_CONFIG_DIR`/`OPENCODE_CONFIG_DIR` re-emitted into the DEFAULT dir instead.
219
214
  for (const r of rows) {
220
- const marker = r.marker && (EMIT_HOST_SCOPE.get(r.marker) ?? ['', ''])[0] === r.host
221
- ? r.marker : '';
222
- const emit = marker || DEFAULT_EMIT.get(`${r.host} ${r.scope}`) || 'opencode-global';
223
- const theme = themeOfDir(r.root) || defaultTheme();
224
- const out = r.scope === 'global' ? null : r.root;
225
- const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
226
- postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
227
- doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
215
+ const { emit, theme, argv } = installProfile(r.host, r.scope, r.root);
228
216
  const label = `${r.host}:${r.scope} (${r.root})`;
229
217
  printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
230
218
  let rc = 1;
@@ -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';
50
+ import { hookRunnerEntry } from '../hosts/shim.mjs';
51
51
  import { confirm } from './setup.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
 
@@ -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');
@@ -810,17 +791,7 @@ function installRelive(root) {
810
791
  return isFile(path.join(root, 'AGENT.md'));
811
792
  }
812
793
 
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
- */
794
+ /** `_harness_mcp._install_move_list` — the rels to move aside, `ownedWithin`-guarded. */
824
795
  function installMoveList(root, kind) {
825
796
  let rels;
826
797
  if (kind === 'project') {
@@ -828,8 +799,7 @@ function installMoveList(root, kind) {
828
799
  } else {
829
800
  rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
830
801
  }
831
- const rroot = resolvePath(root);
832
- return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
802
+ return ownedWithin(root, rels);
833
803
  }
834
804
 
835
805
  /**
@@ -844,16 +814,11 @@ function installMoveList(root, kind) {
844
814
  */
845
815
  function installReaddEntry(target, entry) {
846
816
  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`);
817
+ atomicWriteJson(target, { $schema: 'https://opencode.ai/config.json', instructions: [entry] });
851
818
  return true;
852
819
  }
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;
820
+ const { state, data: cfg, hadComments } = loadJsonObject(target);
821
+ if (state !== 'ok') return false; // unreadable (except OSError), or not an object
857
822
  const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
858
823
  if (indexOfDeepEqual(instr, entry) >= 0) return false;
859
824
  if (path.extname(target) === '.jsonc' && hadComments) {
@@ -862,7 +827,7 @@ function installReaddEntry(target, entry) {
862
827
  return false;
863
828
  }
864
829
  cfg.instructions = [...instr, entry];
865
- writeText(target, `${jsonDumpsIndent(cfg)}\n`);
830
+ atomicWriteJson(target, cfg);
866
831
  return true;
867
832
  }
868
833
 
@@ -1067,16 +1032,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
1067
1032
  const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
1068
1033
  : excludedRulesOfDir(cfg);
1069
1034
  // `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
1070
- const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed), 'global',
1035
+ const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
1071
1036
  managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
1072
1037
  // `and data` — a manifest that did not parse is not one to write back.
1073
1038
  if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
1074
1039
  managed.settings_hooks = claims;
1075
1040
  data.managed = managed;
1076
- const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
1077
1041
  try {
1078
- writeText(tmp, `${jsonDumpsIndent(data)}\n`);
1079
- renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
1042
+ atomicWriteJson(path.join(cfg, GLOBAL_MANIFEST), data);
1080
1043
  } catch { /* except OSError: pass */ }
1081
1044
  }
1082
1045
  }
@@ -1093,9 +1056,7 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
1093
1056
  const man = claudeReadManifest(cfg);
1094
1057
  const managed = managedOf(man);
1095
1058
  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));
1059
+ const rels = ownedWithin(cfg, ownedOf(man).filter((r) => r !== VERSION_MARKER));
1099
1060
  const { done, failed } = moveAll(cfg, stash, rels);
1100
1061
  if (failed) {
1101
1062
  cleanHostStash(cfg, host);
@@ -1129,9 +1090,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1129
1090
  const stash = path.join(cfg, DISABLED_STASH, host);
1130
1091
  const blockFile = path.join(stash, '_claude_md_block.txt');
1131
1092
  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 || {};
1093
+ const reliveManaged = managedOf(reliveManifest);
1135
1094
  let relive = managedBlockRead(claudeMdPath(cfg, reliveManaged)) !== null;
1136
1095
  // Bob GLOBAL writes no managed AGENTS.md block — its preamble carrier is the owned
1137
1096
  // `rules/geneseed.md`, so a live copy of THAT is the relive signal there. Without this arm
@@ -1149,7 +1108,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
1149
1108
  }
1150
1109
  const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
1151
1110
  if (leftovers.length) return { ok: false, failed: leftovers, moved };
1152
- const managed = claudeReadManifest(cfg).managed || {};
1111
+ const managed = managedOf(claudeReadManifest(cfg));
1153
1112
  // ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
1154
1113
  // `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
1155
1114
  // Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
package/js/ui/cli.mjs CHANGED
@@ -429,18 +429,6 @@ export function cliCommand(verb) {
429
429
  return (load().commands ?? []).find((c) => c.name === verb) ?? null;
430
430
  }
431
431
 
432
- /**
433
- * `<verb> --help` for whichever binary asked, or `null` for a verb the table does not
434
- * describe — the caller already owns that refusal and its message.
435
- *
436
- * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
437
- * `bin/geneseed-cli.mjs` the rest of the table, and
438
- * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
439
- * tables — so the help each prints has to come from here rather than from a copy in each.
440
- *
441
- * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
442
- * translates, and this text lands in the same terminals and the same pipes as that one.
443
- */
444
432
  /**
445
433
  * `geneseed --help` — every verb with its one-line help, from the same table the per-verb help
446
434
  * reads. `verbs` is the entry's own dispatch list, so the listing can name nothing the entry
@@ -460,6 +448,18 @@ export function printVerbList(prog, verbs, extra = {}) {
460
448
  return 0;
461
449
  }
462
450
 
451
+ /**
452
+ * `<verb> --help` for whichever binary asked, or `null` for a verb the table does not
453
+ * describe — the caller already owns that refusal and its message.
454
+ *
455
+ * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
456
+ * `bin/geneseed-cli.mjs` the rest of the table, and
457
+ * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
458
+ * tables — so the help each prints has to come from here rather than from a copy in each.
459
+ *
460
+ * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
461
+ * translates, and this text lands in the same terminals and the same pipes as that one.
462
+ */
463
463
  export function printHelp(prog, verb) {
464
464
  const cmd = cliCommand(verb);
465
465
  if (cmd === null) return null;