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.
- package/CHANGELOG.md +39 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-learn.js +10 -1
- package/bin/geneseed-cli.mjs +10 -14
- package/bin/geneseed-hook.mjs +22 -15
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +19 -10
- package/js/build/args.mjs +374 -0
- package/js/build/driver.mjs +137 -553
- package/js/build/emit-claude.mjs +2 -4
- package/js/build/generate.mjs +6 -3
- package/js/build/render.mjs +5 -9
- package/js/build/source.mjs +3 -11
- package/js/hosts/hooks-context.mjs +431 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +12 -4
- package/js/hosts/installs.mjs +5 -6
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +181 -0
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +373 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +1 -1
- package/js/inspect/validate.mjs +48 -35
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +1 -1
- package/js/maintain/migrate.mjs +18 -30
- package/js/maintain/uninstall.mjs +47 -88
- package/js/ui/cli.mjs +12 -12
- package/js/web/actions.mjs +29 -19
- package/js/web/api.mjs +21 -21
- package/js/web/docs.mjs +29 -72
- package/package.json +1 -1
- package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
- package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
- package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-6KDjX8da.js +2 -0
- package/web/dist/assets/Installs-MWuhNjSm.js +2 -0
- package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
- package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
- package/web/dist/assets/Library-DOkO0TtB.js +1 -0
- package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView-BPP1dtLz.js +2 -0
- package/web/dist/assets/Personal-26VPYkK0.js +1 -0
- package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/{drawable-C_x5xncq.js → drawable-zVVDbsyw.js} +1 -1
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-KHt3wE_K.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +4 -5
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/loop/registry.mjs
CHANGED
|
@@ -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` + `
|
|
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 `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
|
|
47
47
|
export const LOOP_FILE = 'LOOP.md';
|
|
48
|
-
|
|
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
|
-
/**
|
|
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
|
|
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`
|
|
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
|
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -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/
|
|
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
|
|
package/js/maintain/migrate.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (!
|
|
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
|
-
// `
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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
|
|
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 '../
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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;
|