geneseed 4.1.0 → 4.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 +43 -0
- package/README.md +4 -4
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- package/adapters/opencode/plugins/geneseed-guard.js +31 -0
- package/docs/concepts/agents.md +1 -0
- package/docs/guides/uninstall.md +2 -0
- package/docs/guides/upgrade.md +2 -0
- package/docs/token-footprint.md +1 -1
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +6 -0
- package/js/build/driver.mjs +47 -11
- package/js/build/emit-claude.mjs +22 -10
- package/js/build/emit-common.mjs +9 -3
- package/js/build/emit-opencode.mjs +18 -11
- package/js/build/source.mjs +1 -0
- package/js/build/version.mjs +10 -3
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks.mjs +4 -0
- package/js/hosts/installs.mjs +1 -1
- package/js/hosts/native.mjs +38 -6
- package/js/hosts/owned.mjs +159 -0
- package/js/hosts/settings.mjs +4 -0
- package/js/hosts/usage.mjs +62 -0
- package/js/inspect/checks-repo.mjs +76 -1
- package/js/inspect/cost.mjs +83 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/maintain/uninstall.mjs +77 -42
- package/js/web/api.mjs +66 -3
- package/js/web/gates-api.mjs +44 -0
- package/js/web/routes.mjs +8 -2
- package/js/web/update-check.mjs +61 -0
- package/js/web/usage-api.mjs +95 -0
- package/package.json +1 -1
- package/registry.json +25 -25
- package/src/AGENT.md.tmpl +1 -1
- package/src/agents/performance.md +72 -0
- package/src/laws/universal.md +7 -5
- package/src/skills/council.md +1 -1
- package/src/skills/develop.md +9 -0
- package/src/skills/geneseed-code-review.md +5 -1
- package/src/skills/security-audit.md +7 -1
- package/src/skills/wayfinder.md +25 -6
- package/src/skills/worktree.md +5 -5
- package/themes/_TEMPLATE.json +1 -1
- package/themes/biker.json +1 -1
- package/themes/commentator.json +1 -1
- package/themes/cyberpunk.json +1 -1
- package/themes/gamer.json +1 -1
- package/themes/imperial.json +1 -1
- package/themes/joker.json +1 -1
- package/themes/marvin.json +1 -1
- package/themes/mean.json +1 -1
- package/themes/military.json +1 -1
- package/themes/neutral.json +1 -1
- package/themes/pirate.json +1 -1
- package/themes/sports.json +1 -1
- package/themes/verstappen.json +1 -1
- package/themes/wizard.json +1 -1
- package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-DDTRkc4M.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-C41RsH-S.js} +1 -1
- package/web/dist/assets/Dashboard-B0DJ2oUk.js +2 -0
- package/web/dist/assets/Docs-DBj7-n9F.js +5 -0
- package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-Bz-Q1Xl-.js} +1 -1
- package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BNlWdJJE.js} +2 -2
- package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BkwmfkEy.js} +1 -1
- package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-CzvMZCUL.js} +2 -2
- package/web/dist/assets/Library-BgIpOTHi.js +1 -0
- package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-s_NsuNaQ.js} +1 -1
- package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-CgeWJnPI.js} +1 -1
- package/web/dist/assets/{Personal-DcVA502F.js → Personal-DMM6jfE0.js} +1 -1
- package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-CPpcsYDc.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-D-fIT_ow.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DXIa0ZG_.js +2 -0
- package/web/dist/index.html +3 -3
- package/src/skills/herdr.md +0 -154
- package/web/dist/assets/Dashboard-VlIx1-At.js +0 -2
- package/web/dist/assets/Docs-6cYYkVeA.js +0 -5
- package/web/dist/assets/Library-DBLX-1SD.js +0 -1
- package/web/dist/assets/api-CRqWld2H.js +0 -1
- package/web/dist/assets/index-BmjcLIOr.js +0 -2
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
writeAliasCommands, writePonytailCommand, writePrimaryAgent, writeTheme,
|
|
15
15
|
} from '../hosts/opencode.mjs';
|
|
16
16
|
import { mergeOpencodeJson, opencodeSentinelWrite } from '../hosts/settings.mjs';
|
|
17
|
+
import { claimLeaf } from '../hosts/owned.mjs';
|
|
17
18
|
import { isFile, readText, writeText } from '../lib/fs.mjs';
|
|
18
19
|
import { relPosix } from '../lib/text.mjs';
|
|
19
20
|
import { assertSourceComplete, build, phaseLog } from './bundle.mjs';
|
|
@@ -54,11 +55,12 @@ import { mkdirSync } from 'node:fs';
|
|
|
54
55
|
function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
|
|
55
56
|
const {
|
|
56
57
|
oldOwned, manifestExisted, agentPath, wireBase, overridesDir, oldDenies = [],
|
|
58
|
+
oldHashes = {},
|
|
57
59
|
} = opts;
|
|
58
60
|
ensureAgentOverridesStub(cfg, overridesDir);
|
|
59
61
|
const overrides = loadAgentOverrides(overridesDir);
|
|
60
62
|
// One claim for every writer below whose path lands in `owned` — see `claimer`.
|
|
61
|
-
const claim = claimer(oldOwned, dir, manifestExisted);
|
|
63
|
+
const claim = claimer(oldOwned, dir, manifestExisted, oldHashes);
|
|
62
64
|
|
|
63
65
|
const { nAgents, nSkills, written, userOnlySkills } = writeNativeLayer(
|
|
64
66
|
items, path.join(dir, 'agents'), path.join(dir, 'skills'), overrides,
|
|
@@ -105,7 +107,7 @@ function opencodeLayer(cfg, items, themeName, theme, dir, owned, opts) {
|
|
|
105
107
|
|
|
106
108
|
return {
|
|
107
109
|
nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands: commands.length, cfgName,
|
|
108
|
-
skillDenies,
|
|
110
|
+
skillDenies, editRefused: [...claim.editRefused],
|
|
109
111
|
};
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -129,7 +131,7 @@ export function emitOpencodeRender(cfg, job) {
|
|
|
129
131
|
phaseLog('RENDER');
|
|
130
132
|
const {
|
|
131
133
|
theme: _theme, out, root, footprint, nativeCatalog, oldOwned, manifestExisted, agentPath,
|
|
132
|
-
oldDenies,
|
|
134
|
+
oldDenies, oldHashes = {},
|
|
133
135
|
} = job;
|
|
134
136
|
const oc = path.join(root, '.opencode');
|
|
135
137
|
|
|
@@ -144,9 +146,9 @@ export function emitOpencodeRender(cfg, job) {
|
|
|
144
146
|
const { theme, items } = renderAll(cfg, _theme, { footprint, nativeCatalog });
|
|
145
147
|
|
|
146
148
|
const {
|
|
147
|
-
nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies,
|
|
149
|
+
nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies, editRefused,
|
|
148
150
|
} = opencodeLayer(cfg, items, _theme, theme, oc, owned, {
|
|
149
|
-
oldOwned, manifestExisted, agentPath, wireBase: root, overridesDir: out, oldDenies,
|
|
151
|
+
oldOwned, manifestExisted, agentPath, wireBase: root, overridesDir: out, oldDenies, oldHashes,
|
|
150
152
|
});
|
|
151
153
|
|
|
152
154
|
return {
|
|
@@ -156,6 +158,7 @@ export function emitOpencodeRender(cfg, job) {
|
|
|
156
158
|
},
|
|
157
159
|
cfgName,
|
|
158
160
|
skillDenies,
|
|
161
|
+
editRefused,
|
|
159
162
|
};
|
|
160
163
|
}
|
|
161
164
|
|
|
@@ -195,6 +198,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
195
198
|
phaseLog('RENDER');
|
|
196
199
|
const {
|
|
197
200
|
theme: themeName, cfgDir, out, footprint, nativeCatalog, oldOwned, agentPath, oldDenies,
|
|
201
|
+
oldHashes = {},
|
|
198
202
|
} = job;
|
|
199
203
|
|
|
200
204
|
// No `lawsPrefix`: the standalone laws dir sits beside AGENT.md in <cfg>, so the lean
|
|
@@ -210,8 +214,11 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
210
214
|
// plain names. Memory links stay RELATIVE: in the global layout AGENT.md and the
|
|
211
215
|
// store are siblings, so `memory/` resolves from AGENT.md's own location and stays
|
|
212
216
|
// hermetic — no absolute path a doctor check would flag as an escape.
|
|
213
|
-
|
|
214
|
-
|
|
217
|
+
const agentDest = path.join(cfgDir, 'AGENT.md');
|
|
218
|
+
if (claimLeaf(cfgDir, agentDest, 'AGENT.md')) {
|
|
219
|
+
writeText(agentDest, stripCapabilityLinks(agentText));
|
|
220
|
+
owned.push('AGENT.md');
|
|
221
|
+
}
|
|
215
222
|
}
|
|
216
223
|
// Co-owned, so never in `owned` — see `opencodeSentinelWrite`.
|
|
217
224
|
opencodeSentinelWrite(cfgDir);
|
|
@@ -219,9 +226,9 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
219
226
|
// `manifestExisted` is deliberately not passed — the Python does not pass it either, so
|
|
220
227
|
// the pre-manifest header line is unreachable from this emit on both sides.
|
|
221
228
|
const {
|
|
222
|
-
nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies,
|
|
229
|
+
nAgents, nSkills, nPlugins, nWorkflows, primary, nCommands, cfgName, skillDenies, editRefused,
|
|
223
230
|
} = opencodeLayer(cfg, items, themeName, theme, cfgDir, owned, {
|
|
224
|
-
oldOwned, agentPath, wireBase: cfgDir, overridesDir: cfgDir, oldDenies,
|
|
231
|
+
oldOwned, agentPath, wireBase: cfgDir, overridesDir: cfgDir, oldDenies, oldHashes,
|
|
225
232
|
});
|
|
226
233
|
|
|
227
234
|
const memStatus = globalMemory(cfgDir, items, out, cfg.src);
|
|
@@ -233,8 +240,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
233
240
|
ensureProfileStub(cfgDir);
|
|
234
241
|
ensureExcludesStub(cfgDir);
|
|
235
242
|
|
|
236
|
-
writeVersion(cfg, cfgDir);
|
|
237
|
-
owned.push(VERSION_MARKER);
|
|
243
|
+
if (writeVersion(cfg, cfgDir) !== null) owned.push(VERSION_MARKER);
|
|
238
244
|
|
|
239
245
|
shipLeanLaws(items, theme, cfgDir, owned, footprint);
|
|
240
246
|
|
|
@@ -247,5 +253,6 @@ export function emitOpencodeGlobalRender(cfg, job) {
|
|
|
247
253
|
nbStatus,
|
|
248
254
|
cfgName,
|
|
249
255
|
skillDenies,
|
|
256
|
+
editRefused,
|
|
250
257
|
};
|
|
251
258
|
}
|
package/js/build/source.mjs
CHANGED
|
@@ -348,6 +348,7 @@ export function skillAliases(src = SRC) {
|
|
|
348
348
|
*/
|
|
349
349
|
export const RETIRED_SKILL_IDS = {
|
|
350
350
|
tickets: 'removed in the 2026-10 sharpen; plan slices the work now',
|
|
351
|
+
herdr: 'removed 2026-10; Orca covers multi-pane and multi-agent work',
|
|
351
352
|
};
|
|
352
353
|
|
|
353
354
|
/**
|
package/js/build/version.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { VERSION_MARKER } from '../hosts/hosts.mjs';
|
|
11
11
|
import { readText, writeText } from '../lib/fs.mjs';
|
|
12
|
+
import { claimLeaf } from '../hosts/owned.mjs';
|
|
12
13
|
import { parseJson } from '../lib/json.mjs';
|
|
13
14
|
import { isDir } from '../lib/fs.mjs';
|
|
14
15
|
import { relPosix } from '../lib/text.mjs';
|
|
@@ -162,8 +163,15 @@ function warnIfDowngrade(cfg, out) {
|
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* `_build_render.write_version`. Returns the fingerprint on a successful write, or `null` when
|
|
168
|
+
* `claimLeaf` refuses `out`'s marker (a symlinked or hard-linked `VERSION_MARKER`) — every
|
|
169
|
+
* caller pushes `VERSION_MARKER` into `owned` only when this is not `null`, the same
|
|
170
|
+
* "skip the write, keep it out of owned" shape `claimer` uses.
|
|
171
|
+
*/
|
|
166
172
|
export function writeVersion(cfg, out) {
|
|
173
|
+
const dest = path.join(out, VERSION_MARKER);
|
|
174
|
+
if (!claimLeaf(out, dest, VERSION_MARKER)) return null;
|
|
167
175
|
warnIfDowngrade(cfg, out);
|
|
168
176
|
const fp = sourceFingerprint(cfg);
|
|
169
177
|
const release = sourceReleaseVersion(cfg);
|
|
@@ -177,8 +185,7 @@ export function writeVersion(cfg, out) {
|
|
|
177
185
|
// clock across a process boundary is not something the harness can do — which is
|
|
178
186
|
// exactly why the choice is spelled out instead of relying on the comparison.
|
|
179
187
|
const today = new Date().toLocaleDateString('sv-SE');
|
|
180
|
-
writeText(
|
|
181
|
-
`${fp} (built ${today}) [release ${release}]\n`);
|
|
188
|
+
writeText(dest, `${fp} (built ${today}) [release ${release}]\n`);
|
|
182
189
|
return fp;
|
|
183
190
|
}
|
|
184
191
|
|
package/js/cli-table.json
CHANGED
|
@@ -1415,6 +1415,21 @@
|
|
|
1415
1415
|
"is_flag": true,
|
|
1416
1416
|
"type": null,
|
|
1417
1417
|
"hidden": false
|
|
1418
|
+
},
|
|
1419
|
+
{
|
|
1420
|
+
"names": [
|
|
1421
|
+
"--force"
|
|
1422
|
+
],
|
|
1423
|
+
"dest": "force",
|
|
1424
|
+
"metavar": null,
|
|
1425
|
+
"help": "also delete owned files you edited since install",
|
|
1426
|
+
"choices": null,
|
|
1427
|
+
"default": false,
|
|
1428
|
+
"required": false,
|
|
1429
|
+
"nargs": "0",
|
|
1430
|
+
"is_flag": true,
|
|
1431
|
+
"type": null,
|
|
1432
|
+
"hidden": false
|
|
1418
1433
|
}
|
|
1419
1434
|
],
|
|
1420
1435
|
"mutex": []
|
package/js/hosts/hooks.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
} from './hosts.mjs';
|
|
60
60
|
import { currentBranch, gitRootOf, loopLaunched } from './gitref.mjs';
|
|
61
61
|
import { selfAndParents, readStdin } from './hooks-prims.mjs';
|
|
62
|
+
import { recordUse } from './usage.mjs';
|
|
62
63
|
|
|
63
64
|
// ======================================================================================
|
|
64
65
|
// git-gate — Doctrine process 5's tool-boundary backstop
|
|
@@ -627,6 +628,9 @@ function normaliseBobPayload(payload) {
|
|
|
627
628
|
function toolGate(args) {
|
|
628
629
|
if (sovereignBypass(args.root)) return 0;
|
|
629
630
|
const payload = normaliseBobPayload(readPayload());
|
|
631
|
+
// Count a skill or subagent call before judging it: Bob runs this verb on every tool and
|
|
632
|
+
// Claude/OpenClaude route their `Skill|Agent|Task` matcher here (js/hosts/usage.mjs).
|
|
633
|
+
recordUse(args.root, payload);
|
|
630
634
|
const ti = (payload && payload.tool_input) || {};
|
|
631
635
|
return typeof ti.command === 'string' ? gitDecide(args, payload) : ruleDecide(args, payload);
|
|
632
636
|
}
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -181,7 +181,7 @@ const CARRIERS = [
|
|
|
181
181
|
* `AGENTS.md` (`CLAUDE_SHAPED['bob-global']`'s summary says so) — which `HOSTS` has no column
|
|
182
182
|
* for and this hard-codes, same as the un-narrowed `CARRIERS` above always did.
|
|
183
183
|
*/
|
|
184
|
-
function carriersFor(host) {
|
|
184
|
+
export function carriersFor(host) {
|
|
185
185
|
const row = HOSTS.find((h) => h.host === host);
|
|
186
186
|
if (!row) return CARRIERS;
|
|
187
187
|
if (host === 'bob') return [path.join('rules', 'geneseed.md'), row.agentFile];
|
package/js/hosts/native.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
|
33
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
|
+
import { claimLeaf, ownedState } from './owned.mjs';
|
|
36
37
|
|
|
37
38
|
/** What a folder skill writes where it needs its own directory — see `writeNativeLayer`. */
|
|
38
39
|
const SKILL_DIR_PLACEHOLDER = '<this-skill-directory>';
|
|
@@ -242,7 +243,7 @@ function isReadonly(text) {
|
|
|
242
243
|
* description leaves the model's always-on catalogue and the model never self-triggers it.
|
|
243
244
|
* Same precedent as `isReadonly` — a phrase in the spec, not a second manifest to keep in
|
|
244
245
|
* step. Which skills carry it is an authoring decision: the teaching skills a human runs on
|
|
245
|
-
* themselves
|
|
246
|
+
* themselves.
|
|
246
247
|
*/
|
|
247
248
|
const USER_INVOKED_RE = /<!--\s*invocation:\s*user\s*-->/;
|
|
248
249
|
export function isUserInvokedOnly(text) {
|
|
@@ -471,14 +472,42 @@ function warnClaudeIgnored(overrides) {
|
|
|
471
472
|
* emit, shared by all of them, so the pre-manifest header still prints once.
|
|
472
473
|
*
|
|
473
474
|
* `oldOwned === null` (or no `cfg`) is a caller doing no ownership at all: claim everything.
|
|
475
|
+
*
|
|
476
|
+
* The leaf guard (`claimLeaf`/`safeLeaf`) runs whenever `cfg != null`, independently of
|
|
477
|
+
* `oldOwned` — it is not about which prior manifest owns `dest`, it is about what sits AT
|
|
478
|
+
* `dest` on disk, and a caller with no `cfg` (`null` or `undefined` — they behave alike
|
|
479
|
+
* throughout this closure) has no root to contain against at all. See `js/hosts/owned.mjs`.
|
|
480
|
+
*
|
|
481
|
+
* `claim.editRefused` (Task 4, fix round 1): the rels this closure refused because the user
|
|
482
|
+
* had edited them — the one piece of its own decision `pruneOwned` cannot re-derive from
|
|
483
|
+
* `owned` alone. A refused edit drops its rel OUT of `owned` (the file was not written), which
|
|
484
|
+
* makes it look exactly like a rel this build stopped shipping — `pruneOwned` would read it as
|
|
485
|
+
* stale, re-run `ownedState`, see `'modified'` again, and print ITS OWN "kept your edited...
|
|
486
|
+
* (no longer shipped)" right under this function's "kept your edited... delete it to get
|
|
487
|
+
* Geneseed's copy back" — two warnings about one rel that is in fact still shipped. The caller
|
|
488
|
+
* reads this set back off the closure and hands it to `pruneOwned` so that rel is excluded from
|
|
489
|
+
* the stale set entirely, silently, rather than re-evaluated.
|
|
474
490
|
*/
|
|
475
|
-
export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
491
|
+
export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
|
|
476
492
|
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
477
493
|
let headerPrinted = false;
|
|
478
|
-
|
|
479
|
-
|
|
494
|
+
const editRefused = new Set();
|
|
495
|
+
const claim = (dest) => {
|
|
496
|
+
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
|
|
497
|
+
if (oldSet === null || cfg == null || !existsSync(dest)) return true;
|
|
480
498
|
const rel = relPosix(cfg, dest);
|
|
481
|
-
if (oldSet.has(rel))
|
|
499
|
+
if (oldSet.has(rel)) {
|
|
500
|
+
// `'unknown'` (no hash recorded — a pre-Task-3 manifest, or a rel `ownedHashes` never
|
|
501
|
+
// hashed) and `'unmodified'` both fall through to the ordinary overwrite below: the
|
|
502
|
+
// constraint that a manifest without `hashes` keeps today's behaviour exactly is this
|
|
503
|
+
// branch doing nothing extra, not a special case.
|
|
504
|
+
if (ownedState(cfg, rel, oldHashes) === 'modified') {
|
|
505
|
+
warn(`[geneseed] kept your edited ${rel} — delete it to get Geneseed's copy back`);
|
|
506
|
+
editRefused.add(rel);
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
482
511
|
if (!manifestExisted && !headerPrinted) {
|
|
483
512
|
warn('[geneseed] first emit over a pre-manifest install — existing files '
|
|
484
513
|
+ 'are treated as yours');
|
|
@@ -488,6 +517,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
488
517
|
+ 'clobbering it');
|
|
489
518
|
return false;
|
|
490
519
|
};
|
|
520
|
+
claim.editRefused = editRefused;
|
|
521
|
+
return claim;
|
|
491
522
|
}
|
|
492
523
|
|
|
493
524
|
/**
|
|
@@ -524,7 +555,8 @@ export function claimer(oldOwned, cfg, manifestExisted = true) {
|
|
|
524
555
|
*/
|
|
525
556
|
export function writeNativeLayer(items, agentsDir, skillsDir, overrides = null, {
|
|
526
557
|
host = 'opencode', oldOwned = null, cfg = null, manifestExisted = true,
|
|
527
|
-
theme = null, src = undefined, skillDirOf = null,
|
|
558
|
+
theme = null, src = undefined, skillDirOf = null, oldHashes = {},
|
|
559
|
+
claim = claimer(oldOwned, cfg, manifestExisted, oldHashes),
|
|
528
560
|
// Bob (host-compat R1): its skills page documents only `name`/`description` — no
|
|
529
561
|
// `disable-model-invocation` — and giving it the Claude-dialect alias SKILL.md (the
|
|
530
562
|
// `host === 'claude'` branch below) would be an unverified key PLUS a visible duplicate
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest rel -> path on disk decisions — the small set of rules that turn an `owned` list
|
|
3
|
+
* entry (a POSIX-spelled relative path, read straight off a `.geneseed-manifest.json` that
|
|
4
|
+
* sits inside the install) into something safe to act on.
|
|
5
|
+
*
|
|
6
|
+
* `ownedWithin` is the first tenant: both `uninstall.mjs`'s owned-file walk and the generator
|
|
7
|
+
* driver's prune read a manifest's `owned` list and must agree on the one security boundary
|
|
8
|
+
* neither may skip — a manifest is a file on disk, and per-repo ones live inside the very repo
|
|
9
|
+
* a build runs over, so a cloned repo's manifest is as untrusted as any other file it ships.
|
|
10
|
+
*
|
|
11
|
+
* `safeLeaf` is the second: `ownedWithin` only proves a REL resolves inside the root, which
|
|
12
|
+
* says nothing about what sits at that path. A per-repo manifest can ship a rel that resolves
|
|
13
|
+
* inside the root AND is itself a symlink or a hard link out — `claimer` runs `safeLeaf` on
|
|
14
|
+
* every destination before it ever claims one.
|
|
15
|
+
*
|
|
16
|
+
* `claimLeaf` is `safeLeaf` plus the one sentence every refusal must print, because `claimer`
|
|
17
|
+
* is not the only writer whose path lands in `owned`: `writeVersion`, `shipLeanLaws`, and a
|
|
18
|
+
* handful of direct `writeText` calls in `emit-claude.mjs`/`emit-opencode.mjs` (the Bob rules
|
|
19
|
+
* stub, the project `.gitignore`, the OpenCode-global `AGENT.md`) push a rel into `owned`
|
|
20
|
+
* too, and the spec's intent — never write through a linked owned file — covers all of them,
|
|
21
|
+
* not just the ones that happen to go through a `claim()` closure.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { resolvePath } from './hosts.mjs';
|
|
28
|
+
import { within } from '../lib/paths.mjs';
|
|
29
|
+
import { isFile } from '../lib/fs.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
33
|
+
* `owned` goes through (uninstall, both deactivates, build prune (`pruneOwned` in js/build/driver.mjs)).
|
|
34
|
+
*
|
|
35
|
+
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
36
|
+
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
37
|
+
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
38
|
+
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
39
|
+
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
|
|
40
|
+
* on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
|
|
41
|
+
* besides, which is why the guard runs on the resolved path rather than the lexical one.
|
|
42
|
+
*/
|
|
43
|
+
export function ownedWithin(base, rels) {
|
|
44
|
+
const rroot = resolvePath(base);
|
|
45
|
+
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when `dest` is safe for `claimer` to (over)write: not itself a link, not a second name
|
|
50
|
+
* for a file with other names, and — once symlinks on its existing ancestor resolve — still
|
|
51
|
+
* inside `root`. False for the shape the leaf guard exists to catch: a cloned repo's manifest
|
|
52
|
+
* naming a rel that resolves inside the config root (so `ownedWithin` lets it through) at a
|
|
53
|
+
* path that is ITSELF a symlink or hard link pointing somewhere else, such as
|
|
54
|
+
* `.opencode/skills/x/SKILL.md` symlinked to `~/.bashrc` with a manifest entry claiming it.
|
|
55
|
+
*
|
|
56
|
+
* `lstatSync(dest, { throwIfNoEntry: false })`, never `existsSync` first — a DANGLING symlink
|
|
57
|
+
* (the attack's own shape: the real target need not exist yet for the write to land on it the
|
|
58
|
+
* moment something else creates it there) reads as ABSENT to `existsSync` and as a symlink to
|
|
59
|
+
* `lstat`, so `existsSync` is exactly the check that misses this case.
|
|
60
|
+
*
|
|
61
|
+
* `nlink` is checked only for a regular file. A directory's `nlink` is routinely > 1 — every
|
|
62
|
+
* child subdirectory's own `..` entry adds one — so gating on `isFile()` is what keeps an
|
|
63
|
+
* ordinary nested skill folder from tripping the hard-link branch on its own directories.
|
|
64
|
+
*
|
|
65
|
+
* A symlinked ROOT — `~/.claude` as a dotfiles repo's own symlink, or a `CLAUDE_CONFIG_DIR`
|
|
66
|
+
* env override pointing at one (`js/hosts/link.mjs` links the `geneseed` CLI binary onto
|
|
67
|
+
* PATH, a different concern entirely, and never a config root) — is NOT this shape:
|
|
68
|
+
* `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
|
|
69
|
+
* ordinary way through a linked root still resolves inside it and stays allowed — only the
|
|
70
|
+
* LEAF itself being a link is refused.
|
|
71
|
+
*/
|
|
72
|
+
export function safeLeaf(root, dest) {
|
|
73
|
+
const rroot = resolvePath(root);
|
|
74
|
+
const st = lstatSync(dest, { throwIfNoEntry: false });
|
|
75
|
+
if (st) {
|
|
76
|
+
if (st.isSymbolicLink()) return false;
|
|
77
|
+
if (st.isFile() && st.nlink > 1) return false;
|
|
78
|
+
}
|
|
79
|
+
return within(resolvePath(dest), rroot);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `safeLeaf(root, dest)`, and on false the warning line every refusal prints — ONE sentence,
|
|
84
|
+
* so `claimer` and every direct owned-path writer below it say the same thing rather than
|
|
85
|
+
* each inventing its own wording. `rel` is the manifest spelling (POSIX, root-relative);
|
|
86
|
+
* callers that already have it (every one of them, since it is what they are about to push
|
|
87
|
+
* into `owned`) pass it straight through instead of re-deriving it from `dest`.
|
|
88
|
+
*/
|
|
89
|
+
export function claimLeaf(root, dest, rel) {
|
|
90
|
+
if (safeLeaf(root, dest)) return true;
|
|
91
|
+
process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
|
|
92
|
+
+ 'regular files it owns\n');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* sha256 hex of the bytes on disk at `p`, with every `\r\n` folded to `\n` first. The fold is
|
|
98
|
+
* the point: a committed install is checked out by git with whatever line endings that clone's
|
|
99
|
+
* `core.autocrlf` / `.gitattributes` / OS asks for, and a teammate on the other OS gets every
|
|
100
|
+
* owned file's endings flipped without touching a byte of content. Hashed raw, that read as an
|
|
101
|
+
* edit to every file and the next rebuild froze the harness (measured: 95 false "kept your
|
|
102
|
+
* edited", owned 106 -> 11). A lone `\r` is not folded — git never writes one, so it is content.
|
|
103
|
+
* `latin1` maps each byte to one char and back, so the fold is byte-exact on any file, text or not.
|
|
104
|
+
*/
|
|
105
|
+
export function fileHash(p) {
|
|
106
|
+
const folded = readFileSync(p).toString('latin1').replaceAll('\r\n', '\n');
|
|
107
|
+
return createHash('sha256').update(folded, 'latin1').digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
|
|
112
|
+
* containment boundary every other owned-rel walk goes through) and a file that still exists —
|
|
113
|
+
* a rel a prune is about to delete, or one a crafted manifest named outside root, is skipped
|
|
114
|
+
* rather than hashed. Keys land in the same sorted order the manifest writers already give
|
|
115
|
+
* `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
|
|
116
|
+
* tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
|
|
117
|
+
*/
|
|
118
|
+
export function ownedHashes(root, owned) {
|
|
119
|
+
const hashes = {};
|
|
120
|
+
for (const rel of ownedWithin(root, owned).sort()) {
|
|
121
|
+
const p = path.join(root, rel);
|
|
122
|
+
if (isFile(p)) hashes[rel] = fileHash(p);
|
|
123
|
+
}
|
|
124
|
+
return hashes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `/^[0-9a-f]{64}$/` — the only shape a hash this module wrote ever takes. */
|
|
128
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `doc.hashes` read back defensively: a manifest is a file on disk, as untrusted as any other
|
|
132
|
+
* (`ownedWithin`'s own docblock), so a hand-edited or corrupt `hashes` must read as "nothing
|
|
133
|
+
* recorded" rather than crash or poison a comparison with a value that was never really a
|
|
134
|
+
* sha256. ANY malformed entry zeroes the whole map rather than only dropping that one key —
|
|
135
|
+
* a map that is wrong in one place is not trustworthy in the rest either.
|
|
136
|
+
*/
|
|
137
|
+
export function hashesOf(doc) {
|
|
138
|
+
const h = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.hashes : undefined;
|
|
139
|
+
if (!h || typeof h !== 'object' || Array.isArray(h)) return {};
|
|
140
|
+
for (const v of Object.values(h)) {
|
|
141
|
+
if (typeof v !== 'string' || !HEX64.test(v)) return {};
|
|
142
|
+
}
|
|
143
|
+
return { ...h };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where one owned file stands against its recorded hash — the table later tasks (rebuild,
|
|
148
|
+
* prune, uninstall) will branch on to stop clobbering a user's edit to a Geneseed-owned file.
|
|
149
|
+
* `'unknown'` is deliberately distinct from `'unmodified'`: an older manifest with no `hashes`
|
|
150
|
+
* entry for this rel (today's installs, pre-Task-3) must not be read as "proven untouched" —
|
|
151
|
+
* see the constraint that a manifest without `hashes` keeps today's behaviour exactly.
|
|
152
|
+
*/
|
|
153
|
+
export function ownedState(root, rel, hashes) {
|
|
154
|
+
if (!Object.hasOwn(hashes, rel)) return 'unknown';
|
|
155
|
+
const expected = hashes[rel];
|
|
156
|
+
const p = path.join(root, rel);
|
|
157
|
+
if (!isFile(p)) return 'missing';
|
|
158
|
+
return fileHash(p) === expected ? 'unmodified' : 'modified';
|
|
159
|
+
}
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -802,6 +802,10 @@ export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [],
|
|
|
802
802
|
matcher: 'Write|Edit|NotebookEdit',
|
|
803
803
|
hooks: [{ type: 'command', command: ruleGate, ...onFailureBlock }],
|
|
804
804
|
},
|
|
805
|
+
// Usage counting (js/hosts/usage.mjs): `tool-gate` records a Skill or subagent call and
|
|
806
|
+
// judges nothing else about it. `Task` is the subagent tool's older name. No
|
|
807
|
+
// `onFailure: 'block'`: a counter that cannot start must never stop a skill.
|
|
808
|
+
{ matcher: 'Skill|Agent|Task', hooks: [{ type: 'command', command: `${run} tool-gate --root "${cfg}"` }] },
|
|
805
809
|
],
|
|
806
810
|
// One matcher-less group, not a `startup|clear` / `resume|compact` split (Claude verdict
|
|
807
811
|
// I4): every SessionStart source — `startup`, `resume`, `clear`, `compact` and `fork` —
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH SKILLS AND AGENTS ACTUALLY FIRE — the writer behind the Overview's "What fires" chart.
|
|
3
|
+
*
|
|
4
|
+
* One line per Skill or subagent call, appended to `<install>/notebook/usage.jsonl`:
|
|
5
|
+
* `{"ts":"2026-10-13","kind":"skill","name":"develop"}`. Counts only — never the prompt, the
|
|
6
|
+
* arguments or a path. The day, not the instant: a count per day is all the chart draws, and a
|
|
7
|
+
* coarser stamp is less of a record of someone's working hours.
|
|
8
|
+
*
|
|
9
|
+
* ON THE HOOK PATH, so it is cheap and silent: it imports only node builtins, prints nothing (a
|
|
10
|
+
* printed byte would turn a gate permissive), and swallows every error — a counter that fails
|
|
11
|
+
* must never block, slow or change a tool call. It rides `tool-gate`, the verb Bob already runs
|
|
12
|
+
* on every tool and Claude/OpenClaude route their `Skill|Agent|Task` matcher to, so no host pays
|
|
13
|
+
* an extra process for it. The OpenCode guard plugin carries the same rules inline (a plugin
|
|
14
|
+
* cannot import from here); `tests/unit/usage.test.mjs` holds both to one table.
|
|
15
|
+
*
|
|
16
|
+
* Off when `GENESEED_NO_USAGE` is set or the install holds a `.usage-off` marker.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const USAGE_LEDGER = path.join('notebook', 'usage.jsonl');
|
|
22
|
+
export const USAGE_OFF = '.usage-off';
|
|
23
|
+
|
|
24
|
+
const NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `{kind, name}` for a skill or subagent call, else null. Reads the Claude shape (`tool_name` /
|
|
28
|
+
* `tool_input`: `Skill` → `skill`, `Agent` or the older `Task` → `subagent_type`), OpenCode's
|
|
29
|
+
* (`skill` → `name`, `task` → `subagent_type`) and, best-effort, Bob's (`tool` / `input`, any
|
|
30
|
+
* tool whose name contains "skill"): Bob's skill tool name is unverified, no Bob runs here.
|
|
31
|
+
*/
|
|
32
|
+
export function usageOf(payload) {
|
|
33
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
34
|
+
const tool = String(payload.tool_name || payload.tool || '').toLowerCase();
|
|
35
|
+
const ti = payload.tool_input || payload.input || payload.args || {};
|
|
36
|
+
let kind = null;
|
|
37
|
+
let name = null;
|
|
38
|
+
if (!tool.startsWith('mcp') && tool.includes('skill')) {
|
|
39
|
+
kind = 'skill';
|
|
40
|
+
name = ti.skill ?? ti.name ?? ti.skill_name;
|
|
41
|
+
} else if (tool === 'agent' || tool === 'task') {
|
|
42
|
+
kind = 'agent';
|
|
43
|
+
name = ti.subagent_type ?? ti.agent;
|
|
44
|
+
}
|
|
45
|
+
if (!kind || typeof name !== 'string') return null;
|
|
46
|
+
name = name.trim().replace(/^\/+/, '');
|
|
47
|
+
return NAME_RE.test(name) ? { kind, name } : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Append one line for `payload` under `root`, if it is a counted call. Never throws. */
|
|
51
|
+
export function recordUse(root, payload, now = new Date()) {
|
|
52
|
+
try {
|
|
53
|
+
if (!root || process.env.GENESEED_NO_USAGE) return;
|
|
54
|
+
const use = usageOf(payload);
|
|
55
|
+
if (!use) return;
|
|
56
|
+
if (existsSync(path.join(root, USAGE_OFF))) return;
|
|
57
|
+
const dir = path.join(root, 'notebook');
|
|
58
|
+
if (!existsSync(dir)) return;
|
|
59
|
+
appendFileSync(path.join(root, USAGE_LEDGER),
|
|
60
|
+
`${JSON.stringify({ ts: now.toISOString().slice(0, 10), ...use })}\n`);
|
|
61
|
+
} catch { /* a counter never fails a tool call */ }
|
|
62
|
+
}
|
|
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import {
|
|
11
11
|
ALIASES_RE, PLUGIN_SRC, RETIRED_SKILL_IDS, ROOT, SRC, THEMES, WORKFLOW_SRC, aliasesOf, knownSkillIds,
|
|
12
12
|
} from '../build/source.mjs';
|
|
13
|
-
import { CLAUDE_STYLE, liveRecordedHooks } from '../hosts/hosts.mjs';
|
|
13
|
+
import { CLAUDE_STYLE, HOSTS, liveRecordedHooks } from '../hosts/hosts.mjs';
|
|
14
14
|
import { claudeCfg, installTargets } from '../hosts/installs.mjs';
|
|
15
15
|
import { VENDORED_SKILL_DIRS } from '../hosts/native.mjs';
|
|
16
16
|
import { COMMAND_SET } from '../hosts/opencode.mjs';
|
|
@@ -507,6 +507,81 @@ export function moduleMapProblems() {
|
|
|
507
507
|
return problems;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Each host's label as the two hand-written tables below spell it, keyed by `HOSTS`' `host` id.
|
|
512
|
+
*
|
|
513
|
+
* Local to this arm rather than a column on `HOSTS` (`js/hosts/hosts.mjs`): `HOSTS` is the
|
|
514
|
+
* machine-facing table (config dirs, project markers, catalog flags), and a doc-table LABEL is
|
|
515
|
+
* a fact about two Markdown files, not about an install.
|
|
516
|
+
*/
|
|
517
|
+
const HOST_LABELS = { opencode: 'OpenCode', claude: 'Claude Code', bob: 'Bob', openclaude: 'OpenClaude' };
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The two hand-written, one-column-per-host tables, and the non-host columns each one carries
|
|
521
|
+
* (a header cell that is neither a `HOST_LABELS` value nor one of these is an unknown label
|
|
522
|
+
* that LOOKS like it was meant to be a host column).
|
|
523
|
+
*/
|
|
524
|
+
const HOST_MATRIX_FILES = [
|
|
525
|
+
{ rel: 'docs/concepts/host-matrix.md', otherCols: new Set(['Capability']) },
|
|
526
|
+
{ rel: 'docs/concepts/enforcement-table.md', otherCols: new Set(['id', 'Pack']) },
|
|
527
|
+
];
|
|
528
|
+
|
|
529
|
+
/** The first `| … |` table row of `text`, as trimmed, non-empty cells — its header. */
|
|
530
|
+
function firstTableHeader(text) {
|
|
531
|
+
for (const line of text.split('\n')) {
|
|
532
|
+
const t = line.trim();
|
|
533
|
+
if (t.startsWith('|')) return t.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* `js/README.md`'s sibling gate for the two hand-written host tables: each one's header row
|
|
540
|
+
* must name exactly the hosts `HOSTS` lists, by their `HOST_LABELS` spelling — no fewer (a new
|
|
541
|
+
* host shipped with no column) and no stray cell that merely LOOKS like a host label (a typo,
|
|
542
|
+
* or a host renamed in `HOST_LABELS` but not in the doc).
|
|
543
|
+
*
|
|
544
|
+
* A host with no `HOST_LABELS` entry is its own problem, independent of either file: nothing
|
|
545
|
+
* in a table header can be compared against a label that does not exist.
|
|
546
|
+
*
|
|
547
|
+
* `root` is a parameter (default: `ROOT`) so a test can point this at a temp copy with one
|
|
548
|
+
* column removed, rather than mutating the real docs tree.
|
|
549
|
+
*/
|
|
550
|
+
export function hostMatrixProblems(root = ROOT) {
|
|
551
|
+
const problems = [];
|
|
552
|
+
const expected = new Set();
|
|
553
|
+
for (const h of HOSTS) {
|
|
554
|
+
const label = HOST_LABELS[h.host];
|
|
555
|
+
if (!label) { problems.push(`[authoring] host ${h.host} has no HOST_LABELS entry`); continue; }
|
|
556
|
+
expected.add(label);
|
|
557
|
+
}
|
|
558
|
+
for (const { rel, otherCols } of HOST_MATRIX_FILES) {
|
|
559
|
+
let text;
|
|
560
|
+
try {
|
|
561
|
+
text = readText(path.join(root, ...rel.split('/')));
|
|
562
|
+
} catch (e) {
|
|
563
|
+
problems.push(`[authoring] ${rel} is unreadable: ${e.message}`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const header = firstTableHeader(text);
|
|
567
|
+
if (!header) {
|
|
568
|
+
problems.push(`[authoring] ${rel} has no table header row`);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const present = new Set(header);
|
|
572
|
+
for (const label of [...expected].sort()) {
|
|
573
|
+
if (!present.has(label)) problems.push(`[authoring] ${rel} has no column for ${label}`);
|
|
574
|
+
}
|
|
575
|
+
for (const cell of header) {
|
|
576
|
+
if (!expected.has(cell) && !otherCols.has(cell)) {
|
|
577
|
+
problems.push(`[authoring] ${rel} column '${cell}' is not a host HOST_LABELS names or `
|
|
578
|
+
+ 'a known non-host column — a stale or misspelled label');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return problems;
|
|
583
|
+
}
|
|
584
|
+
|
|
510
585
|
/**
|
|
511
586
|
* The AI Harness Scorecard checks this repo passes, written out — the ratchet's floor.
|
|
512
587
|
*
|