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/README.md CHANGED
@@ -73,6 +73,7 @@ user co-owns, and the hook verbs those emitted configs then run.
73
73
  | `hooks-context.mjs` | The `context` verb — SessionStart: session files plus the repo's context docs |
74
74
  | `hooks-learn.mjs` | The `learn` verb — Stop/SubagentStop/PreCompact distiller; the hook path's ONE spawn (`$GENESEED_LLM`) |
75
75
  | `hooks-prims.mjs` | The Python path/text primitives the hook verbs share (`splitLines`, `readStdin`, a never-throwing `isFile`) |
76
+ | `usage.mjs` | The usage counter `tool-gate` runs: one `{day, kind, name}` line per Skill or subagent call into `notebook/usage.jsonl`. Hook path: builtins only, silent, never throws |
76
77
  | `memory-files.mjs` | The memory store's file format — `frontmatter`, `MEMORY.md` index writes, agent lessons, consolidate. No spawn: the CLI and web console import it |
77
78
  | `gitref.mjs` | fs/path-only: `gitRootOf`, `gitDirOf`, `currentBranch`, `loopLaunched` — read straight off `.git`, never spawn git. Shared by `hooks.mjs`'s git-gate and `js/loop/cli.mjs`'s commit-message writer |
78
79
  | `settings.mjs` | Merges into user-owned `settings.json`/`opencode.json` (one `loadJsonObject` reader for all of them) and the managed blocks |
@@ -82,6 +83,7 @@ user co-owns, and the hook verbs those emitted configs then run.
82
83
  | `installs.mjs` | Detects deployed installs and reads back their theme, footprint, posture, mode and loop trust preset |
83
84
  | `mcp.mjs` | MCP server presets, the per-host `mcpServers` config read/write, and toggle state |
84
85
  | `link.mjs` | `geneseed link`/`unlink` — the PATH shim, and the only Windows USER-Path registry edit |
86
+ | `owned.mjs` | Manifest rel -> path on disk decisions: `ownedWithin`, the containment boundary `uninstall.mjs` and the driver's prune both go through before acting on an `owned` list entry |
85
87
 
86
88
  **Before editing:** `hooks*.mjs` **are the hook path** — the verdict travels as JSON on stdout and
87
89
  every arm exits 0, so one stray printed byte turns a blocking gate silently permissive, and anything
@@ -106,6 +108,7 @@ Everything that inspects an already-built or already-deployed harness and report
106
108
  | `validate.mjs` | The `--validate-only` verb: renders a sandbox, scans it for tokens and dead links, own exit contract |
107
109
  | `inventory.mjs` | The one catalog walk and its taxonomy tables, read by status, the TUI and the web console |
108
110
  | `status.mjs` | `geneseed status` and `version` — the panel data, counts, accent colour, version verdict |
111
+ | `cost.mjs` | What one install injects every session, by part (root file, skill and agent lists, session files), in characters; owns `CARRIER_CEILING`, which the smoke test gates and the console draws |
109
112
  | `diff.mjs` | `geneseed diff` — what a deployed install has that a fresh render of `src/` does not |
110
113
  | `excludes.mjs` | `geneseed exclude add|remove|list` — the WRITER for `excludes.json`; `hooks.mjs` only reads it |
111
114
  | `registry.mjs` | `installs.json` under XDG — the persistent deploy-root list `rebuild-all` re-emits into |
@@ -211,6 +214,9 @@ the deployed harness as JSON.
211
214
  | `state.mjs` | `webState` — the resolved install view every endpoint reads, with its cached inventory and doctor |
212
215
  | `catalog.mjs` | The Library read model: inventory, constitution tiers, memory/notebook/wiki/config items, `apiCatalog`/`apiItem`, `NotFound` |
213
216
  | `api.mjs` | The dashboard and settings reads: overview, recent, themes, setup, doctor, diff, installs, excludes, loops |
217
+ | `update-check.mjs` | Is a newer release on npm? Answers from memory, refreshes in the background (a day when it got an answer, an hour when it did not), `null` on any failure, never asked under `node --test` |
218
+ | `gates-api.mjs` | `GET /api/gates`: gate asks per rule per day over 30 days, read off every installed harness's `notebook/gates.jsonl` (the Overview's sparklines) |
219
+ | `usage-api.mjs` | `GET /api/usage`: the 30-day skill and agent call counts across every installed harness, the never-fired list, and the 90-day prune of each `notebook/usage.jsonl` |
214
220
  | `user-files.mjs` | The fingerprint-guarded editors for `user-rules.md` (incl. memory promotion) and `PROFILE.md`, read and write |
215
221
  | `actions.mjs` | The other mutating endpoints — memory delete, MCP, excludes, installs, view, restore — plus install/deploy argv |
216
222
  | `daemon.mjs` | Detached web start/stop/status/restart, the `.geneseed-web.json` record, and `openUrl` |
@@ -50,6 +50,9 @@ import {
50
50
  GLOBAL_MANIFEST, HOSTS, resolvePath, opencodeConfigDir, claudeConfigDir, bobConfigDir,
51
51
  openclaudeConfigDir, hostCatalogsNatively,
52
52
  } from '../hosts/hosts.mjs';
53
+ import {
54
+ hashesOf, ownedHashes, ownedState, ownedWithin,
55
+ } from '../hosts/owned.mjs';
53
56
 
54
57
  // P5d moved these out of this file for the reason P5c moved the host resolvers: `harness
55
58
  // status` renders to count, so `bin/geneseed-cli.mjs` needs the same checkout paths and the
@@ -185,11 +188,38 @@ function writeManifestAtomic(file, data) {
185
188
  * Runs AFTER the whole current set is on disk, so a live file is never momentarily absent
186
189
  * part-way through an emit. A pre-existing file that was never in the manifest is the
187
190
  * user's and is not reachable from here at all.
191
+ *
192
+ * `ownedWithin` filters the stale rels before this ever joins one with `oc` — F3
193
+ * (`docs/specs/2026-10-13-borrowed-hardening.md`): this loop used to `path.join` a manifest
194
+ * rel straight in, with no containment check, so a crafted per-repo manifest entry of
195
+ * `../victim.txt` deleted a file outside the install the moment a re-emit called it stale.
196
+ * `uninstall.mjs`'s owned-file walk already drew this boundary; the prune now goes through it.
197
+ *
198
+ * `oldHashes` (Task 4): a stale rel the user has EDITED is kept rather than deleted — the
199
+ * same "never clobber an edit" rule `claimer` enforces on the write side, applied to the
200
+ * delete side. `'unknown'`/`'unmodified'` fall straight through to the ordinary delete below,
201
+ * which is what keeps a pre-Task-3 manifest (no `hashes` at all) behaving exactly as before.
202
+ *
203
+ * `editRefused` (Task 4, fix round 1): the rels `claimer` already refused to (over)write this
204
+ * SAME emit because the user had edited them. Such a rel drops out of `owned` (never written),
205
+ * which makes it look exactly like `stale` — a rel this build stopped shipping — and without
206
+ * this exclusion `pruneOwned` would independently re-run `ownedState`, see `'modified'` again,
207
+ * and print its OWN "kept your edited... (no longer shipped)" right under `claimer`'s "kept
208
+ * your edited... delete it to get Geneseed's copy back": two warnings about one rel that is in
209
+ * fact still shipped. Excluded from `stale` before anything is checked or printed — silently,
210
+ * because `claimer` already said everything there is to say about it.
188
211
  */
189
- function pruneOwned(oc, oldOwned, owned) {
212
+ function pruneOwned(oc, oldOwned, owned, oldHashes = {}, editRefused = []) {
190
213
  const now = new Set(owned);
214
+ const alreadyWarned = new Set(editRefused);
215
+ const stale = [...new Set(oldOwned)]
216
+ .filter((r) => !now.has(r) && !alreadyWarned.has(r)).sort();
191
217
  const failed = [];
192
- for (const rel of [...new Set(oldOwned)].filter((r) => !now.has(r)).sort()) {
218
+ for (const rel of ownedWithin(oc, stale)) {
219
+ if (ownedState(oc, rel, oldHashes) === 'modified') {
220
+ process.stderr.write(`[geneseed] kept your edited ${rel} (no longer shipped)\n`);
221
+ continue;
222
+ }
193
223
  const victim = path.join(oc, rel);
194
224
  try {
195
225
  if (isFile(victim)) {
@@ -282,6 +312,7 @@ function emitOpencode(cfg, args, out) {
282
312
  const { existed: manifestExisted, doc } = readManifest(manifestPath);
283
313
  const oldOwned = (doc && doc.owned) || [];
284
314
  const oldDenies = recordedDenies(doc);
315
+ const oldHashes = hashesOf(doc);
285
316
 
286
317
  const agentPathRel = relUnder(out, root);
287
318
  const agentPath = agentPathRel ? `${agentPathRel}/AGENT.md` : 'AGENT.md';
@@ -293,12 +324,12 @@ function emitOpencode(cfg, args, out) {
293
324
  {
294
325
  theme: args.theme, out, root, footprint: args.footprint,
295
326
  nativeCatalog: hostCatalogsNatively('opencode'), oldOwned, manifestExisted, agentPath,
296
- oldDenies,
327
+ oldDenies, oldHashes,
297
328
  });
298
- const { owned, stats, cfgName, skillDenies } = rendered;
329
+ const { owned, stats, cfgName, skillDenies, editRefused } = rendered;
299
330
 
300
331
  phaseLog('PRUNE');
301
- pruneOwned(oc, oldOwned, owned);
332
+ pruneOwned(oc, oldOwned, owned, oldHashes, editRefused);
302
333
 
303
334
  phaseLog('MANIFEST');
304
335
  writeManifestAtomic(manifestPath, {
@@ -306,6 +337,7 @@ function emitOpencode(cfg, args, out) {
306
337
  + 'Do not edit; removed on re-emit. A pre-existing file not in this '
307
338
  + 'list is yours and is never touched.',
308
339
  owned: [...owned].sort(),
340
+ hashes: ownedHashes(oc, owned),
309
341
  scope: 'project',
310
342
  ...(skillDenies.length ? { skill_denies: skillDenies } : {}),
311
343
  });
@@ -341,6 +373,7 @@ function emitOpencodeGlobal(cfg, args, out) {
341
373
  const { doc } = readManifest(manifestPath);
342
374
  const oldOwned = (doc && doc.owned) || [];
343
375
  const oldDenies = recordedDenies(doc);
376
+ const oldHashes = hashesOf(doc);
344
377
 
345
378
  const agentPath = path.join(cfgDir, 'AGENT.md').split(path.sep).join('/');
346
379
 
@@ -349,12 +382,12 @@ function emitOpencodeGlobal(cfg, args, out) {
349
382
  {
350
383
  theme: args.theme, cfgDir, out,
351
384
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively('opencode'),
352
- oldOwned, agentPath, oldDenies,
385
+ oldOwned, agentPath, oldDenies, oldHashes,
353
386
  });
354
- const { owned, stats, memStatus, nbStatus, cfgName, skillDenies } = rendered;
387
+ const { owned, stats, memStatus, nbStatus, cfgName, skillDenies, editRefused } = rendered;
355
388
 
356
389
  phaseLog('PRUNE');
357
- pruneOwned(cfgDir, oldOwned, owned);
390
+ pruneOwned(cfgDir, oldOwned, owned, oldHashes, editRefused);
358
391
 
359
392
  phaseLog('MANIFEST');
360
393
  writeManifestAtomic(manifestPath, {
@@ -362,6 +395,7 @@ function emitOpencodeGlobal(cfg, args, out) {
362
395
  + 'Do not edit; removed on re-emit. The memory and notebook '
363
396
  + 'stores are NOT listed — they are never deleted.',
364
397
  owned: [...owned].sort(),
398
+ hashes: ownedHashes(cfgDir, owned),
365
399
  ...(skillDenies.length ? { skill_denies: skillDenies } : {}),
366
400
  });
367
401
 
@@ -391,6 +425,7 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
391
425
  const oldOwned = (doc && doc.owned) || [];
392
426
  const oldManaged = (doc && doc.managed && typeof doc.managed === 'object'
393
427
  && !Array.isArray(doc.managed)) ? doc.managed : {};
428
+ const oldHashes = hashesOf(doc);
394
429
 
395
430
  // RENDER + WIRE in one child's worth of work. `preambleExclude` is null for every
396
431
  // carrier but `CLAUDE.md`, so Bob's `AGENTS.md` does not resolve to one; for a `CLAUDE.md`
@@ -402,12 +437,12 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
402
437
  out,
403
438
  footprint: args.footprint, nativeCatalog: hostCatalogsNatively(host),
404
439
  oldOwned, oldManaged, preambleExclude: preambleExclude(claudeMd, host),
405
- hookOpts,
440
+ hookOpts, oldHashes,
406
441
  });
407
- const { owned, stats, memStatus, nbStatus, managed } = rendered;
442
+ const { owned, stats, memStatus, nbStatus, managed, editRefused } = rendered;
408
443
 
409
444
  phaseLog('PRUNE');
410
- pruneOwned(cfgDir, oldOwned, owned);
445
+ pruneOwned(cfgDir, oldOwned, owned, oldHashes, editRefused);
411
446
 
412
447
  phaseLog('MANIFEST');
413
448
  writeManifestAtomic(manifestPath, {
@@ -417,6 +452,7 @@ function emitClaudeCore(cfg, args, { cfgDir, claudeMd, scope, host, out, hookOpt
417
452
  + '(settings.json, or settings.local.json at PROJECT scope on Claude/OpenClaude) '
418
453
  + 'hooks so uninstall removes exactly those.',
419
454
  owned: [...owned].sort(),
455
+ hashes: ownedHashes(cfgDir, owned),
420
456
  managed,
421
457
  scope,
422
458
  });
@@ -13,6 +13,7 @@ import {
13
13
  managedBlockRemove, managedBlockWrite, mergeClaudeSettings,
14
14
  unwireClaudeExcludes, unwireClaudeSettings, wireClaudeExcludes,
15
15
  } from '../hosts/settings.mjs';
16
+ import { claimLeaf } from '../hosts/owned.mjs';
16
17
  import { writeText } from '../lib/fs.mjs';
17
18
  import { get, isDict, isTruthy } from '../lib/json.mjs';
18
19
  import { relPosix } from '../lib/text.mjs';
@@ -51,7 +52,7 @@ export function emitClaudeRender(cfg, job) {
51
52
  phaseLog('RENDER');
52
53
  const {
53
54
  theme: themeName, cfgDir, claudeMd, scope, out, footprint, host, nativeCatalog,
54
- oldOwned,
55
+ oldOwned, oldHashes = {},
55
56
  } = job;
56
57
 
57
58
  // `os.path.relpath(cfg, claude_md.parent)` — note the reversed argument order, and
@@ -98,10 +99,16 @@ export function emitClaudeRender(cfg, job) {
98
99
  // slim shadow stub whose only job is to have the same filename.
99
100
  if (isBob && agentText !== null) {
100
101
  const rulesMd = path.join(cfgDir, 'rules', 'geneseed.md');
101
- mkdirSync(path.dirname(rulesMd), { recursive: true });
102
- writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
103
- : stripCapabilityLinks(prefixedAgentText('../') || agentText));
104
- owned.push('rules/geneseed.md');
102
+ // `claimLeaf` BEFORE `mkdirSync`: `safeLeaf` walks up to the nearest EXISTING ancestor
103
+ // (`resolvePath`'s own contract), so it needs no directory created first — and creating
104
+ // `rules/` first would plant an empty directory at a linked ancestor's target the moment
105
+ // before the refusal fires.
106
+ if (claimLeaf(cfgDir, rulesMd, 'rules/geneseed.md')) {
107
+ mkdirSync(path.dirname(rulesMd), { recursive: true });
108
+ writeText(rulesMd, scope === 'project' ? BOB_RULES_STUB
109
+ : stripCapabilityLinks(prefixedAgentText('../') || agentText));
110
+ owned.push('rules/geneseed.md');
111
+ }
105
112
  }
106
113
 
107
114
  ensureAgentOverridesStub(cfg, cfgDir);
@@ -119,7 +126,7 @@ export function emitClaudeRender(cfg, job) {
119
126
  // One shared `claim` (see `claimer`): the skills/agents writer and the Bob command writer
120
127
  // below both push into the same `owned` list, so a pre-manifest-install warning still
121
128
  // prints once across the whole emit rather than once per writer.
122
- const claim = claimer(oldOwned, cfgDir, true);
129
+ const claim = claimer(oldOwned, cfgDir, true, oldHashes);
123
130
  const { nAgents, nSkills, written } = writeNativeLayer(
124
131
  items, path.join(cfgDir, 'agents'), path.join(cfgDir, 'skills'),
125
132
  loadAgentOverrides(cfgDir),
@@ -160,15 +167,19 @@ export function emitClaudeRender(cfg, job) {
160
167
  : host === 'bob' ? ['settings.json'] : [])
161
168
  .concat(['context.json', WIKI_FILE, 'agent-overrides.json']);
162
169
  if (!existsSync(gi)) {
163
- writeText(gi, `${giLines.join('\n')}\n`);
164
- owned.push('.gitignore');
170
+ // `!existsSync(gi)` is exactly the case a DANGLING symlink also satisfies — it reads
171
+ // as absent — so `claimLeaf` (which lstats) runs before the write, not instead of
172
+ // this check.
173
+ if (claimLeaf(cfgDir, gi, '.gitignore')) {
174
+ writeText(gi, `${giLines.join('\n')}\n`);
175
+ owned.push('.gitignore');
176
+ }
165
177
  } else if (oldOwned.includes('.gitignore')) {
166
178
  owned.push('.gitignore');
167
179
  }
168
180
  }
169
181
 
170
- writeVersion(cfg, cfgDir);
171
- owned.push(VERSION_MARKER);
182
+ if (writeVersion(cfg, cfgDir) !== null) owned.push(VERSION_MARKER);
172
183
 
173
184
  shipLeanLaws(items, theme, cfgDir, owned, footprint);
174
185
 
@@ -192,6 +203,7 @@ export function emitClaudeRender(cfg, job) {
192
203
  memStatus,
193
204
  nbStatus,
194
205
  managed,
206
+ editRefused: [...claim.editRefused],
195
207
  };
196
208
  }
197
209
 
@@ -10,6 +10,7 @@
10
10
  * `emit-claude.mjs` do that; `bundle.mjs` renders the bundle they read from.
11
11
  */
12
12
  import path from 'node:path';
13
+ import { claimLeaf } from '../hosts/owned.mjs';
13
14
  import { copyFile, writeText, isDir } from '../lib/fs.mjs';
14
15
  import { relPosix } from '../lib/text.mjs';
15
16
  import { SRC_DIR_TOKENS, destRel } from './render.mjs';
@@ -183,9 +184,14 @@ export function shipLeanLaws(items, theme, cfgDir, owned, footprint) {
183
184
  // nothing to detect, not because the gate cannot see it.
184
185
  if (text !== null && parts.length && dirs.has(parts[0])) {
185
186
  const dest = path.join(cfgDir, ...parts);
186
- mkdirSync(path.dirname(dest), { recursive: true });
187
- writeText(dest, text);
188
- owned.push(rel);
187
+ // `claimLeaf` BEFORE `mkdirSync`, for the same reason `emitClaudeRender`'s rules stub
188
+ // does: `safeLeaf` needs no directory created first, and creating one ahead of the
189
+ // refusal would plant it at a linked ancestor's target.
190
+ if (claimLeaf(cfgDir, dest, rel)) {
191
+ mkdirSync(path.dirname(dest), { recursive: true });
192
+ writeText(dest, text);
193
+ owned.push(rel);
194
+ }
189
195
  }
190
196
  }
191
197
  }
@@ -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
@@ -901,7 +901,7 @@
901
901
  },
902
902
  {
903
903
  "name": "mcp",
904
- "help": "list the MCP servers wired into each active install, and the presets available to add",
904
+ "help": "list the MCP servers wired into each active install, read-only",
905
905
  "description": "",
906
906
  "positionals": [],
907
907
  "options": [],
@@ -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];