geneseed 4.0.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 (115) hide show
  1. package/CHANGELOG.md +66 -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/HOW-OPENCODE-LOADS.md +35 -0
  6. package/adapters/opencode/plugins/geneseed-activity.js +105 -2
  7. package/adapters/opencode/plugins/geneseed-context.js +96 -1
  8. package/adapters/opencode/plugins/geneseed-guard.js +64 -1
  9. package/adapters/opencode/plugins/geneseed-learn.js +127 -14
  10. package/adapters/opencode/plugins/geneseed-notify.js +46 -1
  11. package/adapters/opencode/plugins/geneseed-ponytail.js +44 -2
  12. package/adapters/opencode/plugins/geneseed-workflow.js +124 -24
  13. package/docs/guides/mcp-filesystem.md +2 -2
  14. package/docs/guides/mcp-gitlab.md +2 -2
  15. package/docs/guides/mcp-markitdown.md +3 -3
  16. package/docs/guides/mcp.md +7 -7
  17. package/docs/guides/project-context.md +1 -1
  18. package/docs/guides/uninstall.md +2 -0
  19. package/docs/guides/upgrade.md +2 -0
  20. package/docs/reference/opencode-plugins.md +9 -0
  21. package/docs/token-footprint.md +1 -1
  22. package/docs/understand/take-it-out.md +1 -1
  23. package/harness.config.json +1 -1
  24. package/js/README.md +6 -0
  25. package/js/build/driver.mjs +47 -11
  26. package/js/build/emit-claude.mjs +22 -10
  27. package/js/build/emit-common.mjs +9 -3
  28. package/js/build/emit-opencode.mjs +18 -11
  29. package/js/build/source.mjs +1 -0
  30. package/js/build/version.mjs +10 -3
  31. package/js/cli-table.json +16 -1
  32. package/js/hosts/hooks.mjs +4 -0
  33. package/js/hosts/installs.mjs +1 -1
  34. package/js/hosts/mcp.mjs +23 -152
  35. package/js/hosts/native.mjs +38 -6
  36. package/js/hosts/opencode.mjs +1 -1
  37. package/js/hosts/owned.mjs +159 -0
  38. package/js/hosts/settings.mjs +4 -0
  39. package/js/hosts/usage.mjs +62 -0
  40. package/js/inspect/checks-repo.mjs +76 -1
  41. package/js/inspect/cost.mjs +83 -0
  42. package/js/inspect/doctor.mjs +2 -1
  43. package/js/inspect/inventory.mjs +1 -1
  44. package/js/maintain/uninstall.mjs +77 -42
  45. package/js/web/actions.mjs +15 -77
  46. package/js/web/api.mjs +74 -4
  47. package/js/web/gates-api.mjs +44 -0
  48. package/js/web/handler.mjs +2 -2
  49. package/js/web/routes.mjs +9 -4
  50. package/js/web/update-check.mjs +61 -0
  51. package/js/web/usage-api.mjs +95 -0
  52. package/package.json +1 -1
  53. package/registry.json +18 -25
  54. package/src/AGENT.md.tmpl +0 -1
  55. package/src/agents/_template.md +2 -1
  56. package/src/agents/researcher.md +2 -1
  57. package/src/agents/reviewer.md +9 -4
  58. package/src/agents/tester.md +3 -3
  59. package/src/doctrines/rigor.md +8 -7
  60. package/src/skills/brainstorm/builder-check.md +16 -0
  61. package/src/skills/brainstorm.md +19 -11
  62. package/src/skills/daydream/SKILL.md +1 -1
  63. package/src/skills/daydream/VENDOR.md +4 -0
  64. package/src/skills/debug/ci.md +4 -4
  65. package/src/skills/debug.md +4 -4
  66. package/src/skills/develop.md +15 -4
  67. package/src/skills/fresh-eyes.md +1 -1
  68. package/src/skills/geneseed-code-review.md +13 -3
  69. package/src/skills/handoff.md +1 -1
  70. package/src/skills/parallel-agents.md +4 -4
  71. package/src/skills/pipeline.md +3 -3
  72. package/src/skills/plan.md +18 -6
  73. package/src/skills/review-response.md +5 -2
  74. package/src/skills/security-audit.md +7 -1
  75. package/src/skills/ship.md +2 -1
  76. package/src/skills/wayfinder.md +25 -6
  77. package/src/skills/worktree.md +7 -5
  78. package/themes/_TEMPLATE.json +0 -1
  79. package/themes/biker.json +0 -1
  80. package/themes/commentator.json +0 -1
  81. package/themes/cyberpunk.json +0 -1
  82. package/themes/gamer.json +0 -1
  83. package/themes/imperial.json +0 -1
  84. package/themes/joker.json +0 -1
  85. package/themes/marvin.json +0 -1
  86. package/themes/mean.json +0 -1
  87. package/themes/military.json +0 -1
  88. package/themes/neutral.json +0 -1
  89. package/themes/pirate.json +0 -1
  90. package/themes/sports.json +0 -1
  91. package/themes/verstappen.json +0 -1
  92. package/themes/wizard.json +0 -1
  93. package/web/dist/assets/{Activity-BCtqxjfy.js → Activity-ClEuDk9q.js} +1 -1
  94. package/web/dist/assets/{ActivityDetail-Ba3Oo4dE.js → ActivityDetail-CS9IuJlw.js} +1 -1
  95. package/web/dist/assets/Dashboard-BiRFjkBS.js +2 -0
  96. package/web/dist/assets/Docs-CD20mrVv.js +5 -0
  97. package/web/dist/assets/{GreenhouseView-DnJ1Ggp1.js → GreenhouseView-CL3PubNb.js} +1 -1
  98. package/web/dist/assets/{Installs-CWoNKFBk.js → Installs-BFLOVV0r.js} +2 -2
  99. package/web/dist/assets/{JournalView-g6-bw4c8.js → JournalView-BvsSkjSn.js} +1 -1
  100. package/web/dist/assets/{Laws-DkVWR1tb.js → Laws-BVoYOkVW.js} +2 -2
  101. package/web/dist/assets/{Library-CK9HZzjV.js → Library-Bl7xqOlh.js} +1 -1
  102. package/web/dist/assets/{Loops-BP98SMOH.js → Loops-Cnpep8FX.js} +1 -1
  103. package/web/dist/assets/{OperatorHudView-DI_n12M9.js → OperatorHudView-B0AMVmtV.js} +1 -1
  104. package/web/dist/assets/{Personal-Bk89f13a.js → Personal-CBg_x2kA.js} +1 -1
  105. package/web/dist/assets/{Tabs-3WuYIE1b.js → Tabs-2gX8I69E.js} +1 -1
  106. package/web/dist/assets/api-Bp5eW_6m.js +1 -0
  107. package/web/dist/assets/{drawable-oCUeq9e5.js → drawable-KPn0rKI7.js} +1 -1
  108. package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
  109. package/web/dist/assets/index-DoD_g2CM.js +2 -0
  110. package/web/dist/index.html +3 -3
  111. package/src/skills/herdr.md +0 -154
  112. package/web/dist/assets/Dashboard-DpHza68-.js +0 -2
  113. package/web/dist/assets/Docs-Bw_-UK0O.js +0 -5
  114. package/web/dist/assets/api-CxgfYu18.js +0 -1
  115. package/web/dist/assets/index-CiXwChvf.js +0 -2
package/js/hosts/mcp.mjs CHANGED
@@ -1,22 +1,15 @@
1
1
  /**
2
- * MCP server presets, state and config I/O — `rituals/_harness_mcp.py`'s MCP block.
2
+ * MCP server state and config reading — `rituals/_harness_mcp.py`'s MCP block, minus its writers.
3
3
  *
4
- * WHAT THE PLAN SAID AND WHAT READING FOUND. The P6f plan scored `api_mcp` + `api_mcp_toggle`
5
- * at "~90 new, already in js/: `_mcp_load`/`readJsonc` (P6c), `settings.mjs`'s atomic write".
6
- * Measured against `js/`: `readJsonc` had crossed and NOTHING ELSE HAD. `js/web/api.mjs`
7
- * carries a five-line `mcpLoad` that is the no-host, comment-tolerant reader P6c needed for
8
- * `geneseed-wiki.jsonc`, which is one arm of one of these thirteen functions. `_MCP_PRESETS`,
9
- * `_mcp_servers_key`, `_mcp_preset_block`, `_mcp_apply`, `_mcp_state`, `_mcp_set_enabled`,
10
- * `_mcp_load`'s host fork, `_mcp_commented`, `_mcp_save`, `_mcp_config_for`,
11
- * `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
12
- * new. The endpoints on top of them are the small half.
4
+ * READ-ONLY BY DECISION (2026-10). Which MCP servers an install wires depends on the user and on
5
+ * the host, so Geneseed shows what each config already holds and where that file lives, and the
6
+ * user edits it. The three presets (`MCP_PRESETS`), the block shaper, the applier, the
7
+ * enable/disable flip and the atomic saver went with the console's Add button and toggle;
8
+ * `docs/guides/mcp*.md` carry the blocks a user copies by hand. Nothing here writes.
13
9
  *
14
- * THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
15
- * `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
16
- * install that config is `~/.claude.json`, which holds projects and history far beyond MCP
17
- * wiring. A `JSON.parse` would turn every `"temperature": 1.0` in somebody's real file into
18
- * `1` on the way back out. `readJsonc` already learned this; the strict-JSON fork has to
19
- * learn it separately, because it does not go through `readJsonc` at all.
10
+ * THE READ IS STILL HOST-SHAPED. OpenCode nests servers under `mcp` with an `enabled` flag and
11
+ * may be `.jsonc`; the Claude-shaped three use `mcpServers`, are strict JSON, and have no flag —
12
+ * present IS enabled. `uninstall` reads through `mcpLoad` too.
20
13
  *
21
14
  * A JsonNumber IS AN OBJECT TO `typeof`, which is why `isDict` (js/lib/json.mjs) excludes
22
15
  * the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
@@ -31,7 +24,7 @@ import { installState, installTargets } from './installs.mjs';
31
24
  import { printOut, printErr } from '../lib/fs.mjs';
32
25
  import { isDict } from '../lib/json.mjs';
33
26
  import { padEndToWidth } from '../lib/text.mjs';
34
- import { atomicWriteJson, loadJsonObject, opencodeTarget } from './settings.mjs';
27
+ import { loadJsonObject, opencodeTarget } from './settings.mjs';
35
28
 
36
29
 
37
30
  /** `dict.get(key, default)`. */
@@ -40,52 +33,6 @@ const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[k
40
33
  /** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
41
34
  const FLAGLESS = CLAUDE_STYLE;
42
35
 
43
- /**
44
- * `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
45
- *
46
- * The `desc` strings go on the wire verbatim (`api_mcp` returns them), so they are the one
47
- * place in this file where a byte matters more than the shape. Insertion order is the
48
- * listing order.
49
- */
50
- export const MCP_PRESETS = {
51
- markitdown: {
52
- label: 'MarkItDown',
53
- desc: 'PDF / Office / HTML -> Markdown for the ingest skill. Runs via `uvx` '
54
- + '(needs uv; zero-install, fetches on first call). No uv? Switch the command '
55
- + 'to ["markitdown-mcp"] after `pipx install markitdown-mcp`. Exposes one '
56
- + 'tool: convert_to_markdown(uri).',
57
- block: { type: 'local', command: ['uvx', 'markitdown-mcp'], enabled: true },
58
- },
59
- gitlab: {
60
- label: 'GitLab',
61
- desc: 'GitLab repo / MR / issue / CI tools via @zereight/mcp-gitlab (npx, no '
62
- + 'install). Edit GITLAB_PERSONAL_ACCESS_TOKEN (scopes: api, read_repository) '
63
- + 'and GITLAB_API_URL before use. For a second instance (a self-hosted server), '
64
- + 'copy this block by hand under a new name and point it at the other URL — '
65
- + 'docs/guides/mcp-gitlab.md has the pattern.',
66
- block: {
67
- type: 'local',
68
- command: ['npx', '-y', '@zereight/mcp-gitlab'],
69
- environment: { GITLAB_PERSONAL_ACCESS_TOKEN: '',
70
- GITLAB_API_URL: 'https://gitlab.com/api/v4' },
71
- enabled: true,
72
- },
73
- },
74
- filesystem: {
75
- label: 'Filesystem',
76
- desc: 'Read/write files under explicitly allowed directories via '
77
- + '@modelcontextprotocol/server-filesystem (npx, no install). Replace the '
78
- + 'trailing path arg(s) with only the dir(s) the agent may touch '
79
- + '(least-privilege).',
80
- block: {
81
- type: 'local',
82
- command: ['npx', '-y', '@modelcontextprotocol/server-filesystem',
83
- '/path/to/allowed/dir'],
84
- enabled: true,
85
- },
86
- },
87
- };
88
-
89
36
  /** `_mcp_servers_key` — OpenCode nests servers under `mcp`, the other three `mcpServers`. */
90
37
  const serversKey = (host) => (FLAGLESS.includes(host) ? 'mcpServers' : 'mcp');
91
38
 
@@ -95,50 +42,11 @@ const serversOf = (config, host) => {
95
42
  return isDict(s) ? s : {};
96
43
  };
97
44
 
98
- /**
99
- * `_mcp_preset_block` — the block to WRITE for a preset, shaped for `host`.
100
- *
101
- * OpenCode takes it verbatim. The flagless three split the command list into `command`
102
- * (head) + `args` (tail) and rename `environment` to `env`.
103
- */
104
- export function mcpPresetBlock(name, host = 'opencode') {
105
- const block = { ...MCP_PRESETS[name].block };
106
- if (!FLAGLESS.includes(host)) return block;
107
- const cmd = [...(block.command || [])];
108
- const out = { command: cmd.length ? cmd[0] : '', args: cmd.slice(1) };
109
- const env = block.environment;
110
- if (env) out.env = { ...env };
111
- return out;
112
- }
113
-
114
- /**
115
- * `_mcp_apply` — a COPY of `config` with server `name` set to `block`, or removed when
116
- * `block` is null. Never touches another key; drops an emptied servers map.
117
- *
118
- * The key ORDER is behaviour, not cosmetics: `setdefault` appends `$schema` at the end of a
119
- * fresh dict and leaves an existing one where it is, and `_mcp_save` writes the result out
120
- * as the file the user then reads. `{...config}` plus a guarded assignment is the same rule.
121
- */
122
- export function mcpApply(config, name, block, host = 'opencode') {
123
- const cfg = { ...config };
124
- const key = serversKey(host);
125
- if (host === 'opencode' && !Object.hasOwn(cfg, '$schema')) {
126
- cfg.$schema = 'https://opencode.ai/config.json';
127
- }
128
- const servers = { ...serversOf(cfg, host) };
129
- if (block === null) delete servers[name];
130
- else servers[name] = block;
131
- if (Object.keys(servers).length) cfg[key] = servers;
132
- else delete cfg[key];
133
- return cfg;
134
- }
135
-
136
45
  /**
137
46
  * `_mcp_state` — 'enabled' | 'disabled' | 'absent'.
138
47
  *
139
48
  * OpenCode: a present server with no explicit `enabled` counts as enabled (its default).
140
- * The flagless three have no such key, so present IS enabled and 'disabled' never occurs —
141
- * toggling one off removes its entry instead.
49
+ * The flagless three have no such key, so present IS enabled and 'disabled' never occurs.
142
50
  */
143
51
  export function mcpState(config, name, host = 'opencode') {
144
52
  const server = serversOf(config, host)[name];
@@ -147,25 +55,13 @@ export function mcpState(config, name, host = 'opencode') {
147
55
  return dget(server, 'enabled', true) ? 'enabled' : 'disabled';
148
56
  }
149
57
 
150
- /** `_mcp_set_enabled` — flip a present server's enabled-ness. No-op when it is absent. */
151
- export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
152
- const server = serversOf(config, host)[name];
153
- if (!isDict(server)) return config;
154
- if (FLAGLESS.includes(host)) {
155
- return enabled ? config : mcpApply(config, name, null, host);
156
- }
157
- return mcpApply(config, name, { ...server, enabled }, host);
158
- }
159
-
160
58
  /**
161
59
  * `_mcp_load` — a config as a dict; `{}` when missing, unreadable, unparseable or not an
162
60
  * object.
163
61
  *
164
62
  * The host fork is the whole function. OpenCode is read comment-tolerantly because a
165
63
  * hand-maintained `opencode.jsonc` carries `//`; the other three are read STRICTLY, because
166
- * their configs are plain JSON by contract, and a file that does not parse as such — for a
167
- * Claude global install, one that holds projects and history — is not one to rewrite from a
168
- * best-effort reading. (The comment stripper's trailing-comma pass IS string-aware; an older
64
+ * their configs are plain JSON by contract. (The comment stripper's trailing-comma pass IS string-aware; an older
169
65
  * version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
170
66
  */
171
67
  export function mcpLoad(p, host = 'opencode') {
@@ -179,11 +75,6 @@ export function mcpCommented(p) {
179
75
  return loadJsonObject(p).hadComments;
180
76
  }
181
77
 
182
- /** `_mcp_save` — pretty JSON, written ATOMICALLY. The one writer, under its MCP name. */
183
- export function mcpSave(p, config) {
184
- atomicWriteJson(p, config);
185
- }
186
-
187
78
  /**
188
79
  * `_mcp_config_for` — the file an install writes its MCP servers into, or null for a host
189
80
  * that carries no MCP wiring at that scope.
@@ -222,19 +113,9 @@ export function mcpInstallTargets() {
222
113
  return out;
223
114
  }
224
115
 
225
- /** `_mcp_known_names` — the presets first, then any server already in THIS config. */
226
- export function mcpKnownNames(config, host = 'opencode') {
227
- const names = Object.keys(MCP_PRESETS);
228
- const present = isDict(config) ? Object.keys(serversOf(config, host)) : [];
229
- return names.concat(present.filter((n) => !Object.hasOwn(MCP_PRESETS, n)));
230
- }
231
-
232
- /** `_mcp_meta` — (label, description); the bare name and a generic note for an unknown one. */
233
- export function mcpMeta(name) {
234
- const p = MCP_PRESETS[name];
235
- if (p) return [p.label, p.desc];
236
- return [name, 'User-defined MCP server (not a Harness preset). It lives in this '
237
- + "config already — 'e' enables/disables it, Enter removes it."];
116
+ /** The servers THIS config already has, in file order. Nothing it could have. */
117
+ export function mcpServerNames(config, host = 'opencode') {
118
+ return isDict(config) ? Object.keys(serversOf(config, host)) : [];
238
119
  }
239
120
 
240
121
  export { isDict };
@@ -242,22 +123,11 @@ export { isDict };
242
123
  // ---- the verb ----------------------------------------------------------------------------
243
124
 
244
125
  /**
245
- * `geneseed mcp` — the CLI FACE this library never had.
246
- *
247
- * Everything above crossed with the web console rather than with a subcommand: the reference
248
- * kept it as a preset/state library whose only command was the uninstall path, and the one
249
- * way to see which servers an install had wired was to start a server and look. That is the
250
- * gap this verb closes, and it closes it by CALLING the library — the same `mcpInstallTargets`
251
- * / `mcpLoad` / `mcpKnownNames` / `mcpState` / `mcpMeta` sequence the console's read endpoint
252
- * runs, rendered as text instead of as JSON.
126
+ * `geneseed mcp` — the servers each active install has wired, and the file they live in.
253
127
  *
254
- * ponytail: READ-ONLY, on purpose, and this is the ceiling. Wiring a server from here needs
255
- * the console's toggle orchestration, whose load-bearing part is not the toggle at all — it
256
- * is the refusal to rewrite a config it could not parse strictly, because for a global
257
- * Claude install that file holds projects and history and a rewrite from an empty object
258
- * would erase them. A second copy of that guard is a second place for it to be wrong. When a
259
- * terminal toggle is wanted, move the console's non-HTTP core down into this module and have
260
- * both faces call it; do not grow a parallel one here.
128
+ * The same `mcpInstallTargets` / `mcpLoad` / `mcpServerNames` / `mcpState` sequence the
129
+ * console's read endpoint runs, rendered as text instead of as JSON. Read-only like the
130
+ * console: there is nothing here to add or toggle a server with.
261
131
  */
262
132
  export function cmdMcp() {
263
133
  const targets = mcpInstallTargets()
@@ -272,9 +142,10 @@ export function cmdMcp() {
272
142
  if (mcpCommented(p)) {
273
143
  printOut(' this config carries comments — edit it by hand so they survive.\n');
274
144
  }
275
- for (const name of mcpKnownNames(cfg, host)) {
276
- const [lbl] = mcpMeta(name);
277
- printOut(` ${padEndToWidth(mcpState(cfg, name, host), 8)} ${padEndToWidth(name, 12)} ${lbl}\n`);
145
+ const names = mcpServerNames(cfg, host);
146
+ if (!names.length) printOut(' no MCP server configured\n');
147
+ for (const name of names) {
148
+ printOut(` ${padEndToWidth(mcpState(cfg, name, host), 8)} ${name}\n`);
278
149
  }
279
150
  }
280
151
  return 0;
@@ -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
@@ -114,7 +114,7 @@ export const COMMAND_SET = {
114
114
  };
115
115
 
116
116
  /** Mirrors `_build_emit.PONYTAIL_COMMAND_BODY`. */
117
- const PONYTAIL_COMMAND_BODY =
117
+ export const PONYTAIL_COMMAND_BODY =
118
118
  'Ponytail level requested: $ARGUMENTS\n\n'
119
119
  + 'The geneseed-ponytail plugin records this level and, from your next turn, appends '
120
120
  + 'the matching "laziest solution that works" ruleset to your system prompt — honour '
@@ -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
+ }