From b08176f089ad1521f552e35fd1c0915254e25489 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 27 Jul 2026 13:06:22 +0200 Subject: [PATCH 1/9] feat(#2693): let the operator pin the claude-code every agent runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves to, and that is normally a release channel. This one package moves fast enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while unstable carries 2.1.220 — and an agent cannot fix it for itself: it only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix` has no other tree to reach for. New host option `services.hyperhive.c0re.claudeCodePackage` takes the package directly and rides the existing `hyperhiveDocs` threading path — serveConfigJson -> HiveEnv -> render_flake — to reach each agent as `hyperhive.claudeCodePath`. Null (the default) is today's behaviour. What travels is the store *path*, as a plain string literal, not a flake input: containers share the host's `/nix/store`, so the build is already reachable inside them with its whole closure and has nothing to travel. An input would be worse than useless — a `path:/nix/store/` input is re-copied as a reference-less `-source`, which strips exactly the closure the binary needs. The catch is that a path written into a generated flake is text, so nothing in the container's closure keeps the binary alive. The host does that instead, and gets it for free: the package is interpolated into `/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context, so the /etc entry references it and the system closure gc-roots it for as long as that generation is the one the agents were rendered from. An assertion pins that property, because losing the context is invisible at eval and at deploy — it would surface only as every agent failing to spawn `claude` whenever the next gc ran. Container side wraps the path in a symlink farm rather than putting it on PATH directly: `systemd.services..path` and `environment.systemPackages` both coerce a store-path *string* through `lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation rejects. Interpolating the path into a builder is just text and evaluates anywhere. `claude-code` drops out of systemPackages when a pin is set, so there is exactly one claude in the container. Refs #2693 --- docs/gotchas.md | 41 +++++++++++++ docs/turn-loop/claude-invocation.md | 11 ++++ hive-c0re/src/coordinator.rs | 20 +++++++ hive-c0re/src/meta.rs | 81 ++++++++++++++++++++++++++ nix/agent-modules/default.nix | 52 ++++++++++++++++- nix/host-modules/hive-c0re/default.nix | 38 ++++++++++++ nix/host-modules/hive-c0re/options.nix | 39 +++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) diff --git a/docs/gotchas.md b/docs/gotchas.md index 119bc38e..6cbc7f9c 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -84,6 +84,47 @@ scoped, only this one package. This is needed because each per-agent operator's host-level `allowUnfree` does **not** propagate in. Operators don't need to set anything on their side. +That same isolation is why an agent can't pick a claude out of a +*different* nixpkgs by itself: a container only ever sees the one +nixpkgs the meta flake injects, so an `agent.nix` naming the host's +`nixpkgs-unstable` has nothing to name. A release channel can trail +unstable by weeks on this package, which is what +`services.hyperhive.c0re.claudeCodePackage` is for — set it host-side +and every agent runs that build. + +What crosses is the **store path**, not the derivation. Containers +share the host's `/nix/store`, so the binary is already reachable +inside them with its whole closure; hive-c0re writes the path into each +agent's flake as a string literal and the agent module symlinks +`bin/claude` onto PATH. Two things rule out the obvious alternatives: a +`path:/nix/store/` flake input is re-copied into the store as a +reference-less `-source` (so the runtime closure never arrives), and +`lib.types.package` fed a bare path runs `builtins.storePath`, which +pure evaluation rejects. `hyperhive.docs.source` gets away with being +an input only because a docs tree has no runtime dependencies. + +The `storePath` trap is worth spelling out, because it is not confined +to options the operator writes: **any** option of type `package` fed a +store-path *string* coerces through `lib.toDerivation`, i.e. +`builtins.storePath`. `environment.systemPackages` and +`systemd.services..path` both do it (the latter takes plain +strings like `/run/wrappers` happily, but anything under +`builtins.storeDir` is treated as a package). So a path handed to the +container as text has to be wrapped in a real derivation — a symlink +farm built from the interpolated string — before it can go anywhere a +package is expected. + +The catch is that a path written into a generated flake is text, not a +reference — the container's closure does not keep the binary alive. +The **host** does: the package is interpolated into +`/etc/hyperhive/serve.json`, so it lands in the host's system closure +and is gc-rooted by the running generation. `builtins.toJSON` preserves +string context, which is the load-bearing detail; discard the context +anywhere on that path and `nix-collect-garbage` will eventually take +the hive's `claude` out from under it. The price of the root is that an +old `claude-code` can't be reclaimed until every agent has rebuilt past +it and the old generations are gone. + ## Claude credentials are per-agent `/var/lib/hyperhive/agents//claude/` bind-mounts to diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index 9d93619e..b9d2f26c 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -21,6 +21,17 @@ auto-reset / retry decisions in `drive_turn`. The lib returns everything it parsed from a turn (usage, cost, context window, resolved model) as `Telemetry`, which the policy layer applies to the bus. +**Which `claude` binary.** The bare name `claude`, resolved off the +harness unit's PATH. By default that's the `claude-code` in the agent's +own nixpkgs (the meta flake's `nixpkgs` input) via +`environment.systemPackages`. Since that's usually a release channel and +this package moves fast, the operator can pin one hive-wide with +`services.hyperhive.c0re.claudeCodePackage`: its store path is written +into each agent's flake, and `claude` on PATH becomes a symlink to it +instead of the container's own `claude-code` — so there's only ever one +`claude` in the container. Agents pick up a new build on their +next rebuild, not live. See docs/gotchas.md::`claude-code` is unfree. + Hive-enforced settings ship at `/etc/claude-code/managed-settings.json` (claude-code's canonical managed-settings path — precedence #1, read-only, un-overridable), wired in `nix/agent-modules/claude-settings.nix` diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index c08927df..ee561c89 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -62,6 +62,17 @@ pub struct Coordinator { /// "nixpkgs"` is set in the host flake). Empty string = legacy /// `follows = "hyperhive/nixpkgs"` behaviour. pub nixpkgs_flake: String, + /// Store path of the `claude-code` build every agent runs, written + /// into each per-agent flake as `hyperhive.claudeCodePath`. Set by + /// the NixOS module option `services.hyperhive.c0re.claudeCodePackage` + /// (which resolves the package and hands us its path). Empty string = + /// every agent keeps the `claude-code` from its own nixpkgs. + /// + /// A path rather than a flake input because containers share the + /// host's `/nix/store`: the binary is already reachable inside them, + /// closure and all. The host module is what keeps it from being + /// garbage-collected — see that option. + pub claude_code_path: String, /// TCP port the host's hive-c0re dashboard listens on. Inlined into /// each per-agent flake so the agent's web UI can build the right /// rebuild-button URL pointing back at the dashboard. @@ -213,6 +224,11 @@ pub struct HiveEnv { /// so a doc edit only re-locks this input, not the whole source. pub hyperhive_docs_flake: String, pub nixpkgs_flake: String, + /// Store path of the `claude-code` agents run, or empty for "each + /// agent keeps the one out of its own nixpkgs". Travels into the + /// container as `hyperhive.claudeCodePath` — a plain string, kept + /// alive host-side by the module that resolved it. + pub claude_code_path: String, pub dashboard_port: u16, pub operator_pronouns: String, pub context_window_tokens: std::collections::HashMap, @@ -235,6 +251,7 @@ impl Default for HiveEnv { hyperhive_flake: "/etc/hyperhive".to_string(), hyperhive_docs_flake: String::new(), nixpkgs_flake: String::new(), + claude_code_path: String::new(), dashboard_port: 7000, operator_pronouns: "she/her".to_string(), context_window_tokens: std::collections::HashMap::from([ @@ -474,6 +491,7 @@ impl Coordinator { hyperhive_flake, hyperhive_docs_flake, nixpkgs_flake, + claude_code_path, dashboard_port, operator_pronouns, context_window_tokens, @@ -520,6 +538,7 @@ impl Coordinator { hyperhive_flake, hyperhive_docs_flake, nixpkgs_flake, + claude_code_path, dashboard_port, operator_pronouns, context_window_tokens, @@ -553,6 +572,7 @@ impl Coordinator { hyperhive_flake: self.hyperhive_flake.clone(), hyperhive_docs_flake: self.hyperhive_docs_flake.clone(), nixpkgs_flake: self.nixpkgs_flake.clone(), + claude_code_path: self.claude_code_path.clone(), dashboard_port: self.dashboard_port, operator_pronouns: self.operator_pronouns.clone(), context_window_tokens: self.context_window_tokens.clone(), diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index bc1fb6f4..b36183fc 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -92,6 +92,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { &hive.hyperhive_flake, &hive.hyperhive_docs_flake, &hive.nixpkgs_flake, + &hive.claude_code_path, hive.dashboard_port, &hive.operator_pronouns, &hive.context_window_tokens, @@ -650,6 +651,7 @@ fn render_flake( hyperhive_flake: &str, docs_flake: &str, nixpkgs_flake: &str, + claude_code_path: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -660,6 +662,7 @@ fn render_flake( hyperhive_flake, docs_flake, nixpkgs_flake, + claude_code_path, dashboard_port, operator_pronouns, context_window_tokens, @@ -937,6 +940,7 @@ fn render_flake_with_lookup( hyperhive_flake: &str, docs_flake: &str, nixpkgs_flake: &str, + claude_code_path: &str, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -1051,6 +1055,21 @@ where if !docs_flake.is_empty() { out.push_str(" hyperhive.docs.source = inputs.\"hyperhive-docs\".outPath;\n"); } + // The `claude-code` agents run, as a bare store path rather than a + // flake input: containers share the host's `/nix/store`, so the + // binary is already there with its closure and has nothing to + // travel. A string literal is also the only shape that evaluates — + // `lib.types.package` on a bare path runs `builtins.storePath`, + // which pure eval rejects. The agent module puts its `bin/` on the + // harness PATH; the host module holds the gc root, since a path + // spelled out here is text and references nothing. + // Empty = no override; agents keep their own nixpkgs' `claude-code`. + if !claude_code_path.is_empty() { + let _ = writeln!( + out, + " hyperhive.claudeCodePath = \"{claude_code_path}\";" + ); + } // CA trust: embed every hive-trusted CA so each agent validates them at // build time. The list is the hive's own self-signed CA (when active) // plus every peer-hive root CA (`swarm.peers..caCert`) — a peer CA is @@ -1561,6 +1580,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1605,6 +1625,7 @@ mod tests { "github:example/hyperhive", "", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1617,6 +1638,57 @@ mod tests { ); } + #[test] + fn render_flake_pins_claude_path_without_adding_an_input() { + // The host-pinned claude travels as a bare store path assigned to + // an option — deliberately NOT as a flake input. Containers share + // the host store, so the build is already reachable; making it an + // input would re-copy it as a reference-less `-source` and strip + // the closure the binary actually needs. + let out = render_flake( + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", + "/nix/store/cccc-claude-code-2.1.220", + 8000, + "she/her", + &std::collections::HashMap::new(), + "4G", + &[sample_spec("alice", false, 9001)], + ); + assert!( + out.contains("hyperhive.claudeCodePath = \"/nix/store/cccc-claude-code-2.1.220\";"), + "claude path assigned as a plain string literal:\n{out}" + ); + assert!( + !out.contains("claude-code-2.1.220\".url"), + "the pinned claude must not become a flake input:\n{out}" + ); + } + + #[test] + fn render_flake_omits_claude_path_when_unset() { + // Empty = no host-level pin: the option is left undefined so the + // agent module keeps its own nixpkgs' `claude-code` (and keeps it + // in `environment.systemPackages`, which is what makes the + // unpinned case self-contained). + let out = render_flake( + "github:example/hyperhive", + "path:/nix/store/bbbb-hyperhive-docs-source", + "path:/nix/store/aaaa-nixpkgs-source", + "", + 8000, + "she/her", + &std::collections::HashMap::new(), + "4G", + &[sample_spec("alice", false, 9001)], + ); + assert!( + !out.contains("claudeCodePath"), + "no claude assignment when unpinned:\n{out}" + ); + } + #[test] fn render_flake_falls_back_to_follows_when_nixpkgs_flake_empty() { // Empty nixpkgs_flake → legacy follows behaviour (backward compat @@ -1625,6 +1697,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1655,6 +1728,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1688,6 +1762,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1723,6 +1798,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1761,6 +1837,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1803,6 +1880,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1886,6 +1964,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -1971,6 +2050,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), @@ -2013,6 +2093,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", + "", 8000, "she/her", &std::collections::HashMap::new(), diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 810ab3b5..ffec918e 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -61,6 +61,33 @@ ''; }; + options.hyperhive.claudeCodePath = lib.mkOption { + type = lib.types.str; + default = ""; + example = "/nix/store/…-claude-code-2.1.220"; + description = '' + Store path of the `claude-code` this agent runs, or `""` (the + default) to use the `claude-code` from the container's own + nixpkgs. + + Set by the generated meta flake when the operator sets + `services.hyperhive.c0re.claudeCodePackage` host-side, so a hive + can run a claude built from a *different* nixpkgs than the one + its agents evaluate against — a release channel can trail + unstable by weeks on this one package. It arrives as a path and + not a package because agents share the host's `/nix/store`, so + the build is already reachable here with its whole closure and + has nothing to travel. + + When set, `claude` on PATH is a symlink to this path's + `bin/claude` and the container's own `claude-code` is dropped, so + there is only ever one claude in the container. Note that neither + the symlink nor anything else in the container's closure *refers* + to the target — keeping it alive is the host's job, see + `services.hyperhive.c0re.claudeCodePackage`. + ''; + }; + config = { assertions = [ # Guard the inputs-routed-as-output pattern: the agent flake.nix is @@ -183,8 +210,31 @@ environment.systemPackages = [ config.hyperhive.packages.hive-metric ] + ++ [ + ( + if config.hyperhive.claudeCodePath == "" then + pkgs.claude-code + else + # Host-pinned claude: a symlink farm around a path the + # container was handed as text. It has to be a derivation — + # `environment.systemPackages` coerces a store-path *string* + # with `toDerivation`, i.e. `builtins.storePath`, which pure + # evaluation rejects (`systemd.services.*.path` does the same, + # which is why the harness gets this via PATH like everything + # else rather than a unit-level entry). Interpolating the path + # into the builder is just text, so it evaluates anywhere. + # + # The symlink registers no store reference — the target isn't + # among this derivation's inputs, so nothing here keeps the + # binary alive. That is deliberate and it is the host's job: + # see `services.hyperhive.c0re.claudeCodePackage`. + pkgs.runCommandLocal "claude-code-pinned" { } '' + mkdir -p "$out/bin" + ln -s ${config.hyperhive.claudeCodePath}/bin/claude "$out/bin/claude" + '' + ) + ] ++ (with pkgs; [ - claude-code bashInteractive coreutils-full # procps for pkill — used by the web UI's /api/cancel to SIGINT the diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 5372ed7a..06aab90e 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -59,6 +59,22 @@ let fi ''; + # Store path of the `claude-code` every agent runs, or "" for "each + # agent keeps the one out of its own nixpkgs". meta.rs writes it into + # each agent's generated flake as a plain string literal, and the agent + # module puts its `bin/` on the harness PATH. + # + # This interpolation is also the package's gc root, and the only one: + # it carries store context, `builtins.toJSON` preserves that, so the + # /etc entry below genuinely references the package and the host's + # system closure holds it alive. Nothing on the container side can — + # a path spelled out in a generated flake is text, not a reference. + # Hence the assertion further down: do NOT discard this context, and + # do not hand meta.rs the path by a route that drops it. The failure + # mode is a garbage-collected `claude` and a hive that can't take a + # turn, weeks after the commit that caused it. + claudeCodePath = if cfg.claudeCodePackage == null then "" else "${cfg.claudeCodePackage}"; + # The `hive-c0re serve` config JSON. Keys are snake_case to match the # `ServeConfig` serde shape the daemon deserialises (the # container-injected HiveEnv fields, flattened, plus the hive-c0re-local @@ -76,6 +92,7 @@ let hyperhive_flake = cfg.hyperhiveFlake; hyperhive_docs_flake = cfg.hyperhiveDocs; nixpkgs_flake = cfg.nixpkgsFlake; + claude_code_path = claudeCodePath; dashboard_port = cfg.dashboardPort; operator_pronouns = cfg.operatorPronouns; context_window_tokens = cfg.contextWindowTokens; @@ -94,6 +111,27 @@ in ]; config = lib.mkIf cfg.enable { + assertions = [ + { + # The pinned claude reaches agents as a bare path, so this + # string's store context is the whole reason the binary survives + # a `nix-collect-garbage`. Losing it is invisible at eval and at + # deploy — it only shows up as every agent failing to spawn + # `claude`, at whatever unrelated moment the gc runs. Cheap + # enough to just check. + assertion = cfg.claudeCodePackage == null || builtins.hasContext claudeCodePath; + message = '' + services.hyperhive.c0re.claudeCodePackage lost its store + context on the way into /etc/hyperhive/serve.json, so the + package is no longer gc-rooted by the system closure and + `nix-collect-garbage` may delete the claude every agent runs. + Something on that path discarded the context (e.g. + builtins.unsafeDiscardStringContext, toString, or reading the + path back out of a plain file) — undo it. + ''; + } + ]; + environment.systemPackages = [ cfg.package pkgs.git diff --git a/nix/host-modules/hive-c0re/options.nix b/nix/host-modules/hive-c0re/options.nix index eee0237c..bd10cecd 100644 --- a/nix/host-modules/hive-c0re/options.nix +++ b/nix/host-modules/hive-c0re/options.nix @@ -128,6 +128,45 @@ of the host's channel. ''; }; + claudeCodePackage = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + example = lib.literalExpression "inputs.nixpkgs-unstable.legacyPackages.x86_64-linux.claude-code"; + description = '' + The `claude-code` build every agent runs, or `null` (the + default) to leave each agent on the `claude-code` from its own + nixpkgs — i.e. whatever `nixpkgsFlake` resolves to. + + This is the one binary the whole hive is built around, and it + moves fast enough that a release channel routinely trails + unstable by weeks on it. An agent cannot fix that for itself: + agents evaluate against the single nixpkgs hive-c0re injects, + so an `agent.nix` has no other tree to reach for. Set this from + a second nixpkgs in the host flake and every agent follows, + without moving the nixpkgs the rest of the container is built + from. + + What travels into the container is the **store path**, not the + derivation: agents share the host's `/nix/store`, so the binary + and its full closure are already reachable there — nothing + needs rebuilding or copying. hive-c0re writes the path into + each agent's generated flake as a plain string literal (a bare + path fed to `lib.types.package` would run `builtins.storePath`, + which is illegal under pure evaluation) and the agent module + puts its `bin/` on the harness's PATH. + + The flip side of a plain string is that nothing in the agent's + own closure refers to it, so the container cannot keep it + alive. The **host** does that instead: this package is + interpolated into `/etc/hyperhive/serve.json`, which puts it in + the host's system closure — so it is gc-rooted by the running + generation for exactly as long as that generation is the one + the agents were rendered from. The cost is that + `nix-collect-garbage` cannot reclaim an old `claude-code` until + every agent has been rebuilt past it and the old generations + are gone. + ''; + }; dashboardPort = lib.mkOption { type = lib.types.port; default = 7000; From 2ad4b431181a92d41682566f9f73f61e76f78350 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 27 Jul 2026 13:21:11 +0200 Subject: [PATCH 2/9] refactor(#2693): null, not "", for the unpinned claude-code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara on PR #2769: "make the default null instead of special casing """. `claude_code_path` was a `String` whose empty value meant "no host-level pin". That is a sentinel doing an `Option`'s job — the same shape argus and mara already rejected on #2755's weights, and the same empty-field cruft mara called out on #2756. So it is `Option` end to end: - host module: `claudeCodePath` evaluates to `null` when `claudeCodePackage` is unset, so `serve.json` carries JSON `null` rather than `""`. - `Coordinator` + `HiveEnv`: `Option`, defaulting to `None`. - `render_flake`/`render_flake_with_lookup`: `Option<&str>`, and the emission is an `if let Some(path)` instead of an `is_empty()` guard. - agent module: `hyperhive.claudeCodePath` is `nullOr str`, default `null`. Behaviour is unchanged in both directions; only the way "unset" is spelled moves. The `builtins.hasContext` assertion still guards the pinned case (short-circuited by the null check, so an unpinned hive never evaluates it). 16/16 `meta::` tests, clippy clean, `nix fmt` no-op, `nix build .#docs` green. --- hive-c0re/src/coordinator.rs | 12 +++---- hive-c0re/src/meta.rs | 43 ++++++++++++-------------- nix/agent-modules/default.nix | 8 ++--- nix/host-modules/hive-c0re/default.nix | 4 +-- 4 files changed, 32 insertions(+), 35 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index ee561c89..7029cbf3 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -65,14 +65,14 @@ pub struct Coordinator { /// Store path of the `claude-code` build every agent runs, written /// into each per-agent flake as `hyperhive.claudeCodePath`. Set by /// the NixOS module option `services.hyperhive.c0re.claudeCodePackage` - /// (which resolves the package and hands us its path). Empty string = - /// every agent keeps the `claude-code` from its own nixpkgs. + /// (which resolves the package and hands us its path). `None` = every + /// agent keeps the `claude-code` from its own nixpkgs. /// /// A path rather than a flake input because containers share the /// host's `/nix/store`: the binary is already reachable inside them, /// closure and all. The host module is what keeps it from being /// garbage-collected — see that option. - pub claude_code_path: String, + pub claude_code_path: Option, /// TCP port the host's hive-c0re dashboard listens on. Inlined into /// each per-agent flake so the agent's web UI can build the right /// rebuild-button URL pointing back at the dashboard. @@ -224,11 +224,11 @@ pub struct HiveEnv { /// so a doc edit only re-locks this input, not the whole source. pub hyperhive_docs_flake: String, pub nixpkgs_flake: String, - /// Store path of the `claude-code` agents run, or empty for "each + /// Store path of the `claude-code` agents run, or `None` for "each /// agent keeps the one out of its own nixpkgs". Travels into the /// container as `hyperhive.claudeCodePath` — a plain string, kept /// alive host-side by the module that resolved it. - pub claude_code_path: String, + pub claude_code_path: Option, pub dashboard_port: u16, pub operator_pronouns: String, pub context_window_tokens: std::collections::HashMap, @@ -251,7 +251,7 @@ impl Default for HiveEnv { hyperhive_flake: "/etc/hyperhive".to_string(), hyperhive_docs_flake: String::new(), nixpkgs_flake: String::new(), - claude_code_path: String::new(), + claude_code_path: None, dashboard_port: 7000, operator_pronouns: "she/her".to_string(), context_window_tokens: std::collections::HashMap::from([ diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index b36183fc..167d0908 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -92,7 +92,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { &hive.hyperhive_flake, &hive.hyperhive_docs_flake, &hive.nixpkgs_flake, - &hive.claude_code_path, + hive.claude_code_path.as_deref(), hive.dashboard_port, &hive.operator_pronouns, &hive.context_window_tokens, @@ -651,7 +651,7 @@ fn render_flake( hyperhive_flake: &str, docs_flake: &str, nixpkgs_flake: &str, - claude_code_path: &str, + claude_code_path: Option<&str>, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -940,7 +940,7 @@ fn render_flake_with_lookup( hyperhive_flake: &str, docs_flake: &str, nixpkgs_flake: &str, - claude_code_path: &str, + claude_code_path: Option<&str>, dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, @@ -1063,12 +1063,9 @@ where // which pure eval rejects. The agent module puts its `bin/` on the // harness PATH; the host module holds the gc root, since a path // spelled out here is text and references nothing. - // Empty = no override; agents keep their own nixpkgs' `claude-code`. - if !claude_code_path.is_empty() { - let _ = writeln!( - out, - " hyperhive.claudeCodePath = \"{claude_code_path}\";" - ); + // `None` = no override; agents keep their own nixpkgs' `claude-code`. + if let Some(path) = claude_code_path { + let _ = writeln!(out, " hyperhive.claudeCodePath = \"{path}\";"); } // CA trust: embed every hive-trusted CA so each agent validates them at // build time. The list is the hive's own self-signed CA (when active) @@ -1580,7 +1577,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1625,7 +1622,7 @@ mod tests { "github:example/hyperhive", "", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1649,7 +1646,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "/nix/store/cccc-claude-code-2.1.220", + Some("/nix/store/cccc-claude-code-2.1.220"), 8000, "she/her", &std::collections::HashMap::new(), @@ -1668,7 +1665,7 @@ mod tests { #[test] fn render_flake_omits_claude_path_when_unset() { - // Empty = no host-level pin: the option is left undefined so the + // `None` = no host-level pin: the option is left undefined so the // agent module keeps its own nixpkgs' `claude-code` (and keeps it // in `environment.systemPackages`, which is what makes the // unpinned case self-contained). @@ -1676,7 +1673,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1697,7 +1694,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1728,7 +1725,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1762,7 +1759,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1798,7 +1795,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1837,7 +1834,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1880,7 +1877,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -1964,7 +1961,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -2050,7 +2047,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), @@ -2093,7 +2090,7 @@ mod tests { "github:example/hyperhive", "path:/nix/store/bbbb-hyperhive-docs-source", "path:/nix/store/aaaa-nixpkgs-source", - "", + None, 8000, "she/her", &std::collections::HashMap::new(), diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index ffec918e..5b282fb0 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -62,11 +62,11 @@ }; options.hyperhive.claudeCodePath = lib.mkOption { - type = lib.types.str; - default = ""; + type = lib.types.nullOr lib.types.str; + default = null; example = "/nix/store/…-claude-code-2.1.220"; description = '' - Store path of the `claude-code` this agent runs, or `""` (the + Store path of the `claude-code` this agent runs, or `null` (the default) to use the `claude-code` from the container's own nixpkgs. @@ -212,7 +212,7 @@ ] ++ [ ( - if config.hyperhive.claudeCodePath == "" then + if config.hyperhive.claudeCodePath == null then pkgs.claude-code else # Host-pinned claude: a symlink farm around a path the diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 06aab90e..bbcb528c 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -59,7 +59,7 @@ let fi ''; - # Store path of the `claude-code` every agent runs, or "" for "each + # Store path of the `claude-code` every agent runs, or null for "each # agent keeps the one out of its own nixpkgs". meta.rs writes it into # each agent's generated flake as a plain string literal, and the agent # module puts its `bin/` on the harness PATH. @@ -73,7 +73,7 @@ let # do not hand meta.rs the path by a route that drops it. The failure # mode is a garbage-collected `claude` and a hive that can't take a # turn, weeks after the commit that caused it. - claudeCodePath = if cfg.claudeCodePackage == null then "" else "${cfg.claudeCodePackage}"; + claudeCodePath = if cfg.claudeCodePackage == null then null else "${cfg.claudeCodePackage}"; # The `hive-c0re serve` config JSON. Keys are snake_case to match the # `ServeConfig` serde shape the daemon deserialises (the From 4d885df9ade2575c86915042f88dca6b20cf5a0b Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 27 Jul 2026 13:43:16 +0200 Subject: [PATCH 3/9] refactor(#2693): pass claudeCodePackage straight into serve.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `builtins.toJSON` already serialises a derivation as its out path and `null` as `null`, so the `if … then null else "${…}"` binding was doing by hand what the serialiser does anyway. Hand the package in whole and drop the intermediate. The gc-root property is unchanged and re-measured on the real module: `environment.etc."hyperhive/serve.json".text` still `hasContext`, so the host system closure still holds the package alive. Verified both ways — with the package set the rendered JSON is byte-identical to the interpolated version, and unset still emits `null` — with all module assertions passing in each case. The assertion now checks `builtins.toJSON cfg.claudeCodePackage`, which is the value that actually has to carry the context, rather than an intermediate that no longer exists. --- nix/host-modules/hive-c0re/default.nix | 34 ++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index bbcb528c..28937561 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -59,22 +59,6 @@ let fi ''; - # Store path of the `claude-code` every agent runs, or null for "each - # agent keeps the one out of its own nixpkgs". meta.rs writes it into - # each agent's generated flake as a plain string literal, and the agent - # module puts its `bin/` on the harness PATH. - # - # This interpolation is also the package's gc root, and the only one: - # it carries store context, `builtins.toJSON` preserves that, so the - # /etc entry below genuinely references the package and the host's - # system closure holds it alive. Nothing on the container side can — - # a path spelled out in a generated flake is text, not a reference. - # Hence the assertion further down: do NOT discard this context, and - # do not hand meta.rs the path by a route that drops it. The failure - # mode is a garbage-collected `claude` and a hive that can't take a - # turn, weeks after the commit that caused it. - claudeCodePath = if cfg.claudeCodePackage == null then null else "${cfg.claudeCodePackage}"; - # The `hive-c0re serve` config JSON. Keys are snake_case to match the # `ServeConfig` serde shape the daemon deserialises (the # container-injected HiveEnv fields, flattened, plus the hive-c0re-local @@ -92,7 +76,20 @@ let hyperhive_flake = cfg.hyperhiveFlake; hyperhive_docs_flake = cfg.hyperhiveDocs; nixpkgs_flake = cfg.nixpkgsFlake; - claude_code_path = claudeCodePath; + # The `claude-code` every agent runs, or null for "each agent keeps + # the one out of its own nixpkgs". `builtins.toJSON` serialises a + # derivation as its out path (and null as null), so the package goes + # in whole rather than interpolated — that is also the ONLY thing + # gc-rooting it: the resulting string carries store context, so this + # /etc entry genuinely references the package and the host's system + # closure holds it alive. Nothing container-side can — meta.rs writes + # the path into each agent's generated flake as a plain string + # literal, and text is not a reference. Hence the assertion below: do + # NOT discard this context, and do not route the path through + # anything that drops it. The failure mode is a garbage-collected + # `claude` and a hive that can't take a turn, weeks after the commit + # that caused it. + claude_code_path = cfg.claudeCodePackage; dashboard_port = cfg.dashboardPort; operator_pronouns = cfg.operatorPronouns; context_window_tokens = cfg.contextWindowTokens; @@ -119,7 +116,8 @@ in # deploy — it only shows up as every agent failing to spawn # `claude`, at whatever unrelated moment the gc runs. Cheap # enough to just check. - assertion = cfg.claudeCodePackage == null || builtins.hasContext claudeCodePath; + assertion = + cfg.claudeCodePackage == null || builtins.hasContext (builtins.toJSON cfg.claudeCodePackage); message = '' services.hyperhive.c0re.claudeCodePackage lost its store context on the way into /etc/hyperhive/serve.json, so the From bc83fde4ad39c977a2ac83bbd3ff2bfe6f5f9ac5 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 12:53:12 +0200 Subject: [PATCH 4/9] claude-plugins: ship a hyperhive-authored notes-hygiene skill by default --- .../.claude-plugin/marketplace.json | 16 +++++++ .../notes-hygiene/.claude-plugin/plugin.json | 7 +++ .../skills/notes-hygiene/SKILL.md | 48 +++++++++++++++++++ flake.nix | 1 + nix/agent-modules/claude-settings.nix | 34 +++++++++---- nix/agent-modules/packages.nix | 4 +- nix/packages/claude-plugins.nix | 48 +++++++++++++++++++ nix/packages/default.nix | 5 ++ 8 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 claude-plugins/.claude-plugin/marketplace.json create mode 100644 claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json create mode 100644 claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md create mode 100644 nix/packages/claude-plugins.nix diff --git a/claude-plugins/.claude-plugin/marketplace.json b/claude-plugins/.claude-plugin/marketplace.json new file mode 100644 index 00000000..af3d093c --- /dev/null +++ b/claude-plugins/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "hyperhive", + "description": "hyperhive-authored Claude Code plugins, shipped hive-wide via the default hyperhive.claudeMarketplaces/claudePlugins nix options (nix/agent-modules/claude-settings.nix). Built as a nix store path and added with `claude plugin marketplace add ` - no forge repo or git remote needed. Add a new plugins// subtree + one entry below to ship another.", + "owner": { + "name": "hyperhive" + }, + "plugins": [ + { + "name": "notes-hygiene", + "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files - use before archiving, overwriting, or pruning any notes/state file.", + "source": "./plugins/notes-hygiene", + "category": "productivity" + } + ] +} diff --git a/claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json b/claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json new file mode 100644 index 00000000..f0918a04 --- /dev/null +++ b/claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "notes-hygiene", + "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files.", + "author": { + "name": "hyperhive" + } +} diff --git a/claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md b/claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md new file mode 100644 index 00000000..d78e643a --- /dev/null +++ b/claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md @@ -0,0 +1,48 @@ +--- +name: notes-hygiene +description: Enforces safe handling of an agent's own durable state/notes files (e.g. state/notes.md, state/TODO.md, or anything else under state/ meant to survive a container restart) - read the file before archiving, overwriting, or pruning it, split resolved work into small dated archive files instead of growing one giant live file, and never shell-pipe-append (>>) into a notes file. Use this whenever you are about to write to a durable notes/state file, especially right before a context-compaction checkpoint, when told to "flush state" or "write down anything relevant", or when a live notes file has grown large and needs an archive pass. +--- + +# Notes Hygiene + +Durable notes/state files (your `CLAUDE.md`, `state/notes.md`, `state/TODO.md`, +and anything else under `state/`) are your memory across container resets and +context compactions. They only work if you actually read them before you +change them - writing blind is how history gets silently destroyed or +duplicated. + +## Before writing to any durable notes file + +1. **Read it first.** Never overwrite or append to a notes/state file based + on memory or assumption of its current contents - the file may have moved + on since your last read (another turn, another agent touched shared + space, a prior session ended mid-edit). Read (or re-read the relevant + section) immediately before editing. +2. **Never pipe-append.** Don't do `echo "..." >> state/notes.md` (or + equivalent) from a shell command. That's a blind write with no chance to + see - and reconcile with - what's already there. Use your file-edit tool + (read, then edit/write) instead. +3. **Prefer a targeted edit over a full rewrite** for anything long-lived: an + edit is less likely to accidentally clobber unrelated content than + regenerating the whole file from your current context. + +## Keeping a notes file lean + +- Split into a **live file** (only what's currently active/gated/relevant - + this is what you re-read every turn, keep it short) and an **archive** + (resolved/closed work, moved out of the hot path, never deleted). +- Archive in **dated, per-topic chunks** - `YYYY-MM-DD-.md` - not one + ever-growing archive blob. `ls` on the archive dir should read like a + timeline. +- When you finish a piece of work, collapse its entry in the live file to a + one-line "done" summary and move the full detail into a new dated + archive file. +- A live file well past ~500 lines (or into four figures) is overdue for an + archive pass - do it at a natural checkpoint (finishing a task, a + context-compaction prompt, or whenever you notice it). + +## Full convention + +The hive-wide version of these rules (read-only reference - don't duplicate +it into your own notes) lives at `/knowledge/notes-hygiene.md`; read that for +the complete rationale. diff --git a/flake.nix b/flake.nix index 4146a87a..5c127078 100644 --- a/flake.nix +++ b/flake.nix @@ -94,6 +94,7 @@ assets frontend reference-docs + claude-plugins ; }; }; diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index fe5c1943..5aac9f8b 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -228,7 +228,10 @@ in options.hyperhive.claudeMarketplaces = lib.mkOption { type = lib.types.listOf lib.types.str; - default = [ "anthropics/claude-plugins-official" ]; + default = [ + "anthropics/claude-plugins-official" + "${config.hyperhive.packages.claude-plugins}" + ]; example = [ "anthropics/claude-plugins-official" "anthropics/claude-plugins-community" @@ -242,14 +245,24 @@ in reference a marketplace (e.g. `foo@claude-plugins-official`). Rendered to `/etc/hyperhive/claude-marketplaces.json`. - Defaults to Anthropic's official marketplace; agents get it - out of the box without any per-agent.nix wiring. + Defaults to Anthropic's official marketplace plus hyperhive's + own `claude-plugins` nix package (see + `nix/packages/claude-plugins.nix`) — a local-path marketplace + built as a plain nix store path, registered under the name + `hyperhive` (from its `marketplace.json`, not the store path + itself, so plugin specs stay stable across rebuilds). No forge + repo or git remote needed to ship a hive-authored skill; agents + get both marketplaces out of the box without any per-agent.nix + wiring. ''; }; options.hyperhive.claudePlugins = lib.mkOption { type = lib.types.listOf lib.types.str; - default = [ "skill-creator@claude-plugins-official" ]; + default = [ + "skill-creator@claude-plugins-official" + "notes-hygiene@hyperhive" + ]; example = [ "formatter@my-marketplace" "thinking-tools@anthropics" @@ -264,15 +277,18 @@ in `/etc/hyperhive/claude-plugins.json`; the harness reads it via `plugins::install_configured`. - Defaults to Anthropic's `skill-creator`, which teaches an agent - to write, refine, and evaluate its own skills — agents get it - out of the box, matching the default marketplace above. + Defaults to Anthropic's `skill-creator` (teaches an agent to + write, refine, and evaluate its own skills) plus hyperhive's own + `notes-hygiene` skill (read-before-write + dated-archive + discipline for durable notes/state files) — agents get both out + of the box, matching the default marketplaces above. Note that a per-agent definition REPLACES this default rather than extending it (ordinary NixOS list-option semantics, same as `claudeMarketplaces`). An agent that wants extra plugins AND the - default should list `skill-creator@claude-plugins-official` - alongside them. + defaults should list both + `skill-creator@claude-plugins-official` and + `notes-hygiene@hyperhive` alongside them. ''; }; diff --git a/nix/agent-modules/packages.nix b/nix/agent-modules/packages.nix index af3052b8..ca465fee 100644 --- a/nix/agent-modules/packages.nix +++ b/nix/agent-modules/packages.nix @@ -13,8 +13,8 @@ per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`, `hive-bash-daemon`, `hive-forge`, `hive-forge-notify`, `hive-matrix-daemon`, - `hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend` and - `reference-docs` trees. Wired by the flake's agent-base/ruth + `hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend`, + `reference-docs` and `claude-plugins` trees. Wired by the flake's agent-base/ruth nixosModules to `hyperhive.packages..*`; override an individual key per-agent to swap in a patched binary. ''; diff --git a/nix/packages/claude-plugins.nix b/nix/packages/claude-plugins.nix new file mode 100644 index 00000000..b83896a0 --- /dev/null +++ b/nix/packages/claude-plugins.nix @@ -0,0 +1,48 @@ +{ + stdenv, +}: + +# hyperhive-authored Claude Code plugin marketplace (source tree at +# ../../claude-plugins), shipped as a standalone derivation and spliced +# into the *default* `hyperhive.claudeMarketplaces`/`claudePlugins` lists +# (nix/agent-modules/claude-settings.nix) as a local marketplace source. +# `claude plugin marketplace add` accepts a plain filesystem path (verified: +# `claude plugin marketplace add --help` → "Add a marketplace from a URL, +# path, or GitHub repo"), and registers it under the marketplace.json's own +# `name` field ("hyperhive") rather than the path — so +# `@hyperhive` is a stable plugin spec regardless of the store +# path's hash. The built store path IS the marketplace: no forge repo, no +# git remote, no PAT/PR ceremony needed to ship a skill hive-wide. +# +# Every hive-authored skill lives under its own `plugins//` subtree +# in the source tree; add one there plus a matching entry in +# `.claude-plugin/marketplace.json`'s `plugins` list to ship another. +# +# Pure data: nothing to build, `$out` is the tree as-is (mirrors +# ./reference-docs.nix). `cp -r . "$out"` (not `cp -r ./* $out`) because +# the marketplace/plugin manifests live under `.claude-plugin/` — a +# hidden directory a shell glob (`*`) would silently skip. +stdenv.mkDerivation { + pname = "hyperhive-claude-plugins"; + version = "0.1.0"; + # Narrow src keeps this derivation's input hash decoupled from the rest + # of the tree — a skill edit only re-hashes this. + src = ../../claude-plugins; + + dontBuild = true; + dontConfigure = true; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r . "$out"/ + runHook postInstall + ''; + + dontFixup = true; + + meta = { + description = "hyperhive-authored Claude Code plugin marketplace (skills shipped hive-wide)"; + homepage = "https://forge.darkest.space/hyperhive/hyperhive"; + }; +} diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 8a8746fe..b3759bb9 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -139,6 +139,11 @@ in # so the binary derivation stays cached when a prompt edit ripples # through. assets = pkgs.callPackage ./assets.nix { }; + # hyperhive-authored Claude Code plugin marketplace (source tree at + # ../../claude-plugins) — a local-path marketplace spliced into the + # default `hyperhive.claudeMarketplaces`/`claudePlugins` lists. See + # ./claude-plugins.nix for the "why a store path, not a repo" rationale. + claude-plugins = pkgs.callPackage ./claude-plugins.nix { }; # The repo docs/ markdown tree as a standalone derivation — # agents read it in-container (added as a claude additional # directory) and the website repo reuses it as a flake input, From 2bd1b0a3d5352d156f917ce4d5d9cd62fc7b78c8 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 12:57:55 +0200 Subject: [PATCH 5/9] claude-plugins: rename notes-hygiene to state-hygiene --- claude-plugins/.claude-plugin/marketplace.json | 4 ++-- .../.claude-plugin/plugin.json | 2 +- .../skills/state-hygiene}/SKILL.md | 4 ++-- nix/agent-modules/claude-settings.nix | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) rename claude-plugins/plugins/{notes-hygiene => state-hygiene}/.claude-plugin/plugin.json (85%) rename claude-plugins/plugins/{notes-hygiene/skills/notes-hygiene => state-hygiene/skills/state-hygiene}/SKILL.md (98%) diff --git a/claude-plugins/.claude-plugin/marketplace.json b/claude-plugins/.claude-plugin/marketplace.json index af3d093c..1cc8a964 100644 --- a/claude-plugins/.claude-plugin/marketplace.json +++ b/claude-plugins/.claude-plugin/marketplace.json @@ -7,9 +7,9 @@ }, "plugins": [ { - "name": "notes-hygiene", + "name": "state-hygiene", "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files - use before archiving, overwriting, or pruning any notes/state file.", - "source": "./plugins/notes-hygiene", + "source": "./plugins/state-hygiene", "category": "productivity" } ] diff --git a/claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json b/claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json similarity index 85% rename from claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json rename to claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json index f0918a04..44486683 100644 --- a/claude-plugins/plugins/notes-hygiene/.claude-plugin/plugin.json +++ b/claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { - "name": "notes-hygiene", + "name": "state-hygiene", "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files.", "author": { "name": "hyperhive" diff --git a/claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md b/claude-plugins/plugins/state-hygiene/skills/state-hygiene/SKILL.md similarity index 98% rename from claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md rename to claude-plugins/plugins/state-hygiene/skills/state-hygiene/SKILL.md index d78e643a..2cf6d351 100644 --- a/claude-plugins/plugins/notes-hygiene/skills/notes-hygiene/SKILL.md +++ b/claude-plugins/plugins/state-hygiene/skills/state-hygiene/SKILL.md @@ -1,9 +1,9 @@ --- -name: notes-hygiene +name: state-hygiene description: Enforces safe handling of an agent's own durable state/notes files (e.g. state/notes.md, state/TODO.md, or anything else under state/ meant to survive a container restart) - read the file before archiving, overwriting, or pruning it, split resolved work into small dated archive files instead of growing one giant live file, and never shell-pipe-append (>>) into a notes file. Use this whenever you are about to write to a durable notes/state file, especially right before a context-compaction checkpoint, when told to "flush state" or "write down anything relevant", or when a live notes file has grown large and needs an archive pass. --- -# Notes Hygiene +# State Hygiene Durable notes/state files (your `CLAUDE.md`, `state/notes.md`, `state/TODO.md`, and anything else under `state/`) are your memory across container resets and diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 5aac9f8b..5fecee08 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -261,7 +261,7 @@ in type = lib.types.listOf lib.types.str; default = [ "skill-creator@claude-plugins-official" - "notes-hygiene@hyperhive" + "state-hygiene@hyperhive" ]; example = [ "formatter@my-marketplace" @@ -279,7 +279,7 @@ in Defaults to Anthropic's `skill-creator` (teaches an agent to write, refine, and evaluate its own skills) plus hyperhive's own - `notes-hygiene` skill (read-before-write + dated-archive + `state-hygiene` skill (read-before-write + dated-archive discipline for durable notes/state files) — agents get both out of the box, matching the default marketplaces above. @@ -288,7 +288,7 @@ in `claudeMarketplaces`). An agent that wants extra plugins AND the defaults should list both `skill-creator@claude-plugins-official` and - `notes-hygiene@hyperhive` alongside them. + `state-hygiene@hyperhive` alongside them. ''; }; From d207299d0f8bf4caac4f75ab3b247597833ad8db Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 13:03:20 +0200 Subject: [PATCH 6/9] claude-plugins: fix docs build by adding defaultText to claudeMarketplaces --- nix/agent-modules/claude-settings.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 5fecee08..997e476e 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -232,6 +232,10 @@ in "anthropics/claude-plugins-official" "${config.hyperhive.packages.claude-plugins}" ]; + defaultText = lib.literalMD '' + `[ "anthropics/claude-plugins-official" "''${hyperhive.packages.claude-plugins}" ]` + (the flake's own local-path plugin marketplace) + ''; example = [ "anthropics/claude-plugins-official" "anthropics/claude-plugins-community" From 1d3f22805faebfc279ec5f10fa37a1cf35f8b793 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 13:21:00 +0200 Subject: [PATCH 7/9] claude-plugins: generalize state-hygiene into a shared base plugin for all agents --- claude-plugins/.claude-plugin/marketplace.json | 6 +++--- .../plugins/base/.claude-plugin/plugin.json | 7 +++++++ .../skills/state-hygiene/SKILL.md | 0 .../state-hygiene/.claude-plugin/plugin.json | 7 ------- nix/agent-modules/claude-settings.nix | 18 +++++++++++------- 5 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 claude-plugins/plugins/base/.claude-plugin/plugin.json rename claude-plugins/plugins/{state-hygiene => base}/skills/state-hygiene/SKILL.md (100%) delete mode 100644 claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json diff --git a/claude-plugins/.claude-plugin/marketplace.json b/claude-plugins/.claude-plugin/marketplace.json index 1cc8a964..1ada195c 100644 --- a/claude-plugins/.claude-plugin/marketplace.json +++ b/claude-plugins/.claude-plugin/marketplace.json @@ -7,9 +7,9 @@ }, "plugins": [ { - "name": "state-hygiene", - "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files - use before archiving, overwriting, or pruning any notes/state file.", - "source": "./plugins/state-hygiene", + "name": "base", + "description": "hyperhive's base plugin - skills every agent needs, regardless of role. Currently: state-hygiene (read-before-write discipline and dated-archive convention for durable agent notes/state files). Add a new skill here (not a new plugin) if it applies to all agents; give it its own specialized plugin if it doesn't.", + "source": "./plugins/base", "category": "productivity" } ] diff --git a/claude-plugins/plugins/base/.claude-plugin/plugin.json b/claude-plugins/plugins/base/.claude-plugin/plugin.json new file mode 100644 index 00000000..bb267b49 --- /dev/null +++ b/claude-plugins/plugins/base/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "base", + "description": "hyperhive's base plugin - skills every agent needs, regardless of role.", + "author": { + "name": "hyperhive" + } +} diff --git a/claude-plugins/plugins/state-hygiene/skills/state-hygiene/SKILL.md b/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md similarity index 100% rename from claude-plugins/plugins/state-hygiene/skills/state-hygiene/SKILL.md rename to claude-plugins/plugins/base/skills/state-hygiene/SKILL.md diff --git a/claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json b/claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json deleted file mode 100644 index 44486683..00000000 --- a/claude-plugins/plugins/state-hygiene/.claude-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "state-hygiene", - "description": "Read-before-write discipline and dated-archive convention for durable agent notes/state files.", - "author": { - "name": "hyperhive" - } -} diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 997e476e..f946839d 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -265,7 +265,7 @@ in type = lib.types.listOf lib.types.str; default = [ "skill-creator@claude-plugins-official" - "state-hygiene@hyperhive" + "base@hyperhive" ]; example = [ "formatter@my-marketplace" @@ -283,16 +283,20 @@ in Defaults to Anthropic's `skill-creator` (teaches an agent to write, refine, and evaluate its own skills) plus hyperhive's own - `state-hygiene` skill (read-before-write + dated-archive - discipline for durable notes/state files) — agents get both out - of the box, matching the default marketplaces above. + `base` plugin — one plugin bundling every skill that applies to + *all* agents regardless of role (currently just `state-hygiene`, + read-before-write + dated-archive discipline for durable + notes/state files; more all-agent skills land as additional + skills inside this same plugin, not new plugins — a skill that + only some agents need gets its own specialized plugin instead). + Agents get both out of the box, matching the default + marketplaces above. Note that a per-agent definition REPLACES this default rather than extending it (ordinary NixOS list-option semantics, same as `claudeMarketplaces`). An agent that wants extra plugins AND the - defaults should list both - `skill-creator@claude-plugins-official` and - `state-hygiene@hyperhive` alongside them. + defaults should list both `skill-creator@claude-plugins-official` + and `base@hyperhive` alongside them. ''; }; From 291abe6e41a1e48589739be6a9dc65f3ca5e52c8 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 13:44:54 +0200 Subject: [PATCH 8/9] claude-plugins: drop hive-specific /knowledge/notes-hygiene.md reference from state-hygiene skill --- claude-plugins/plugins/base/skills/state-hygiene/SKILL.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md b/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md index 2cf6d351..417e0484 100644 --- a/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md +++ b/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md @@ -41,8 +41,6 @@ duplicated. archive pass - do it at a natural checkpoint (finishing a task, a context-compaction prompt, or whenever you notice it). -## Full convention - -The hive-wide version of these rules (read-only reference - don't duplicate -it into your own notes) lives at `/knowledge/notes-hygiene.md`; read that for -the complete rationale. +If your environment ships its own hive-wide/repo-wide notes convention doc, +treat that as the canonical source and this skill as a reminder to follow +it - don't duplicate its rationale into your own notes either. From 896dfc6194c3ffdf92f2c553a49bfef999b6aad0 Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 27 Jul 2026 13:48:52 +0200 Subject: [PATCH 9/9] claude-plugins: state-hygiene skill - archive files go in a subdir, not state top level --- .../plugins/base/skills/state-hygiene/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md b/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md index 417e0484..276f1f49 100644 --- a/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md +++ b/claude-plugins/plugins/base/skills/state-hygiene/SKILL.md @@ -29,10 +29,13 @@ duplicated. ## Keeping a notes file lean - Split into a **live file** (only what's currently active/gated/relevant - - this is what you re-read every turn, keep it short) and an **archive** - (resolved/closed work, moved out of the hot path, never deleted). + this is what you re-read every turn, keep it short) and an **archive + subdirectory** (e.g. `state/notes-archive/`) for resolved/closed work, + moved out of the hot path, never deleted. Don't drop archived files + loose at the top level of `state/` alongside the live files - a + dedicated subdir keeps `ls state/` showing only what's live. - Archive in **dated, per-topic chunks** - `YYYY-MM-DD-.md` - not one - ever-growing archive blob. `ls` on the archive dir should read like a + ever-growing archive blob. `ls` on the archive subdir should read like a timeline. - When you finish a piece of work, collapse its entry in the live file to a one-line "done" summary and move the full detail into a new dated