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.
- package/CHANGELOG.md +180 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +127 -88
- package/js/maintain/update.mjs +4 -4
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
- 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
|
|
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 (
|
|
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
|
-
|
|
139
|
-
const b2j = chainB(b
|
|
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
|
-
|
|
163
|
-
let codes = opcodes(a, b
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
}
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -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,
|
|
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
|
package/js/maintain/migrate.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
63
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
297
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
695
|
-
+ `${hostSpec(host).agentFile} managed block
|
|
696
|
-
+ 'settings.json
|
|
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
|
-
|
|
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
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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 };
|
package/js/maintain/update.mjs
CHANGED
|
@@ -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.
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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 —
|