geneseed 3.20.0 → 4.1.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 +71 -0
- package/README.md +9 -1
- package/SHIPPED.md +1 -1
- package/adapters/bob/README.md +31 -12
- package/adapters/claude-code/README.md +24 -17
- package/adapters/claude-code/settings.json +4 -6
- package/adapters/openclaude/README.md +13 -5
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +35 -0
- package/adapters/opencode/plugins/geneseed-activity.js +105 -2
- package/adapters/opencode/plugins/geneseed-context.js +96 -1
- package/adapters/opencode/plugins/geneseed-guard.js +324 -175
- package/adapters/opencode/plugins/geneseed-learn.js +127 -14
- package/adapters/opencode/plugins/geneseed-notify.js +46 -1
- package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
- package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
- package/bin/geneseed-hook.mjs +36 -5
- package/docs/README.md +3 -1
- package/docs/candidate-hosts.md +0 -8
- package/docs/concepts/enforcement-table.md +66 -0
- package/docs/concepts/hook-gates.md +2 -2
- package/docs/concepts/hooks.md +6 -6
- package/docs/concepts/host-matrix.md +4 -3
- package/docs/concepts/loop-branch.md +6 -1
- package/docs/concepts/rules.md +1 -1
- package/docs/design-history.md +59 -0
- package/docs/extending.md +11 -3
- package/docs/guides/install-opencode.md +1 -1
- package/docs/guides/loop-running.md +1 -1
- package/docs/guides/mcp-filesystem.md +2 -2
- package/docs/guides/mcp-gitlab.md +2 -2
- package/docs/guides/mcp-markitdown.md +3 -3
- package/docs/guides/mcp.md +7 -7
- package/docs/guides/project-context.md +1 -1
- package/docs/reference/env-context.md +3 -2
- package/docs/reference/environment.md +2 -1
- package/docs/reference/opencode-plugins.md +9 -0
- package/docs/reference/plugin-guard.md +3 -3
- package/docs/reference/troubleshoot-hooks.md +34 -0
- package/docs/understand/design-choices-engineering.md +29 -0
- package/docs/understand/design-choices-gates.md +23 -0
- package/docs/understand/design-choices-hosts.md +21 -0
- package/docs/understand/design-choices-install.md +19 -0
- package/docs/understand/design-choices-shape.md +21 -0
- package/docs/understand/design-choices.md +34 -0
- package/docs/understand/enforced-vs-asked.md +5 -4
- package/docs/understand/machine-bob.md +1 -1
- package/docs/understand/machine-opencode.md +2 -2
- package/harness.config.json +1 -1
- package/js/build/driver.mjs +42 -16
- package/js/build/emit-claude.mjs +24 -8
- package/js/build/emit-opencode.mjs +33 -15
- package/js/build/generate.mjs +38 -9
- package/js/build/source.mjs +39 -0
- package/js/cli-table.json +7 -7
- package/js/hosts/gitref.mjs +3 -3
- package/js/hosts/hooks-context.mjs +11 -30
- package/js/hosts/hooks-learn.mjs +89 -8
- package/js/hosts/hooks.mjs +212 -18
- package/js/hosts/hosts.mjs +217 -13
- package/js/hosts/installs.mjs +259 -46
- package/js/hosts/mcp.mjs +23 -152
- package/js/hosts/native.mjs +48 -12
- package/js/hosts/opencode.mjs +48 -18
- package/js/hosts/settings.mjs +366 -42
- package/js/hosts/shim.mjs +37 -0
- package/js/inspect/checks-authoring.mjs +81 -1
- package/js/inspect/checks-constitution.mjs +2 -1
- package/js/inspect/checks-repo.mjs +68 -4
- package/js/inspect/doctor.mjs +40 -9
- package/js/inspect/scan.mjs +5 -4
- package/js/inspect/status.mjs +62 -12
- package/js/lib/text.mjs +23 -0
- package/js/loop/catalog.mjs +7 -2
- package/js/maintain/migrate.mjs +42 -5
- package/js/maintain/uninstall.mjs +150 -38
- package/js/maintain/update.mjs +22 -2
- package/js/web/actions.mjs +43 -87
- package/js/web/api.mjs +13 -4
- package/js/web/handler.mjs +2 -2
- package/js/web/routes.mjs +1 -2
- package/js/web/state.mjs +28 -15
- package/package.json +1 -1
- package/src/agents/_template.md +3 -2
- package/src/agents/researcher.md +2 -1
- package/src/agents/reviewer.md +9 -4
- package/src/agents/tester.md +3 -3
- package/src/bricks/_template.md +1 -1
- package/src/doctrines/ops.md +9 -7
- package/src/doctrines/rigor.md +8 -7
- package/src/laws/universal.md +1 -1
- package/src/skills/_template.md +3 -3
- package/src/skills/brainstorm/builder-check.md +16 -0
- package/src/skills/brainstorm.md +19 -11
- package/src/skills/daydream/SKILL.md +1 -1
- package/src/skills/daydream/VENDOR.md +4 -0
- package/src/skills/debug/ci.md +4 -4
- package/src/skills/debug.md +4 -4
- package/src/skills/develop.md +6 -4
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/geneseed-code-review.md +8 -2
- package/src/skills/handoff.md +1 -1
- package/src/skills/herdr.md +1 -1
- package/src/skills/parallel-agents.md +4 -4
- package/src/skills/pipeline.md +3 -3
- package/src/skills/plan.md +18 -6
- package/src/skills/review-response.md +5 -2
- package/src/skills/ship.md +2 -1
- package/src/skills/worktree.md +2 -0
- package/web/dist/assets/{Activity-IbwdHHyx.js → Activity-CqVt2u9s.js} +1 -1
- package/web/dist/assets/{ActivityDetail-B5uivdWI.js → ActivityDetail-Bui5Gomc.js} +1 -1
- package/web/dist/assets/{Dashboard-v6WQ37bD.js → Dashboard-VlIx1-At.js} +2 -2
- package/web/dist/assets/{Docs-CypIfDBX.js → Docs-6cYYkVeA.js} +1 -1
- package/web/dist/assets/{GreenhouseView-ClxP0hQG.js → GreenhouseView-DgNY8QKU.js} +1 -1
- package/web/dist/assets/{Installs-CPkQeFjO.js → Installs-DZwZOUZJ.js} +1 -1
- package/web/dist/assets/{JournalView-BeEInhUf.js → JournalView-DXpFbKOG.js} +1 -1
- package/web/dist/assets/{Laws-Ds6U5xBQ.js → Laws-Bh7ztekk.js} +1 -1
- package/web/dist/assets/{Library-BraUKPyQ.js → Library-DBLX-1SD.js} +1 -1
- package/web/dist/assets/{Loops-DWcy3mwK.js → Loops-CvFMZ7_P.js} +1 -1
- package/web/dist/assets/{OperatorHudView-CQyrJQ9m.js → OperatorHudView-FPTUH1CB.js} +1 -1
- package/web/dist/assets/{Personal-YWqs6qFL.js → Personal-DcVA502F.js} +1 -1
- package/web/dist/assets/{Tabs-EhzEC-Xh.js → Tabs-DtyR4h75.js} +1 -1
- package/web/dist/assets/{api-CxgfYu18.js → api-CRqWld2H.js} +1 -1
- package/web/dist/assets/{drawable-CEKPNV7X.js → drawable-CNSPVZxX.js} +1 -1
- package/web/dist/assets/{index-CPi22b7y.js → index-BmjcLIOr.js} +2 -2
- package/web/dist/index.html +2 -2
|
@@ -50,18 +50,20 @@ import path from 'node:path';
|
|
|
50
50
|
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
51
51
|
import { confirm } from '../lib/prompt.mjs';
|
|
52
52
|
import {
|
|
53
|
-
claudeCfg, claudeReadManifest, doctrinesOfDir,
|
|
54
|
-
installState,
|
|
53
|
+
claudeCfg, claudeReadManifest, doctrinesOfDir, EMIT_HOST_SCOPE, emitHostScopeOf,
|
|
54
|
+
excludedRulesOfDir, installKind, installState,
|
|
55
55
|
registeredTargets, DISABLED_STASH,
|
|
56
56
|
} from '../hosts/installs.mjs';
|
|
57
57
|
import {
|
|
58
|
-
CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser,
|
|
58
|
+
CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, isHostGlobalDir, opencodeConfigDir,
|
|
59
|
+
resolvePath, settingsFile,
|
|
59
60
|
} from '../hosts/hosts.mjs';
|
|
60
61
|
import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
|
|
61
62
|
import {
|
|
62
63
|
atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
|
|
63
|
-
loadJsonObject, mergeClaudeSettings, opencodeTarget,
|
|
64
|
-
settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes,
|
|
64
|
+
loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
|
|
65
|
+
settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes,
|
|
66
|
+
unwireClaudeSettings,
|
|
65
67
|
} from '../hosts/settings.mjs';
|
|
66
68
|
import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
|
|
67
69
|
import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
|
|
@@ -212,27 +214,63 @@ function warnMarkersKept() {
|
|
|
212
214
|
const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
|
|
213
215
|
'.geneseed-footprint', VERSION_MARKER];
|
|
214
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The CURRENT manifest's `skill_denies` — the `permission.skill` denies Geneseed itself added
|
|
219
|
+
* (`opencodeLayer` in emit-opencode.mjs records them; a deny the user wrote, even on a Geneseed
|
|
220
|
+
* skill, is never in it). Task 4 (O-1) wires one per user-only skill, and unlike the Law IV
|
|
221
|
+
* `permission.bash` gates (permanent invariants, never taken back — see
|
|
222
|
+
* `reconcileOpencodePermission`), a skill-visibility deny is reversible: once uninstalled there
|
|
223
|
+
* is no skill left to hide, and the name is the user's to reuse. No record takes back nothing.
|
|
224
|
+
*/
|
|
225
|
+
function skillPermissionNames(man) {
|
|
226
|
+
return Array.isArray(man.skill_denies) ? man.skill_denies.filter((n) => typeof n === 'string') : [];
|
|
227
|
+
}
|
|
228
|
+
|
|
215
229
|
/**
|
|
216
230
|
* `_harness_mcp._unmerge_opencode_json` — drop one `instructions` entry, leave every other
|
|
217
231
|
* key intact.
|
|
218
232
|
*
|
|
219
|
-
*
|
|
233
|
+
* `denyNames`, Task 4 (O-1): the denies this install's manifest records writing
|
|
234
|
+
* (`skill_denies`) — remove `permission.skill[name]` for each one, but ONLY where the value is
|
|
235
|
+
* still exactly `"deny"`, and drop the `skill` object once it is empty. Ownership comes from the
|
|
236
|
+
* record, not the value; the value check on top guards a user who re-scoped a recorded name to
|
|
237
|
+
* their own `"allow"`/`"ask"`. A
|
|
238
|
+
* caller passing `[]` (every non-uninstall unwire — `installDeactivate`, same as
|
|
239
|
+
* `permission.bash`) leaves `permission.skill` untouched, so a paused install keeps the model
|
|
240
|
+
* blind to its skills rather than re-exposing them until the next `geneseed build`.
|
|
241
|
+
*
|
|
242
|
+
* The sweep runs even when the `instructions` entry is already gone — an uninstall after a
|
|
243
|
+
* deactivate, which removed the entry but kept the denies (PR #241 review) — so either half
|
|
244
|
+
* alone is a change, and only "neither" declines.
|
|
245
|
+
*
|
|
246
|
+
* A COMMENTED `.jsonc` is not rewritten and the user is told to do it by hand — the entry AND
|
|
247
|
+
* the recorded denies still set to `"deny"`, each named: rewriting it
|
|
220
248
|
* would drop the comments. That branch returns False, so the caller's `unmerged` reports
|
|
221
249
|
* REALITY rather than intent.
|
|
222
250
|
*/
|
|
223
|
-
export function unmergeOpencodeJson(p, entry) {
|
|
251
|
+
export function unmergeOpencodeJson(p, entry, denyNames = []) {
|
|
224
252
|
const target = opencodeTarget(p);
|
|
225
253
|
// Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
|
|
226
254
|
const { state, data: cfg, hadComments } = loadJsonObject(target);
|
|
227
255
|
if (state !== 'ok') return false;
|
|
228
256
|
const instr = cfg.instructions;
|
|
229
|
-
|
|
257
|
+
const hasEntry = Array.isArray(instr) && instr.includes(entry);
|
|
258
|
+
// The deny sweep does NOT hang off the entry: `installDeactivate` already dropped the entry
|
|
259
|
+
// (and kept the denies), so an uninstall after a deactivate finds no entry but still owns them.
|
|
260
|
+
const skill = isDict(cfg.permission) && isDict(cfg.permission.skill) ? cfg.permission.skill : null;
|
|
261
|
+
const denies = skill ? denyNames.filter((n) => skill[n] === 'deny') : [];
|
|
262
|
+
if (!hasEntry && !denies.length) return false;
|
|
230
263
|
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
231
|
-
printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it
|
|
232
|
-
+ `this from its "instructions" by hand: ${jsonDumps(entry)}
|
|
264
|
+
printOut(`[uninstall] ${path.basename(target)} has comments — not rewriting it.`
|
|
265
|
+
+ (hasEntry ? ` Remove this from its "instructions" by hand: ${jsonDumps(entry)}` : '')
|
|
266
|
+
+ (denies.length ? ` Remove these from its "permission.skill" by hand: `
|
|
267
|
+
+ `${denies.map((n) => jsonDumps(n)).join(', ')}` : '')
|
|
268
|
+
+ '\n');
|
|
233
269
|
return false;
|
|
234
270
|
}
|
|
235
|
-
cfg.instructions = instr.filter((i) => i !== entry);
|
|
271
|
+
if (hasEntry) cfg.instructions = instr.filter((i) => i !== entry);
|
|
272
|
+
for (const name of denies) delete skill[name];
|
|
273
|
+
if (skill && denies.length && !Object.keys(skill).length) delete cfg.permission.skill;
|
|
236
274
|
atomicWriteJson(target, cfg);
|
|
237
275
|
return true;
|
|
238
276
|
}
|
|
@@ -265,16 +303,9 @@ export function archiveStore(store) {
|
|
|
265
303
|
return dest;
|
|
266
304
|
}
|
|
267
305
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
* `settings.local.json` for a Claude PROJECT install (personal, untracked), `settings.json`
|
|
272
|
-
* everywhere else, and the manifest is the authority. Every lifecycle path must target the
|
|
273
|
-
* file the EMIT wrote, or the hooks linger in one file while the claims chase another.
|
|
274
|
-
*/
|
|
275
|
-
function settingsFile(cfg, managed) {
|
|
276
|
-
return path.join(cfg, (managed && managed.settings_file) || 'settings.json');
|
|
277
|
-
}
|
|
306
|
+
// `settingsFile` lives in `hosts.mjs` (the hook stand-down reads it, and must not import this
|
|
307
|
+
// module); re-exported here for `migrate`.
|
|
308
|
+
export { settingsFile };
|
|
278
309
|
|
|
279
310
|
/** `_harness_mcp._claude_md_path` — where the manifest says the managed block lives. */
|
|
280
311
|
function claudeMdPath(cfg, managed) {
|
|
@@ -343,7 +374,8 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
|
343
374
|
if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
|
|
344
375
|
}
|
|
345
376
|
const unmerged = unmergeOpencodeJson(path.join(target, 'opencode.json'),
|
|
346
|
-
path.join(target, 'AGENT.md').split(path.sep).join('/'));
|
|
377
|
+
path.join(target, 'AGENT.md').split(path.sep).join('/'), skillPermissionNames(man));
|
|
378
|
+
managedBlockRemove(path.join(target, OPENCODE_SENTINEL));
|
|
347
379
|
if (failed.length) warnMarkersKept();
|
|
348
380
|
else for (const m of REVERSAL_MARKERS) unlinkQuiet(path.join(target, m));
|
|
349
381
|
let archived = null;
|
|
@@ -403,10 +435,11 @@ function opencodeProjectUninstall(root) {
|
|
|
403
435
|
const entry = installAgentEntry(root, 'project');
|
|
404
436
|
let removed = 0;
|
|
405
437
|
let failed = [];
|
|
438
|
+
let man = null;
|
|
406
439
|
const oc = path.join(root, '.opencode');
|
|
407
440
|
const manifestPath = path.join(oc, GLOBAL_MANIFEST);
|
|
408
441
|
if (isFile(manifestPath)) {
|
|
409
|
-
|
|
442
|
+
man = claudeReadManifest(oc);
|
|
410
443
|
[removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
|
|
411
444
|
if (failed.length) warnSurvivors(failed);
|
|
412
445
|
// Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
|
|
@@ -427,7 +460,8 @@ function opencodeProjectUninstall(root) {
|
|
|
427
460
|
}
|
|
428
461
|
const am = path.join(root, 'AGENT.md');
|
|
429
462
|
if (isFile(am)) { unlinkQuiet(am); removed += 1; }
|
|
430
|
-
const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry
|
|
463
|
+
const unmerged = unmergeOpencodeJson(path.join(root, 'opencode.json'), entry,
|
|
464
|
+
man ? skillPermissionNames(man) : []);
|
|
431
465
|
const result = { removed, unmerged, archived: null };
|
|
432
466
|
if (failed.length) result.failed = failed;
|
|
433
467
|
return result;
|
|
@@ -519,10 +553,75 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
519
553
|
if (archived.length) out.archived = archived;
|
|
520
554
|
return out;
|
|
521
555
|
}
|
|
522
|
-
|
|
523
|
-
|
|
556
|
+
// The root markers are ONE PER ROOT (`generate.mjs`'s "THE MARKER IS TRUSTED ONLY FOR ITS
|
|
557
|
+
// OWN HOST") and `registryRoots` keeps a row alive only while `.geneseed-emit` exists there
|
|
558
|
+
// — so in a repo sharing two project installs (`.claude/` + `.opencode/` at the same cwd,
|
|
559
|
+
// last deploy wins the marker), deleting it unconditionally because THIS host is being
|
|
560
|
+
// uninstalled would silently deregister the OTHER host's still-live install, regardless of
|
|
561
|
+
// which host's name the marker happened to carry.
|
|
562
|
+
//
|
|
563
|
+
// Delete every marker ONLY when nothing else is installed here. Otherwise:
|
|
564
|
+
// - `.geneseed-emit` carries HOST IDENTITY (`EMIT_HOST_SCOPE`'s key names a (host, scope)
|
|
565
|
+
// pair) and `installProfile`/`migrateSurvey` trust it only for the host it names
|
|
566
|
+
// (`generate.mjs`'s own docblock). If it currently names the host just removed, REWRITE
|
|
567
|
+
// it to the survivor's own emit name — the same string `writeMarkers` (`driver.mjs`)
|
|
568
|
+
// would have written for that survivor — rather than deleting it and leaving the
|
|
569
|
+
// registry row to self-prune a live install. If it already names a survivor, it is
|
|
570
|
+
// already correct; leave it untouched.
|
|
571
|
+
// - `.geneseed-theme` and `VERSION_MARKER` carry NO host identity — a bare theme name and
|
|
572
|
+
// a version/fingerprint string — so there is no survivor value to rewrite them TO; the
|
|
573
|
+
// only question is whether they are THIS host's own litter to clear. At PROJECT scope
|
|
574
|
+
// `.geneseed-theme` here is OpenCode's alone: `driver.mjs`'s own comment says the
|
|
575
|
+
// claude/bob/openclaude project emits never write it, and `themeOfDir`'s host-narrowing
|
|
576
|
+
// (host-compat B1 round 3, `js/hosts/installs.mjs`) means no Claude-style sibling ever
|
|
577
|
+
// reads it either — so delete it only when OpenCode is the host being removed, and
|
|
578
|
+
// leave it for a Claude-style uninstall (it belongs to a surviving or absent OpenCode
|
|
579
|
+
// install either way, never to the host that just left). `VERSION_MARKER` is written at
|
|
580
|
+
// `cfgDir`, never bare `root`, by every PROJECT emit (`js/build/version.mjs`'s callers),
|
|
581
|
+
// so a project root never actually carries this file regardless of host — deleting it
|
|
582
|
+
// here is always a no-op there. At GLOBAL scope neither file is ever shared (each host
|
|
583
|
+
// owns its own config dir), so both are always this host's own and always safe to drop.
|
|
584
|
+
// PROJECT scope uses `projectQualifies` (the same predicate `uninstallResolve` already
|
|
585
|
+
// trusts for exactly this question), never `installState`: `installState`'s OpenCode
|
|
586
|
+
// branch answers 'active' off a bare `.opencode/` DIRECTORY's existence alone, with no
|
|
587
|
+
// manifest or marker check, so an unrelated non-Geneseed `.opencode/` sitting next to a
|
|
588
|
+
// real Claude install would read as a "surviving" OpenCode install and keep the marker
|
|
589
|
+
// alive for a host that was never actually here.
|
|
590
|
+
const survivor = HOSTS.find(({ host: h }) => h !== host && (
|
|
591
|
+
scope === 'project'
|
|
592
|
+
? projectQualifies(root, h)
|
|
593
|
+
// GLOBAL: each host owns a distinct config dir, so no two hosts' global installs ever
|
|
594
|
+
// share a root — this can only match THIS host's own global install, and by the time
|
|
595
|
+
// execution reaches here (step 3) step 1 has already unlinked THIS host's own
|
|
596
|
+
// manifest/markers, so the lookback never mistakes its own just-removed install for a
|
|
597
|
+
// surviving sibling.
|
|
598
|
+
: installState(root, h, scope) !== 'absent'
|
|
599
|
+
));
|
|
600
|
+
if (!survivor) {
|
|
601
|
+
for (const m of ['.geneseed-emit', '.geneseed-theme', VERSION_MARKER]) {
|
|
602
|
+
unlinkQuiet(path.join(root, m));
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
const markerScope = emitHostScopeOf(root);
|
|
606
|
+
if (markerScope !== null && markerScope[0] === host) {
|
|
607
|
+
const survivorEmit = [...EMIT_HOST_SCOPE.entries()]
|
|
608
|
+
.find(([, hs]) => hs[0] === survivor.host && hs[1] === scope)?.[0];
|
|
609
|
+
if (survivorEmit) {
|
|
610
|
+
writeText(path.join(root, '.geneseed-emit'), `${survivorEmit}\n`);
|
|
611
|
+
} else {
|
|
612
|
+
// Defensive: every (host, scope) pair in `HOSTS` has an `EMIT_HOST_SCOPE` entry, so
|
|
613
|
+
// this should be unreachable — but silently leaving a marker naming the just-removed
|
|
614
|
+
// host would deregister the survivor exactly as before, so it is loud instead of quiet.
|
|
615
|
+
printErr(`[uninstall] WARN: could not resolve an emit name for the surviving `
|
|
616
|
+
+ `${survivor.host}:${scope} install here — the shared .geneseed-emit marker still `
|
|
617
|
+
+ `names ${host}, which was just removed. Re-run \`geneseed rebuild-all\` or `
|
|
618
|
+
+ `re-emit ${survivor.host} to refresh it.\n`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (scope === 'global' || host === 'opencode') unlinkQuiet(path.join(root, '.geneseed-theme'));
|
|
622
|
+
unlinkQuiet(path.join(root, VERSION_MARKER));
|
|
524
623
|
}
|
|
525
|
-
// 4. Tidy an emptied marker dir (.claude/.bob) so no husk lingers in the repo.
|
|
624
|
+
// 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
|
|
526
625
|
if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
|
|
527
626
|
const out = { ok: true, removed: summary.removed ?? 0, memory };
|
|
528
627
|
if (archived.length) out.archived = archived;
|
|
@@ -532,8 +631,9 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
532
631
|
/**
|
|
533
632
|
* `_harness_mcp._project_qualifies` — does `root` carry a REAL Geneseed project install?
|
|
534
633
|
*
|
|
535
|
-
* The marker dir exists, is not the host's global
|
|
536
|
-
* `installTargets` aliasing guard
|
|
634
|
+
* The marker dir exists, is not the host's global seen from its parent (`isHostGlobalDir`, the
|
|
635
|
+
* `installTargets` aliasing guard — the default `~/.claude` too, under `$CLAUDE_CONFIG_DIR`),
|
|
636
|
+
* and shows Geneseed's own tracks: the manifest, or the
|
|
537
637
|
* root `.geneseed-emit` naming this host's project emit for a pre-manifest legacy install.
|
|
538
638
|
* A bare non-Geneseed `.claude/` is very common and must never hijack the resolve.
|
|
539
639
|
*/
|
|
@@ -542,7 +642,7 @@ export function projectQualifies(root, host) {
|
|
|
542
642
|
const cfg = path.join(root, spec.projectMarker);
|
|
543
643
|
if (!isDir(cfg)) return false;
|
|
544
644
|
try {
|
|
545
|
-
if (
|
|
645
|
+
if (isHostGlobalDir(host, cfg)) return false;
|
|
546
646
|
} catch { /* as the Python's bare `except Exception: pass` */ }
|
|
547
647
|
if (isFile(path.join(cfg, GLOBAL_MANIFEST))) return true;
|
|
548
648
|
const hs = emitHostScopeOf(root);
|
|
@@ -556,7 +656,8 @@ export function projectQualifies(root, host) {
|
|
|
556
656
|
* is NAMED like a project marker and is the global install, never `claude:project` rooted at
|
|
557
657
|
* `$HOME`, so the global-config-dir case is checked before the marker-name case.
|
|
558
658
|
*
|
|
559
|
-
* 1. `--target` IS a host's global
|
|
659
|
+
* 1. `--target` IS a host's global (`isHostGlobalDir`: its env-resolved dir, its default
|
|
660
|
+
* `~/<marker>`, or a dir whose emit marker names that host's `-global` emit).
|
|
560
661
|
* 2. `--target` IS a project marker dir itself (…/.claude) — root is its parent.
|
|
561
662
|
* 3. `--target` (as a root) carries a Geneseed project install.
|
|
562
663
|
* 4. `--target` given and unrecognised — null, and the caller reports it.
|
|
@@ -566,7 +667,7 @@ export function uninstallResolve(targetArg) {
|
|
|
566
667
|
const globalHit = (p) => {
|
|
567
668
|
for (const spec of HOSTS) {
|
|
568
669
|
try {
|
|
569
|
-
if (
|
|
670
|
+
if (isHostGlobalDir(spec.host, p)) return [spec.host, 'global', p];
|
|
570
671
|
} catch { continue; }
|
|
571
672
|
}
|
|
572
673
|
return null;
|
|
@@ -673,12 +774,17 @@ export function cmdUninstall(args) {
|
|
|
673
774
|
const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
|
|
674
775
|
printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
|
|
675
776
|
if (CLAUDE_STYLE.includes(host)) {
|
|
777
|
+
// Not always settings.json: a Claude/OpenClaude PROJECT install wires settings.local.json
|
|
778
|
+
// (host-compat B4) — read back from the manifest `settingsFile` already resolves, rather
|
|
779
|
+
// than restate the rule here a second time.
|
|
780
|
+
const cfgForSf = claudeCfg(root, scope, host);
|
|
781
|
+
const sfName = path.basename(settingsFile(cfgForSf, managedOf(claudeReadManifest(cfgForSf))));
|
|
676
782
|
printOut('[uninstall] removes: agents/, skills/, markers, the '
|
|
677
783
|
+ `${hostSpec(host).agentFile} managed block, and Geneseed's `
|
|
678
|
-
+
|
|
784
|
+
+ `${sfName} hooks/excludes (your own keys/hooks are kept).\n`);
|
|
679
785
|
} else if (scope === 'global') {
|
|
680
|
-
printOut('[uninstall] removes: AGENT.md,
|
|
681
|
-
+ 'opencode.json instructions entry.\n');
|
|
786
|
+
printOut('[uninstall] removes: AGENT.md, the AGENTS.md managed block, agents/, skills/, '
|
|
787
|
+
+ 'plugins/, markers, and the opencode.json instructions entry.\n');
|
|
682
788
|
} else {
|
|
683
789
|
printOut('[uninstall] removes: AGENT.md, .opencode/, laws/, agents/, skills/, and the '
|
|
684
790
|
+ 'opencode.json instructions entry.\n');
|
|
@@ -949,6 +1055,8 @@ export function installDeactivate(root, host = 'opencode', scope = 'global') {
|
|
|
949
1055
|
return { ok: false, failed, rolled_back: done.length };
|
|
950
1056
|
}
|
|
951
1057
|
unmergeOpencodeJson(path.join(root, 'opencode.json'), installAgentEntry(root, kind));
|
|
1058
|
+
// The sentinel goes too, or a disabled install keeps the user's ~/.claude/CLAUDE.md from loading.
|
|
1059
|
+
if (kind === 'global') managedBlockRemove(path.join(root, OPENCODE_SENTINEL));
|
|
952
1060
|
for (const rel of done) pruneAncestors(path.dirname(path.join(root, rel)), root);
|
|
953
1061
|
return { ok: true, kind, moved: done.length };
|
|
954
1062
|
}
|
|
@@ -975,6 +1083,7 @@ export function installReactivate(root, host = 'opencode', scope = 'global') {
|
|
|
975
1083
|
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
976
1084
|
const kind = installKind(root) || 'global';
|
|
977
1085
|
installReaddEntry(target, installAgentEntry(root, kind));
|
|
1086
|
+
if (kind === 'global') opencodeSentinelWrite(root);
|
|
978
1087
|
rmtreeQuiet(stash);
|
|
979
1088
|
return { ok: true, kind, moved };
|
|
980
1089
|
}
|
|
@@ -1026,11 +1135,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
|
|
|
1026
1135
|
// unconditionally (fail-closed, so not a hole — but wrong, and it made the toggle one-way
|
|
1027
1136
|
// for project installs). `root` first, `cfg` second: on a global install the two are the
|
|
1028
1137
|
// same directory anyway. Both silent ⇒ `null` ⇒ the gate stays.
|
|
1029
|
-
|
|
1138
|
+
// Both reads are narrowed to `host`'s OWN carrier (host-compat B1): in a repo shared with
|
|
1139
|
+
// another Claude-style host, an un-narrowed scan can answer with a SIBLING host's carrier —
|
|
1140
|
+
// the same failure `installProfile` had, reached here because `root` is the shared repo.
|
|
1141
|
+
const doctrines = doctrinesOfDir(root, host) ?? doctrinesOfDir(cfg, host);
|
|
1030
1142
|
// Same two-carrier read for the second axis, and its own default: an absent line means
|
|
1031
1143
|
// NOTHING excluded, so a reactivate can only ever restore a gate, never remove one.
|
|
1032
|
-
const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
|
|
1033
|
-
: excludedRulesOfDir(cfg);
|
|
1144
|
+
const excluded = excludedRulesOfDir(root, host).length ? excludedRulesOfDir(root, host)
|
|
1145
|
+
: excludedRulesOfDir(cfg, host);
|
|
1034
1146
|
// `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
|
|
1035
1147
|
const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
|
|
1036
1148
|
managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
|
package/js/maintain/update.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import path from 'node:path';
|
|
|
46
46
|
import { ROOT } from '../build/source.mjs';
|
|
47
47
|
import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
|
|
48
48
|
import { opencodeConfigDir } from '../hosts/hosts.mjs';
|
|
49
|
+
import { legacyProcessCarrierOfDir } from '../hosts/installs.mjs';
|
|
49
50
|
import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
50
51
|
import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
|
|
51
52
|
import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
|
|
@@ -617,9 +618,18 @@ async function drainStdout() {
|
|
|
617
618
|
* Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
|
|
618
619
|
* may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
|
|
619
620
|
* is given no out and no root.
|
|
621
|
+
*
|
|
622
|
+
* `null` — distinct from `[]`'s "never built, nothing to carry over" — means `dir` carries a
|
|
623
|
+
* pre-comms carrier (same guard as `rebuild-all`/`migrate`/the web console, reusing
|
|
624
|
+
* `legacyProcessCarrierOfDir`): `installProfile` would read it exactly as written, and feeding
|
|
625
|
+
* that into a rebuild would write the loss back PERMANENTLY. `[]` here would be WORSE, not
|
|
626
|
+
* neutral — it resets every axis to the generator's config default, the exact failure this
|
|
627
|
+
* function exists to prevent, on top of the comms/process-address loss. `upgrade` must skip the
|
|
628
|
+
* bundle rebuild entirely on `null`, never fall through to an empty axes list.
|
|
620
629
|
*/
|
|
621
630
|
export async function installAxes(dir, { installProfile, setupBuildArgs }, notify = null) {
|
|
622
631
|
if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
|
|
632
|
+
if (legacyProcessCarrierOfDir(String(dir), 'opencode')) return null;
|
|
623
633
|
const p = installProfile('opencode', 'global', String(dir), notify);
|
|
624
634
|
return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
|
|
625
635
|
undefined, p.excludeRules, p.trust, p.excludeSkills).slice(4);
|
|
@@ -756,8 +766,18 @@ export async function upgrade(ref = null, themeArg = null) {
|
|
|
756
766
|
} catch (e) {
|
|
757
767
|
log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
|
|
758
768
|
}
|
|
759
|
-
let rc =
|
|
760
|
-
|
|
769
|
+
let rc = 0;
|
|
770
|
+
if (axes === null) {
|
|
771
|
+
// `installAxes` found a pre-comms carrier at this bundle's own target — skip the rebuild
|
|
772
|
+
// entirely rather than fall through with no axes (which would reset it to the generator's
|
|
773
|
+
// config default, worse than the loss this guard exists to stop). `rebuildInstalls` below
|
|
774
|
+
// reaches the same directory through `rebuild-all`'s own, identically-reasoned skip.
|
|
775
|
+
log(`[geneseed] SKIPPED bundle rebuild: pre-comms carrier at `
|
|
776
|
+
+ `${emit === 'opencode-global' ? cfg : out} (names 'process 8') — run `
|
|
777
|
+
+ '`geneseed setup` for it.');
|
|
778
|
+
} else {
|
|
779
|
+
rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounceDaemonIfRunning);
|
|
780
|
+
}
|
|
761
781
|
if (rc !== 0) {
|
|
762
782
|
log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
|
|
763
783
|
return 1;
|
package/js/web/actions.mjs
CHANGED
|
@@ -42,20 +42,18 @@ import { setupBuildArgs } from '../build/generate.mjs';
|
|
|
42
42
|
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
43
43
|
import { activeLoops, setLoopPreset } from '../loop/registry.mjs';
|
|
44
44
|
import {
|
|
45
|
-
|
|
45
|
+
HOSTS, bobConfigDir, claudeConfigDir, expanduser, openclaudeConfigDir,
|
|
46
46
|
opencodeConfigDir, resolvePath,
|
|
47
47
|
} from '../hosts/hosts.mjs';
|
|
48
48
|
import {
|
|
49
49
|
EMIT_HOST_SCOPE, doctrinesForBuild, excludedRulesOfDir, excludedSkillsOfDir, footprintOfDir,
|
|
50
|
-
installState,
|
|
51
|
-
installTargets, modeOfDir, postureOfDir, trustOfDir,
|
|
50
|
+
installState, installTargets, legacyProcessCarrierOfDir, modeOfDir, postureOfDir, trustOfDir,
|
|
52
51
|
} from '../hosts/installs.mjs';
|
|
53
52
|
import {
|
|
54
|
-
|
|
55
|
-
mcpMeta, mcpPresetBlock, mcpSave, mcpSetEnabled, mcpState,
|
|
53
|
+
isDict, mcpCommented, mcpInstallTargets, mcpLoad, mcpServerNames, mcpState,
|
|
56
54
|
} from '../hosts/mcp.mjs';
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
55
|
+
import { isFile, isDir } from '../lib/fs.mjs';
|
|
56
|
+
import { formatRepr, formatValue, isTruthy } from '../lib/json.mjs';
|
|
59
57
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
60
58
|
import { normcase, which, within } from '../lib/paths.mjs';
|
|
61
59
|
import { stripWhitespace } from '../lib/text.mjs';
|
|
@@ -349,18 +347,20 @@ export function apiLoopsPresetMutate(state, body) {
|
|
|
349
347
|
|
|
350
348
|
// ---- MCP ----------------------------------------------------------------------------------
|
|
351
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The MCP servers each active install ALREADY has, and nothing it could have. Read-only by
|
|
352
|
+
* decision (2026-10): which servers to wire depends on the user and on the host, so the console
|
|
353
|
+
* shows what is in each config and where that file lives, and the user edits it. There is no
|
|
354
|
+
* preset to add and no toggle, so nothing here ever rewrites a config — including
|
|
355
|
+
* `~/.claude.json`, which holds far more than MCP wiring.
|
|
356
|
+
*/
|
|
352
357
|
export function apiMcp() {
|
|
353
358
|
const out = [];
|
|
354
359
|
for (const [label, p, host, scope, root] of mcpInstallTargets()) {
|
|
355
360
|
if (installState(root, host, scope) !== 'active') continue;
|
|
356
361
|
const cfg = mcpLoad(p, host);
|
|
357
|
-
const servers =
|
|
358
|
-
|
|
359
|
-
const [lbl, desc] = mcpMeta(name);
|
|
360
|
-
servers.push({ name, label: lbl, desc,
|
|
361
|
-
preset: Object.hasOwn(MCP_PRESETS, name),
|
|
362
|
-
state: mcpState(cfg, name, host) });
|
|
363
|
-
}
|
|
362
|
+
const servers = mcpServerNames(cfg, host)
|
|
363
|
+
.map((name) => ({ name, state: mcpState(cfg, name, host) }));
|
|
364
364
|
out.push({ label, path: p, host, root, exists: isFile(p),
|
|
365
365
|
commented: mcpCommented(p), servers });
|
|
366
366
|
}
|
|
@@ -371,10 +371,8 @@ export function apiMcp() {
|
|
|
371
371
|
/**
|
|
372
372
|
* The MCP config paths a request body is allowed to name, as `path -> [path, host]`.
|
|
373
373
|
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* endpoint exactly as it is of the toggle: an unlisted path must 404 before anything is opened.
|
|
377
|
-
* Two copies of a security check drift, and the copy that drifts is the one nobody re-read.
|
|
374
|
+
* `/api/reveal` hands a path to the desktop's file manager, and this allowlist is the whole
|
|
375
|
+
* security of that endpoint: an unlisted path must 404 before anything is opened.
|
|
378
376
|
*
|
|
379
377
|
* It is rebuilt per call rather than cached: installs come and go while the daemon runs, and a
|
|
380
378
|
* stale allowlist is wrong in both directions — refusing a real target, or naming a dead one.
|
|
@@ -387,67 +385,6 @@ export function mcpTargetPaths() {
|
|
|
387
385
|
return known;
|
|
388
386
|
}
|
|
389
387
|
|
|
390
|
-
/**
|
|
391
|
-
* Enable / disable / first-add one server.
|
|
392
|
-
*
|
|
393
|
-
* THE PATH COMES OUT OF THE REQUEST BODY, so the allowlist is the whole security of this
|
|
394
|
-
* endpoint: `known` is built from the detected install targets and an unlisted path is a 404
|
|
395
|
-
* before anything is read or written. Without it this is "write arbitrary JSON to an
|
|
396
|
-
* arbitrary file", behind a CSRF token and nothing else.
|
|
397
|
-
*
|
|
398
|
-
* The Claude-shaped hosts are parsed ONCE, STRICTLY, and that exact object is what gets
|
|
399
|
-
* rewritten — so the safety check and the value saved come from the same read, with no
|
|
400
|
-
* time-of-check/time-of-use gap and no comment-stripper touching a string that contains
|
|
401
|
-
* `,]`. A file that will not parse is refused rather than clobbered: it may be
|
|
402
|
-
* `~/.claude.json`, which holds projects and history far beyond MCP wiring.
|
|
403
|
-
*/
|
|
404
|
-
export function apiMcpToggle(state, body) {
|
|
405
|
-
const name = strOr(bget(body, 'name'));
|
|
406
|
-
const want = isTruthy(bget(body, 'enabled'));
|
|
407
|
-
const pathArg = strOr(bget(body, 'path'));
|
|
408
|
-
const known = mcpTargetPaths();
|
|
409
|
-
const hit = known.get(pathArg);
|
|
410
|
-
if (hit === undefined || !name) {
|
|
411
|
-
throw new NotFound(`mcp target ${pathArg || '(none)'}`);
|
|
412
|
-
}
|
|
413
|
-
const [p, host] = hit;
|
|
414
|
-
if (mcpCommented(p)) {
|
|
415
|
-
return { ok: false, error: 'config holds comments — edit it by hand to keep them' };
|
|
416
|
-
}
|
|
417
|
-
let cfg;
|
|
418
|
-
if (CLAUDE_STYLE.includes(host)) {
|
|
419
|
-
if (isFile(p)) {
|
|
420
|
-
let parsed;
|
|
421
|
-
try {
|
|
422
|
-
parsed = parseJson(readText(p));
|
|
423
|
-
} catch {
|
|
424
|
-
parsed = null;
|
|
425
|
-
}
|
|
426
|
-
if (!isDict(parsed)) {
|
|
427
|
-
return { ok: false,
|
|
428
|
-
error: "couldn't parse this config — edit it by hand to avoid data loss" };
|
|
429
|
-
}
|
|
430
|
-
cfg = parsed;
|
|
431
|
-
} else {
|
|
432
|
-
cfg = {};
|
|
433
|
-
}
|
|
434
|
-
} else {
|
|
435
|
-
cfg = mcpLoad(p);
|
|
436
|
-
}
|
|
437
|
-
if (mcpState(cfg, name, host) === 'absent') {
|
|
438
|
-
if (!Object.hasOwn(MCP_PRESETS, name)) {
|
|
439
|
-
return { ok: false, error: `unknown server '${name}'` };
|
|
440
|
-
}
|
|
441
|
-
if (!want) return { ok: false, error: `'${name}' is not configured` };
|
|
442
|
-
cfg = mcpApply(cfg, name, mcpPresetBlock(name, host), host);
|
|
443
|
-
cfg = mcpSetEnabled(cfg, name, true, host);
|
|
444
|
-
} else {
|
|
445
|
-
cfg = mcpSetEnabled(cfg, name, want, host);
|
|
446
|
-
}
|
|
447
|
-
mcpSave(p, cfg);
|
|
448
|
-
return { ok: true, name, state: mcpState(cfg, name, host) };
|
|
449
|
-
}
|
|
450
|
-
|
|
451
388
|
// ---- the harness selector ------------------------------------------------------------------
|
|
452
389
|
|
|
453
390
|
/**
|
|
@@ -605,26 +542,38 @@ export function apiInstallCmd(state, body) {
|
|
|
605
542
|
if (installState(root, host, scope) === 'disabled') {
|
|
606
543
|
return { error: 'install is disabled — reactivate it before (re)building' };
|
|
607
544
|
}
|
|
545
|
+
// A LEGACY CARRIER IS REFUSED, NEVER REBUILT — same guard as `rebuild-all`/`migrate` (see
|
|
546
|
+
// `legacyProcessCarrierOfDir`'s docblock): `doctrinesForBuild`/`excludedRulesOfDir` below
|
|
547
|
+
// read a pre-comms carrier exactly as written, and this endpoint's `{cmd}` is EXECUTED by
|
|
548
|
+
// `startJob` (`js/web/handler.mjs`'s `install` action) — building that argv here would write
|
|
549
|
+
// the same permanent loss a console click away.
|
|
550
|
+
if (legacyProcessCarrierOfDir(root, host)) {
|
|
551
|
+
return { error: `${host}:${scope} (${root}) predates the comms pack (its carrier names `
|
|
552
|
+
+ "'process 8') — run `geneseed setup` for it before rebuilding from the console" };
|
|
553
|
+
}
|
|
608
554
|
const emit = EMIT_FOR.get(`${host} ${scope}`);
|
|
609
555
|
if (emit === undefined) return { error: `no install mode for ${host}:${scope}` };
|
|
556
|
+
// Every read-back below is narrowed to `host`'s OWN carrier (host-compat B1) — see the
|
|
557
|
+
// comment at `installProfile` in `js/build/generate.mjs`.
|
|
610
558
|
const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
|
|
611
|
-
theme: state.theme, footprint: footprintOfDir(root),
|
|
612
|
-
|
|
559
|
+
theme: state.theme, footprint: footprintOfDir(root),
|
|
560
|
+
posture: postureOfDir(root, host) || 'peer',
|
|
561
|
+
mode: modeOfDir(root, host) || 'direct', trust: trustOfDir(root, host) || DEFAULT_PRESET });
|
|
613
562
|
// Unspecified means "keep what this install already has", exactly as theme, footprint,
|
|
614
563
|
// posture and mode above do — a rebuild through the console is not a place to silently
|
|
615
564
|
// re-decide the constitution. ⚠ AND A CARRIER WITH NO `Active packs:` MARKER (a pre-2.3
|
|
616
565
|
// install) MUST NOT LAND ON `harness.config.json`: `doctrinesOfDir` answers `null` there,
|
|
617
566
|
// `null` elided the flag, and the generator's config fallback then narrowed the install and
|
|
618
567
|
// took its consent gate with it. `doctrinesForBuild` resolves unknown to ALL packs.
|
|
619
|
-
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
|
|
568
|
+
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
|
|
620
569
|
// The per-rule axis needs NO `doctrinesForBuild`-style rescue: the marker it reads is only
|
|
621
570
|
// written when something is excluded, so its absence is a statement ("nothing") rather than
|
|
622
571
|
// the silence a pre-2.3 carrier gives on packs. Unspecified therefore keeps the install's
|
|
623
572
|
// own answer, and a pre-marker install answers the empty list — which is what it has.
|
|
624
|
-
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
|
|
573
|
+
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
|
|
625
574
|
const out = scope === 'global' ? null : String(root);
|
|
626
575
|
const argv = setupBuildArgs(theme || 'neutral', emit, out, out, fp, pos, mode, doctrines,
|
|
627
|
-
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
|
|
576
|
+
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
|
|
628
577
|
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
|
|
629
578
|
}
|
|
630
579
|
|
|
@@ -704,6 +653,13 @@ export function apiDeployCmd(state, body) {
|
|
|
704
653
|
if (cfgdirs.has(root)) {
|
|
705
654
|
return { error: "that's a host global config dir — use its existing row to build a global install" };
|
|
706
655
|
}
|
|
656
|
+
// Same guard as `apiInstallCmd` above: a landing folder that already holds a pre-comms
|
|
657
|
+
// install must not be rebuilt through `doctrinesForBuild`/`excludedRulesOfDir` below. A
|
|
658
|
+
// fresh, never-built folder has no carrier at all, so this is a no-op for the common case.
|
|
659
|
+
if (legacyProcessCarrierOfDir(root, host)) {
|
|
660
|
+
return { error: `${root} predates the comms pack (its carrier names 'process 8') — `
|
|
661
|
+
+ 'run `geneseed setup` for it before deploying over it' };
|
|
662
|
+
}
|
|
707
663
|
// A fresh deploy is full, at the default register, mode and trust.
|
|
708
664
|
const { theme, footprint: fp, posture: pos, mode, trust } = bodyAxes(body, {
|
|
709
665
|
theme: state.theme, footprint: 'full', posture: 'peer', mode: 'direct',
|
|
@@ -714,10 +670,10 @@ export function apiDeployCmd(state, body) {
|
|
|
714
670
|
// the flag off, the generator fell back to `harness.config.json`, and deploying onto an
|
|
715
671
|
// existing all-four Claude install dropped it to one pack — measured: 6 hook groups became 5
|
|
716
672
|
// and `PreToolUse::Bash` went with them. `doctrinesForBuild` resolves unknown to ALL packs.
|
|
717
|
-
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root);
|
|
718
|
-
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root);
|
|
673
|
+
const doctrines = bodyDoctrines(body) ?? doctrinesForBuild(root, host);
|
|
674
|
+
const excludeRules = bodyExcludeRules(body) ?? excludedRulesOfDir(root, host);
|
|
719
675
|
// project-scope emit name == host name (opencode / claude / bob / openclaude)
|
|
720
676
|
const argv = setupBuildArgs(theme || 'neutral', host, root, root, fp, pos, mode, doctrines,
|
|
721
|
-
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root));
|
|
677
|
+
PACK_ORDER, excludeRules, trust, excludedSkillsOfDir(root, null, host));
|
|
722
678
|
return { cmd: [process.execPath, path.join(ROOT, 'bin', 'build-driver.mjs'), ...argv] };
|
|
723
679
|
}
|
package/js/web/api.mjs
CHANGED
|
@@ -113,16 +113,25 @@ export function viewCfg(host, scope, root) {
|
|
|
113
113
|
return root;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The row `id` carries the ROOT, not only `host:scope`: two project installs of one host (two
|
|
118
|
+
* repos on Bob, say) are both `bob:project`, and the console keys its selection, its React rows
|
|
119
|
+
* and its per-install picks on this id — with a shared one, both rows read as selected and every
|
|
120
|
+
* pick resolved to whichever came first. `installTargets()` de-duplicates on (host, root), so
|
|
121
|
+
* this is unique by construction.
|
|
122
|
+
*/
|
|
116
123
|
export function apiInstalls(state) {
|
|
117
124
|
const out = [];
|
|
118
125
|
for (const [host, scope, root] of installTargets()) {
|
|
119
126
|
out.push({
|
|
120
|
-
id: `${host}:${scope}`, host, scope, path: root,
|
|
127
|
+
id: `${host}:${scope}:${root}`, host, scope, path: root,
|
|
121
128
|
state: installState(root, host, scope),
|
|
122
|
-
|
|
129
|
+
// Narrowed to this row's own host (host-compat B1): a shared repo otherwise answers
|
|
130
|
+
// every row with whichever host's carrier the host-agnostic scan favours.
|
|
131
|
+
theme: themeOfDir(root, host),
|
|
123
132
|
footprint: footprintOfDir(root),
|
|
124
|
-
posture: postureOfDir(root),
|
|
125
|
-
mode: modeOfDir(root),
|
|
133
|
+
posture: postureOfDir(root, host),
|
|
134
|
+
mode: modeOfDir(root, host),
|
|
126
135
|
trust: trustOfDir(root, host),
|
|
127
136
|
selected: samePath(viewCfg(host, scope, root), state.target),
|
|
128
137
|
});
|
package/js/web/handler.mjs
CHANGED
|
@@ -371,8 +371,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
371
371
|
try {
|
|
372
372
|
return doPost(req, res, path, ae, buf);
|
|
373
373
|
} catch (e) {
|
|
374
|
-
// `
|
|
375
|
-
//
|
|
374
|
+
// `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and `apiRulesPromote` all
|
|
375
|
+
// raise `NotFound` for an unknown target, mapped to 404
|
|
376
376
|
// here; anything else falls to the 500 arm below, carrying its own message.
|
|
377
377
|
if (e instanceof NotFound) {
|
|
378
378
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
package/js/web/routes.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { formatValue, isTruthy, parseJson } from '../lib/json.mjs';
|
|
20
20
|
import { percentDecode } from '../lib/text.mjs';
|
|
21
|
-
import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp,
|
|
21
|
+
import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMemoryDelete, apiSelectView } from './actions.mjs';
|
|
22
22
|
import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
|
|
23
23
|
import {
|
|
24
24
|
apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
|
|
@@ -43,7 +43,6 @@ import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote }
|
|
|
43
43
|
*/
|
|
44
44
|
/** @type {Map<string, [(state: any, body: any) => any, boolean]>} */
|
|
45
45
|
export const POST_ROUTES = new Map([
|
|
46
|
-
['/api/mcp', [apiMcpToggle, true]],
|
|
47
46
|
['/api/install', [apiInstallToggle, true]],
|
|
48
47
|
['/api/excludes', [apiExcludesMutate, true]],
|
|
49
48
|
['/api/view', [apiSelectView, true]],
|