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.
Files changed (80) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/adapters/claude-code/README.md +0 -21
  5. package/adapters/opencode/plugins/geneseed-guard.js +31 -0
  6. package/docs/guides/uninstall.md +2 -0
  7. package/docs/guides/upgrade.md +2 -0
  8. package/docs/token-footprint.md +1 -1
  9. package/docs/understand/take-it-out.md +1 -1
  10. package/harness.config.json +1 -1
  11. package/js/README.md +6 -0
  12. package/js/build/driver.mjs +47 -11
  13. package/js/build/emit-claude.mjs +22 -10
  14. package/js/build/emit-common.mjs +9 -3
  15. package/js/build/emit-opencode.mjs +18 -11
  16. package/js/build/source.mjs +1 -0
  17. package/js/build/version.mjs +10 -3
  18. package/js/cli-table.json +15 -0
  19. package/js/hosts/hooks.mjs +4 -0
  20. package/js/hosts/installs.mjs +1 -1
  21. package/js/hosts/native.mjs +38 -6
  22. package/js/hosts/owned.mjs +159 -0
  23. package/js/hosts/settings.mjs +4 -0
  24. package/js/hosts/usage.mjs +62 -0
  25. package/js/inspect/checks-repo.mjs +76 -1
  26. package/js/inspect/cost.mjs +83 -0
  27. package/js/inspect/doctor.mjs +2 -1
  28. package/js/inspect/inventory.mjs +1 -1
  29. package/js/maintain/uninstall.mjs +77 -42
  30. package/js/web/api.mjs +66 -3
  31. package/js/web/gates-api.mjs +44 -0
  32. package/js/web/routes.mjs +8 -2
  33. package/js/web/update-check.mjs +61 -0
  34. package/js/web/usage-api.mjs +95 -0
  35. package/package.json +1 -1
  36. package/registry.json +18 -25
  37. package/src/AGENT.md.tmpl +0 -1
  38. package/src/skills/develop.md +9 -0
  39. package/src/skills/geneseed-code-review.md +5 -1
  40. package/src/skills/security-audit.md +7 -1
  41. package/src/skills/wayfinder.md +25 -6
  42. package/src/skills/worktree.md +5 -5
  43. package/themes/_TEMPLATE.json +0 -1
  44. package/themes/biker.json +0 -1
  45. package/themes/commentator.json +0 -1
  46. package/themes/cyberpunk.json +0 -1
  47. package/themes/gamer.json +0 -1
  48. package/themes/imperial.json +0 -1
  49. package/themes/joker.json +0 -1
  50. package/themes/marvin.json +0 -1
  51. package/themes/mean.json +0 -1
  52. package/themes/military.json +0 -1
  53. package/themes/neutral.json +0 -1
  54. package/themes/pirate.json +0 -1
  55. package/themes/sports.json +0 -1
  56. package/themes/verstappen.json +0 -1
  57. package/themes/wizard.json +0 -1
  58. package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-ClEuDk9q.js} +1 -1
  59. package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-CS9IuJlw.js} +1 -1
  60. package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
  61. package/web/dist/assets/Docs-CD20mrVv.js +5 -0
  62. package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-CL3PubNb.js} +1 -1
  63. package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BFLOVV0r.js} +2 -2
  64. package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BvsSkjSn.js} +1 -1
  65. package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-BVoYOkVW.js} +2 -2
  66. package/web/dist/assets/{Library-DBLX-1SD.js → Library-Bl7xqOlh.js} +1 -1
  67. package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-Cnpep8FX.js} +1 -1
  68. package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-B0AMVmtV.js} +1 -1
  69. package/web/dist/assets/{Personal-DcVA502F.js → Personal-CBg_x2kA.js} +1 -1
  70. package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-2gX8I69E.js} +1 -1
  71. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  72. package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-KPn0rKI7.js} +1 -1
  73. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  74. package/web/dist/assets/index-DoD_g2CM.js +2 -0
  75. package/web/dist/index.html +3 -3
  76. package/src/skills/herdr.md +0 -154
  77. package/web/dist/assets/Dashboard-VlIx1-At.js +0 -2
  78. package/web/dist/assets/Docs-6cYYkVeA.js +0 -5
  79. package/web/dist/assets/api-CRqWld2H.js +0 -1
  80. package/web/dist/assets/index-BmjcLIOr.js +0 -2
@@ -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
- /** `_build_render.write_version`. */
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(path.join(out, VERSION_MARKER),
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": []
@@ -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
  }
@@ -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];
@@ -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, and the ones gated on an environment the model cannot see (a herdr pane).
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
- return (dest) => {
479
- if (oldSet === null || cfg === null || !existsSync(dest)) return true;
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)) return true;
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, claim = claimer(oldOwned, cfg, manifestExisted),
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
+ }
@@ -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
+ }
@@ -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
@@ -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
- herdr: 'harness', 'skill-forge': 'harness',
109
+ 'skill-forge': 'harness',
110
110
  teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
111
111
  };
112
112