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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.3.0] — 2026-10-15
12
+
13
+ Minor: a performance agent, and Deletion Is Deliberate says what counts as recoverable.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating so installed harnesses carry the new agent and the amended law.
17
+
18
+ ### Added
19
+ - **performance agent.** Measures where time is spent before it names a bottleneck, and answers "cannot measure" rather than guess. Read-only with a shell, so it can run benchmarks and profilers; it reports and leaves the fix to the developer. Dispatched on a reported slowdown, a change on a hot path, or as a council seat. Registered as experimental.
20
+
21
+ ### Changed
22
+ - **Deletion Is Deliberate** defines recoverable: deleting work committed and pushed to a branch runs freely; deleting uncommitted, unpushed or untracked work asks. History-rewriting git verbs (force-push, `reset --hard`, `clean -f`) stay gated.
23
+
24
+ ## [4.2.0] — 2026-10-14
25
+
26
+ Minor: the web console's Overview becomes a control panel over every installed harness, rebuild, prune and uninstall keep owned files you edited, and skill and subagent calls are counted.
27
+
28
+ ### Upgrade notes
29
+ - Run `geneseed rebuild-all` once after updating to wire the usage counter and record the per-file hashes that edit detection reads. An install built before 4.2.0 has no hashes, so until it is rebuilt its owned files are treated as before.
30
+ - Run `geneseed web restart` to load the new console.
31
+
32
+ ### Added
33
+ - **The Overview as a control panel.** The default Overview lists every installed harness on the machine: whether it matches its source, hand edits, its always-on cost against the root-file ceiling, its build time, and Rebuild when it is behind or edited. Below that: what needs attention, then charts of always-on cost by part, which skills and agents fire over 30 days, and gate asks per rule. Hosts with nothing installed are not listed; installing stays on the Installs page. The four older views are still in the Appearance palette. New read endpoints: `GET /api/fleet` (each install with its own sync verdict and always-on cost), `GET /api/usage`, `GET /api/gates` and `GET /api/update`.
34
+ - **Update check.** The console shows "*x.y.z* available" with an Update button only when npm has a release newer than the running source, and "update check unavailable" when the check failed. It asks `registry.npmjs.org` at most once a day (once an hour after a failure, with a 3-second timeout). `GENESEED_NO_UPDATE_CHECK` turns it off.
35
+ - **Usage counting.** Every Skill or subagent call appends one `{day, kind, name}` line to the install's `notebook/usage.jsonl`. It records counts only, never the prompt, arguments or paths, and adds no process and no model tokens. It is on by default. `GENESEED_NO_USAGE` or a `.usage-off` file in the install turns it off. On Claude Code and OpenClaude it is a new `Skill|Agent|Task` hook group with no `onFailure: block`, so a counter can never stop a skill. On Bob, skill detection is best-effort. Lines older than 90 days are pruned.
36
+
37
+ ### Changed
38
+ - **Rebuild, prune and uninstall keep owned files you edited.** The manifest now records a hash of every owned file. A rebuild leaves an owned file you edited in place and tells you to delete it if you want Geneseed's copy back, and a prune does not remove it. Uninstall leaves it too, completes around it, and names it; `geneseed uninstall --force` deletes it anyway. The console's Remove does the same and names the files it kept. The hash ignores CRLF vs LF, so a checkout with the other OS's line endings does not count as an edit.
39
+ - **review**: when a spec exists and the diff spans more than a few files, the spec-fidelity pass runs in its own subagent, given only the diff and the spec. The smell baseline gains message chains and refused bequest.
40
+ - **security-audit**: a finding stands only once it is traced from an attacker-controlled source to an unguarded sink through the real callers. Until then it stays a lead. The skill also names three shortcuts that produce false positives.
41
+ - **wayfinder**: each decision ticket has a type (research, prototype, grilling or task) that says how it gets resolved. Research tickets run in parallel when the map is charted, a ticket worked with the user resolves only in that live exchange, and a sweep that finds nothing unclear produces no map.
42
+ - **develop** names three test anti-patterns: implementation-coupled, tautological and horizontal slicing.
43
+ - Nine registry entities move from experimental to approved: researcher, git-history, dependencies, security-audit, worktree, token-report, explain-changes, rule and skill-forge.
44
+ - `doctor` checks that the host matrix and the enforcement table have exactly one column per host.
45
+
46
+ ### Fixed
47
+ - A prune no longer follows a manifest path outside the install. A per-repo manifest entry such as `../victim.txt` could delete a file outside the install on re-emit. Prune now applies the same containment check as uninstall.
48
+ - An emit never writes through a symlink planted at an owned path. This covers every path an emit writes, including `rules/geneseed.md`, `.gitignore`, the OpenCode global `AGENT.md` and the version marker.
49
+
50
+ ### Removed
51
+
52
+ - The `herdr` skill. Orca now covers multi-pane and multi-agent work, so a skill limited to a herdr pane only added to the catalogue. It joins `RETIRED_SKILL_IDS`, so an install that excluded it still rebuilds. Skills: 47 → 46.
53
+
11
54
  ## [4.1.0] — 2026-10-13
12
55
 
13
56
  Minor: the planning, review, debugging and multi-agent skills take the lessons of Superpowers 7.0, and the host canary.
package/README.md CHANGED
@@ -11,8 +11,8 @@
11
11
  [![Node >= 22.3](https://img.shields.io/badge/node-%3E%3D22.3-5fa04e)](package.json)
12
12
  [![zero dependencies](https://img.shields.io/badge/deps-zero-success)](package.json)
13
13
  [![Themes](https://img.shields.io/badge/themes-14-9cf)](themes/)
14
- [![Skills](https://img.shields.io/badge/skills-47-blueviolet)](src/skills/)
15
- [![Agents](https://img.shields.io/badge/agents-18-orange)](src/agents/)
14
+ [![Skills](https://img.shields.io/badge/skills-46-blueviolet)](src/skills/)
15
+ [![Agents](https://img.shields.io/badge/agents-19-orange)](src/agents/)
16
16
  [![Laws and doctrines](https://img.shields.io/badge/laws-9%20%2B%2029%20doctrines-critical)](src/doctrines/)
17
17
  [![Plugins](https://img.shields.io/badge/plugins-7-teal)](adapters/opencode/plugins/)
18
18
  [![OpenCode · Claude Code · Bob · OpenClaude · AGENT.md](https://img.shields.io/badge/works%20with-OpenCode%20·%20Claude%20Code%20·%20Bob%20·%20OpenClaude%20·%20AGENT.md-1f6feb)](#-supported-harnesses)
@@ -178,8 +178,8 @@ The harness ships as a small set of layers, and the web console's rail is the sa
178
178
  | **🧭 Ethos** (`ontology/`) | the mind the rules govern, in four prose sections — **Telos** (the Pact: three ranked laws — protect the user, serve their intent, keep your own honesty), **Evidence** (every claim graded by how it was obtained), **Decisions** (classify and tier by reversibility, show the real forks), **Conduct** (answer what was asked, once). Always in force, never toggleable |
179
179
  | **🛡️ Rules** (`laws/`) | 9 universal laws — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-asserting, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, cure-the-cause, echo-the-intent. Each rule has a permanent id; its number is only its position, and the harness cites rules by name |
180
180
  | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run), **comms** (how answers are presented). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all five pack files ship on disk either way, so a citation into a pack you left out still resolves |
181
- | **🤖 Agents** (18) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `explorer`, `researcher`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
182
- | **🛠 Skills** (47) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **herdr** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
181
+ | **🤖 Agents** (19) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `performance`, `explorer`, `researcher`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
182
+ | **🛠 Skills** (46) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
183
183
  | **🔌 Plugins** (OpenCode) | `geneseed-context` injects project docs *and your machine wiki* every session (and across compaction); `geneseed-learn` distils memory at session end; `geneseed-guard` enforces the safety Laws and protected wiki folders at the tool boundary; `geneseed-workflow` registers the `workflow` tool that runs saved orchestration scripts; `geneseed-notify` sends a native OS notification when a long run finishes; `geneseed-ponytail` holds a minimal-code mode (`/ponytail lite\|full\|ultra\|off`), opt-in, injecting the laziest-that-works ruleset every turn so it doesn't drift; `geneseed-activity` streams what each session is doing to the web console's Activity view |
184
184
  | **🧠 Memory** (`memory/`) | one-fact-per-file durable knowledge, indexed by `MEMORY.md` (git-ignored, personal) |
185
185
  | **📓 Notebook** (`notebook/`) | the agent's sovereign space — any medium (code, tools, data, notes), self-ruled via a seed-once charter, always git-ignored; only its `.gitignore` is build-asserted |
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.1.0**.
23
+ > string. Current label: **4.3.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -29,7 +29,7 @@ Every row below is present in the tree today; "Spec" names the design record.
29
29
  | Capability | What it is | Spec |
30
30
  | --- | --- | --- |
31
31
  | **Themed generator** | One neutral `src/` → 14 themed bundles via `geneseed-build` (zero dependencies); structure stays theme-independent | [DESIGN.md](DESIGN.md) |
32
- | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 18 agents, 47 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
32
+ | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 19 agents, 46 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
33
33
  | **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the nine always-on **Invariants**, and five toggleable **Doctrine** packs (craft, rigor, ops, process, comms) addressed `<pack> <n>`. Every rule carries a permanent id; its number is its position, computed at render time, and a citation renders as the rule's principle name. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates rule ids (declared, unique, never a retired one), the per-rule Principle column, every `{{LAW:<id>}}` / `{{DOCTRINE:<id>}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | `2026-08-19-three-tier-constitution-design` |
34
34
  | **`doctor`** | One check: unresolved tokens, dead/non-hermetic links, theme-key parity, authoring gates, rendered-bundle drift | `2026-06-07-theme-aware-doctor-diff` |
35
35
  | **`diff` + improvements export** | Deployed-vs-source drift report; export an `improvements.md` back-port | `2026-06-12-improvements-export` |
@@ -44,7 +44,7 @@ Every row below is present in the tree today; "Spec" names the design record.
44
44
  | **Collaboration layer** | Negotiated postures (peer/mentor/expert/assistant/artisan, chosen at setup, inlined in AGENT.md), the bidirectional Pact, epistemically-typed memory (`force:` + Bridge rule), and a seed-once `PROFILE.md` identity with a web editor tab and a `profile` skill that interviews the user to draft it | `2026-07-12-collaboration-layer` |
45
45
  | **Rule front door** | Both durable stores enter through the `rule` skill's fork (*standing rule, or fact to remember?* — always asked, never inferred): the memory branch elicits the binding `force` and shows the file before writing, the rule branch pressure-tests by reformulation and a mandatory counter-example. Doctrine process 1 carries the norm on every host; a `rule-gate` PreToolUse hook (Claude, Bob) and a `geneseed-guard` speed bump (OpenCode) hold it at the tool boundary | `2026-07-30-rule-front-door` |
46
46
  | **Entity registry** | `registry.json` records a lifecycle status, version, owner and add-date for every agent and skill in the tree (maintainer-side — never rendered into a bundle, so it costs no session context). `doctor` gates it against `src/` in both directions, sweeps everything that ships for credentials, and holds each vendored skill folder to an immutable upstream commit. The status surfaces as a badge in the web catalog, shown only where it deviates from `approved` | `2026-07-31-entity-registry` |
47
- | **Node runtime + npm package** | The whole harness runs from Node: every command `js/cli-table.json` declares, all five hook verbs, every web-console endpoint (the OS-native folder picker excepted, and declined on purpose) and both generators. Every commit runs the unit suite, `doctor --all` across every theme × host × footprint, and the generator's two self-comparisons, on Linux and Windows both; the frozen emit/cli/web recordings that also gated it retired on 2026-08-17 (see the 2.0.0 entry in [CHANGELOG.md](CHANGELOG.md)). It ships as the zero-dependency `geneseed` package (`geneseed`, `geneseed-hook`, `geneseed-build`), so `npx geneseed setup` needs no clone and nothing else installed; `geneseed migrate` moves an existing checkout install across. Node is the only runtime it asks for: the `token-report` skill's own script is `token_report.mjs`, gated over seeded transcripts for all four hosts, and `daydream` and `herdr` call `node -e` | `2026-08-06-npx-distribution` |
47
+ | **Node runtime + npm package** | The whole harness runs from Node: every command `js/cli-table.json` declares, all five hook verbs, every web-console endpoint (the OS-native folder picker excepted, and declined on purpose) and both generators. Every commit runs the unit suite, `doctor --all` across every theme × host × footprint, and the generator's two self-comparisons, on Linux and Windows both; the frozen emit/cli/web recordings that also gated it retired on 2026-08-17 (see the 2.0.0 entry in [CHANGELOG.md](CHANGELOG.md)). It ships as the zero-dependency `geneseed` package (`geneseed`, `geneseed-hook`, `geneseed-build`), so `npx geneseed setup` needs no clone and nothing else installed; `geneseed migrate` moves an existing checkout install across. Node is the only runtime it asks for: the `token-report` skill's own script is `token_report.mjs`, gated over seeded transcripts for all four hosts, and `daydream` calls `node -e` | `2026-08-06-npx-distribution` |
48
48
  | **Runtime awareness** | MCP discovery, web research, tool-call batching, review discipline — carried by the doctrine packs (`ops 1` tool discovery, `process 4` read the docs first, `rigor 2`/`rigor 3` honest tests and cover-and-verify), not by the invariants | `2026-06-07-runtime-awareness-and-review-discipline` |
49
49
  | **Run from anywhere / uninstall** | `link`/`unlink` onto PATH; global uninstall that keeps the memory store | `2026-06-07-version-and-uninstall` |
50
50
 
@@ -92,27 +92,6 @@ hooks or plugins, the AGENT.md prose still carries the rule.
92
92
  > plugin. A `context.json` still overrides, and `"extend": true` layers a manifest
93
93
  > on top of discovery.
94
94
 
95
- ## Bundled skill — `herdr`
96
-
97
- The canonical herdr skill lives in the Geneseed source tree at
98
- [`src/skills/herdr.md`](../../src/skills/herdr.md). The build renders it into
99
- every emit — `.opencode/skills/herdr/SKILL.md` for OpenCode hosts, the
100
- portable bundle for everyone else — so a deployed harness picks it up
101
- automatically alongside every other Geneseed skill.
102
-
103
- A copy is also kept at
104
- [`.claude/skills/herdr/SKILL.md`](../../.claude/skills/herdr/SKILL.md) so Claude
105
- Code running **inside this repo** (i.e. people working on Geneseed itself)
106
- discovers it directly, since the build doesn't write into `.claude/`. The two
107
- copies share content; keep them in sync when you edit either.
108
-
109
- Either way, you only need:
110
-
111
- - the **`herdr` binary** on your `PATH` (install via [herdr.dev](https://herdr.dev)),
112
- - the agent launched **inside a herdr pane** — the skill self-gates on
113
- `HERDR_ENV=1` and stays silent everywhere else, so shipping it is harmless
114
- for users who don't run herdr.
115
-
116
95
  ## Other tools
117
96
 
118
97
  This adapter is Claude-Code-specific. On tools without hooks, the harness still
@@ -546,11 +546,42 @@ function protectedCheck(p) {
546
546
  // substring stance: over-matching the class is harmless (see WRITE_TOOLS note).
547
547
  const WIKI_MUTATE_TOOLS = [...WRITE_TOOLS, "delete", "remove", "rename", "move", "trash"]
548
548
 
549
+ // Usage counting: the same rules as js/hosts/usage.mjs, inline because a plugin cannot import
550
+ // from the repo (V2 loads every .js in plugins/ as a plugin). One line per skill or subagent
551
+ // call, the day and the name only, under <cfg>/notebook/usage.jsonl. Silent and never throws:
552
+ // a counter must not change, slow or fail a tool call. tests/unit/usage.test.mjs holds this
553
+ // copy and the hook's to one table.
554
+ const USAGE_NAME_RE = /^[A-Za-z0-9][\w.:@/-]{0,99}$/
555
+ function usageOf(tool, args) {
556
+ const t = String(tool || "").toLowerCase()
557
+ const a = args || {}
558
+ let kind = null
559
+ let name = null
560
+ if (!t.startsWith("mcp") && t.includes("skill")) { kind = "skill"; name = a.skill ?? a.name ?? a.skill_name }
561
+ else if (t === "agent" || t === "task") { kind = "agent"; name = a.subagent_type ?? a.agent }
562
+ if (!kind || typeof name !== "string") return null
563
+ name = name.trim().replace(/^\/+/, "")
564
+ return USAGE_NAME_RE.test(name) ? { kind, name } : null
565
+ }
566
+ async function recordUse(tool, args) {
567
+ try {
568
+ if (process.env.GENESEED_NO_USAGE) return
569
+ const use = usageOf(tool, args)
570
+ if (!use) return
571
+ const cfg = path.dirname(PLUGIN_DIR)
572
+ if (existsSync(path.join(cfg, ".usage-off"))) return
573
+ if (!(await isDir(path.join(cfg, "notebook")))) return
574
+ await fs.appendFile(path.join(cfg, "notebook", "usage.jsonl"),
575
+ `${JSON.stringify({ ts: new Date().toISOString().slice(0, 10), ...use })}\n`)
576
+ } catch { /* a counter never fails a tool call */ }
577
+ }
578
+
549
579
  export const GeneseedGuard = async (ctx) => {
550
580
  // The session's working directory, the same resolution the context plugin uses.
551
581
  const root = () => ctx?.worktree || ctx?.directory || process.cwd()
552
582
  return {
553
583
  "tool.execute.before": async (input, output) => {
584
+ await recordUse(input?.tool || input?.name, output?.args || input?.args)
554
585
  if (OFF) return
555
586
  if (await sovereignBypass(root())) return
556
587
  const tool = (input?.tool || input?.name || "").toLowerCase()
@@ -24,6 +24,7 @@ An **agent** is a specialist the main session can hand a piece of work to. Each
24
24
  | `tester` | writes, runs and diagnoses tests |
25
25
  | `reviewer` | reviews a change for correctness and quality before it merges |
26
26
  | `security` | audits changes that touch the security surface |
27
+ | `performance` | measures where time and memory go, names the bottleneck with numbers, proposes the fix |
27
28
  | `docs` | writes and updates user-facing documentation after code lands |
28
29
 
29
30
  ### The council
@@ -28,6 +28,8 @@ Each install keeps a manifest, a list of the files Geneseed wrote. **Uninstall d
28
28
  - **OpenCode per repo:** `AGENT.md`, `.opencode/`, `laws/`, `agents/`, `skills/`, and the `instructions` entry in `opencode.json`.
29
29
  - **Claude Code, Bob, OpenClaude:** `agents/`, `skills/`, the marker files, Geneseed's managed block in the instructions file (`CLAUDE.md` or `AGENTS.md`), and Geneseed's own hooks and exclusions in `settings.json`. Your own settings keys and hooks stay.
30
30
 
31
+ **A file Geneseed wrote that you have since edited is kept.** The manifest records each file's fingerprint, so uninstall can tell your edit apart from Geneseed's copy. It leaves the edited file in place, names it (`kept 1 file(s) you edited: ...`), and finishes the uninstall anyway: the file is yours now, and no later rebuild touches it. To delete edited files too, pass `--force`. Changing only the line endings, as a git checkout on another system does, is not an edit.
32
+
31
33
  It also removes the install's row from the registry of installs. Start a new session in your tool afterwards so it stops loading the old files.
32
34
 
33
35
  ## What it keeps
@@ -48,6 +48,8 @@ A rebuild only replaces files the install's manifest says Geneseed owns. It leav
48
48
 
49
49
  The agent can refine its own deployed agent and skill files as it works, and so can you. A rebuild would overwrite those edits, so **setup, re-theme and upgrade export them first**. When the files they are about to overwrite differ from a fresh render, the differences are written to a markdown **improvements file** in `improvements/`, inside the install's own folder (for the OpenCode global install, `~/.config/opencode/improvements/`). The manifest does not list that folder, so rebuilds never overwrite it and uninstall never removes it.
50
50
 
51
+ A rebuild also refuses to overwrite a file you edited. The manifest records a fingerprint of every file Geneseed wrote; when a file no longer matches it, the rebuild leaves it alone and prints `kept your edited <file> — delete it to get Geneseed's copy back`. From then on the file is yours and later rebuilds skip it. A file the new version no longer ships is kept the same way, with `(no longer shipped)`. Line-ending changes from a git checkout do not count as edits. Installs made before fingerprints existed are overwritten as before.
52
+
51
53
  To see the drift yourself, or to export it on demand:
52
54
 
53
55
  ```
@@ -93,7 +93,7 @@ until 2026-09 it did, and the whole harness was paid twice per session.
93
93
  `<!-- invocation: user -->` renders `disable-model-invocation: true`; Claude
94
94
  Code and Bob then drop its description from the always-on skill metadata and
95
95
  only `/name` opens it. The three teaching skills (`teach`, `quiz`, `learn-mode`)
96
- and `herdr` carry it today.
96
+ carry it today.
97
97
  - **Aliases cost 0 always-on.** A merged skill's old names (`<!-- aliases: -->`)
98
98
  ship as user-only stubs on Claude Code, Bob and OpenClaude, and as `/name` commands
99
99
  on OpenCode — never in the model's catalogue, so a rename or merge is free.
@@ -6,7 +6,7 @@ kind: "concept"
6
6
  section: "On your machine"
7
7
  description: "What geneseed uninstall removes, and what it keeps for you."
8
8
  ---
9
- `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `geneseed-wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. See [Uninstall](../guides/uninstall.md).
9
+ `geneseed uninstall` removes only what Geneseed recorded writing: the rules file or its managed block, `agents/`, `skills/`, plugins, and Geneseed's own hook and config entries. It keeps `memory/` and `notebook/` in place, along with the files it seeded for you to edit (`user-rules.md`, `PROFILE.md`, `geneseed-wiki.jsonc`). Add `--archive-memory` to move memory and notebook aside instead. It still deletes nothing. A file Geneseed wrote that you have since edited stays too, and becomes yours; add `--force` to delete edited files as well. See [Uninstall](../guides/uninstall.md).
10
10
 
11
11
  ---
12
12
 
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.1.0"
5
+ "version": "4.3.0"
6
6
  }
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
  }