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.
Files changed (85) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +4 -4
  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/concepts/agents.md +1 -0
  7. package/docs/guides/uninstall.md +2 -0
  8. package/docs/guides/upgrade.md +2 -0
  9. package/docs/token-footprint.md +1 -1
  10. package/docs/understand/take-it-out.md +1 -1
  11. package/harness.config.json +1 -1
  12. package/js/README.md +6 -0
  13. package/js/build/driver.mjs +47 -11
  14. package/js/build/emit-claude.mjs +22 -10
  15. package/js/build/emit-common.mjs +9 -3
  16. package/js/build/emit-opencode.mjs +18 -11
  17. package/js/build/source.mjs +1 -0
  18. package/js/build/version.mjs +10 -3
  19. package/js/cli-table.json +15 -0
  20. package/js/hosts/hooks.mjs +4 -0
  21. package/js/hosts/installs.mjs +1 -1
  22. package/js/hosts/native.mjs +38 -6
  23. package/js/hosts/owned.mjs +159 -0
  24. package/js/hosts/settings.mjs +4 -0
  25. package/js/hosts/usage.mjs +62 -0
  26. package/js/inspect/checks-repo.mjs +76 -1
  27. package/js/inspect/cost.mjs +83 -0
  28. package/js/inspect/doctor.mjs +2 -1
  29. package/js/inspect/inventory.mjs +1 -1
  30. package/js/maintain/uninstall.mjs +77 -42
  31. package/js/web/api.mjs +66 -3
  32. package/js/web/gates-api.mjs +44 -0
  33. package/js/web/routes.mjs +8 -2
  34. package/js/web/update-check.mjs +61 -0
  35. package/js/web/usage-api.mjs +95 -0
  36. package/package.json +1 -1
  37. package/registry.json +25 -25
  38. package/src/AGENT.md.tmpl +1 -1
  39. package/src/agents/performance.md +72 -0
  40. package/src/laws/universal.md +7 -5
  41. package/src/skills/council.md +1 -1
  42. package/src/skills/develop.md +9 -0
  43. package/src/skills/geneseed-code-review.md +5 -1
  44. package/src/skills/security-audit.md +7 -1
  45. package/src/skills/wayfinder.md +25 -6
  46. package/src/skills/worktree.md +5 -5
  47. package/themes/_TEMPLATE.json +1 -1
  48. package/themes/biker.json +1 -1
  49. package/themes/commentator.json +1 -1
  50. package/themes/cyberpunk.json +1 -1
  51. package/themes/gamer.json +1 -1
  52. package/themes/imperial.json +1 -1
  53. package/themes/joker.json +1 -1
  54. package/themes/marvin.json +1 -1
  55. package/themes/mean.json +1 -1
  56. package/themes/military.json +1 -1
  57. package/themes/neutral.json +1 -1
  58. package/themes/pirate.json +1 -1
  59. package/themes/sports.json +1 -1
  60. package/themes/verstappen.json +1 -1
  61. package/themes/wizard.json +1 -1
  62. package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-DDTRkc4M.js} +1 -1
  63. package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-C41RsH-S.js} +1 -1
  64. package/web/dist/assets/Dashboard-B0DJ2oUk.js +2 -0
  65. package/web/dist/assets/Docs-DBj7-n9F.js +5 -0
  66. package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-Bz-Q1Xl-.js} +1 -1
  67. package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BNlWdJJE.js} +2 -2
  68. package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BkwmfkEy.js} +1 -1
  69. package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-CzvMZCUL.js} +2 -2
  70. package/web/dist/assets/Library-BgIpOTHi.js +1 -0
  71. package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-s_NsuNaQ.js} +1 -1
  72. package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-CgeWJnPI.js} +1 -1
  73. package/web/dist/assets/{Personal-DcVA502F.js → Personal-DMM6jfE0.js} +1 -1
  74. package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-CPpcsYDc.js} +1 -1
  75. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  76. package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-D-fIT_ow.js} +1 -1
  77. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  78. package/web/dist/assets/index-DXIa0ZG_.js +2 -0
  79. package/web/dist/index.html +3 -3
  80. package/src/skills/herdr.md +0 -154
  81. package/web/dist/assets/Dashboard-VlIx1-At.js +0 -2
  82. package/web/dist/assets/Docs-6cYYkVeA.js +0 -5
  83. package/web/dist/assets/Library-DBLX-1SD.js +0 -1
  84. package/web/dist/assets/api-CRqWld2H.js +0 -1
  85. 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
- writeText(path.join(cfgDir, 'AGENT.md'), stripCapabilityLinks(agentText));
214
- owned.push('AGENT.md');
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
  }
@@ -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
  /**
@@ -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
  *