geneseed 4.1.0 → 4.2.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 +30 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- package/adapters/opencode/plugins/geneseed-guard.js +31 -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 +18 -25
- package/src/AGENT.md.tmpl +0 -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 +0 -1
- package/themes/biker.json +0 -1
- package/themes/commentator.json +0 -1
- package/themes/cyberpunk.json +0 -1
- package/themes/gamer.json +0 -1
- package/themes/imperial.json +0 -1
- package/themes/joker.json +0 -1
- package/themes/marvin.json +0 -1
- package/themes/mean.json +0 -1
- package/themes/military.json +0 -1
- package/themes/neutral.json +0 -1
- package/themes/pirate.json +0 -1
- package/themes/sports.json +0 -1
- package/themes/verstappen.json +0 -1
- package/themes/wizard.json +0 -1
- package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-ClEuDk9q.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-CS9IuJlw.js} +1 -1
- package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
- package/web/dist/assets/Docs-CD20mrVv.js +5 -0
- package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-CL3PubNb.js} +1 -1
- package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BFLOVV0r.js} +2 -2
- package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BvsSkjSn.js} +1 -1
- package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-BVoYOkVW.js} +2 -2
- package/web/dist/assets/{Library-DBLX-1SD.js → Library-Bl7xqOlh.js} +1 -1
- package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-Cnpep8FX.js} +1 -1
- package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-B0AMVmtV.js} +1 -1
- package/web/dist/assets/{Personal-DcVA502F.js → Personal-CBg_x2kA.js} +1 -1
- package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-2gX8I69E.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-KPn0rKI7.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DoD_g2CM.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/api-CRqWld2H.js +0 -1
- package/web/dist/assets/index-BmjcLIOr.js +0 -2
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
|
*
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT ONE INSTALL COSTS EVERY SESSION, read off the files it emitted — the Overview's
|
|
3
|
+
* always-on figure. Characters, not tokens: the console shows ≈ chars ÷ 4 and says it is an
|
|
4
|
+
* estimate, because a tokenizer would be a vendored dependency for a number that only has to
|
|
5
|
+
* be honest about its size.
|
|
6
|
+
*
|
|
7
|
+
* Four parts, each the thing the host loads before the user types:
|
|
8
|
+
* - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
|
|
9
|
+
* `AGENTS.md`), CRLF decoded exactly as the smoke test decodes it, so the figure
|
|
10
|
+
* and `CARRIER_CEILING` are the same measurement.
|
|
11
|
+
* - `skills` the name + description every native skill lists (aliases included: they are
|
|
12
|
+
* SKILL.md files the host lists like any other).
|
|
13
|
+
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
14
|
+
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
15
|
+
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
20
|
+
import { carriersFor } from '../hosts/installs.mjs';
|
|
21
|
+
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
22
|
+
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
23
|
+
import { isFile, readText } from '../lib/fs.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE ANTI-BLOAT CEILING on the carrier, per footprint — the most a root file may weigh with
|
|
27
|
+
* every doctrine pack active. `tests/unit/emit_smoke.test.mjs` gates every emit against it and the
|
|
28
|
+
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
29
|
+
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
30
|
+
*/
|
|
31
|
+
export const CARRIER_CEILING = { full: 62_076, lean: 38_890 };
|
|
32
|
+
|
|
33
|
+
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
|
37
|
+
* (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
|
|
38
|
+
* escapes are decoded here or one character counts as six.
|
|
39
|
+
*/
|
|
40
|
+
function fmValue(raw) {
|
|
41
|
+
if (!raw) return '';
|
|
42
|
+
if (raw.includes('\\')) {
|
|
43
|
+
try { return JSON.parse(`"${raw}"`); } catch { /* not a JSON escape after all: count as written */ }
|
|
44
|
+
}
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
|
|
49
|
+
function listedChars(file, fallbackName) {
|
|
50
|
+
let fm;
|
|
51
|
+
try { [fm] = frontmatter(decode(file)); } catch { return 0; }
|
|
52
|
+
const name = fmValue(fm.get('name')) || fallbackName;
|
|
53
|
+
return name.length + fmValue(fm.get('description')).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
58
|
+
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
59
|
+
* carrier answers `null` for the whole cost, so the console shows "not measured", not a tiny bar.
|
|
60
|
+
*/
|
|
61
|
+
export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
62
|
+
const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
|
|
63
|
+
if (!carrierPath) return null;
|
|
64
|
+
const carrier = decode(carrierPath).length;
|
|
65
|
+
|
|
66
|
+
let skills = 0;
|
|
67
|
+
const skillsDir = path.join(dir, 'skills');
|
|
68
|
+
for (const name of listDir(skillsDir)) {
|
|
69
|
+
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
70
|
+
if (isFile(f)) skills += listedChars(f, name);
|
|
71
|
+
}
|
|
72
|
+
let agents = 0;
|
|
73
|
+
if (host !== 'bob') {
|
|
74
|
+
const agentsDir = path.join(dir, 'agents');
|
|
75
|
+
for (const name of listDir(agentsDir)) {
|
|
76
|
+
if (name.endsWith('.md')) agents += listedChars(path.join(agentsDir, name), name.slice(0, -3));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let context = 0;
|
|
80
|
+
try { for (const s of sessionFiles(dir)) context += s.text.length; } catch { /* none */ }
|
|
81
|
+
|
|
82
|
+
return { carrier, skills, agents, context, ceiling: CARRIER_CEILING[footprint] ?? null };
|
|
83
|
+
}
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
|
|
|
42
42
|
import {
|
|
43
43
|
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
44
44
|
} from './checks-build.mjs';
|
|
45
|
-
import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
45
|
+
import { hostMatrixProblems, moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
46
46
|
import { NOTE, isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -249,6 +249,7 @@ export function doctorCollect({
|
|
|
249
249
|
problems = problems.concat(ran('openclaude_dual', 'OpenClaude dual harness',
|
|
250
250
|
openclaudeDualHarnessNotes()));
|
|
251
251
|
problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
|
|
252
|
+
problems = problems.concat(ran('host_matrix', 'Host tables', hostMatrixProblems()));
|
|
252
253
|
problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
|
|
253
254
|
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
|
254
255
|
// `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export const SKILL_CLASS = {
|
|
|
106
106
|
// knowledge base, terminal) rather than about the user's code. They sat under
|
|
107
107
|
// `understand` before, which made that class a grab-bag.
|
|
108
108
|
wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
|
|
109
|
-
|
|
109
|
+
'skill-forge': 'harness',
|
|
110
110
|
teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
|
|
111
111
|
};
|
|
112
112
|
|