From b6e180dfbf002f7f37bfa5f9ae99776ed7434166 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 18 Sep 2026 16:36:44 +0200 Subject: [PATCH] agent: make claudePlugins additive instead of replacing The base set (skill-creator + base@hyperhive) was declared via the option's `default`, so a per-agent definition of claudePlugins replaced it wholesale. Move the base set to a plain `config` definition instead: a listOf option merges multiple plain definitions by concatenation, so an agent's own list now adds to the base set rather than replacing it, while lib.mkForce / lib.mkOverride on the agent side still replace the whole merged list deliberately (mkDefault was ruled out explicitly). Also de-dup at the JSON-render site with lib.unique, so an agent that names a base-set entry itself doesn't get it installed twice, and reword the option doc, which still claimed the old REPLACES semantics. Four module-eval cases cover the unset / agent-adds / mkForce-replaces / duplicate-entry shapes. Refs #4467 --- nix/agent-modules/claude-settings.nix | 41 ++++++++++++++----- nix/module-eval.nix | 58 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 67257ce5..63b77aff 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -190,10 +190,13 @@ in options.services.hyperhive.agent.claudePlugins = lib.mkOption { type = lib.types.listOf lib.types.str; - default = [ - "skill-creator@claude-plugins-official" - "base@hyperhive" - ]; + # No `default` here — the base set below is set as a plain `config` + # DEFINITION (near the bottom of this file) instead. A + # `default` is exactly what a per-agent definition REPLACES; a second + # definition at the same option instead MERGES, which for a + # `listOf` is a concatenation — the standard NixOS shape for "the base + # set plus whatever the agent adds". `lib.mkForce` / `lib.mkOverride` + # still let an operator replace the whole merged list deliberately. example = [ "formatter@my-marketplace" "thinking-tools@anthropics" @@ -208,7 +211,7 @@ in `/etc/hyperhive/claude-plugins.json`; the harness reads it via `plugins::install_configured`. - Defaults to Anthropic's `skill-creator` (teaches an agent to + Always includes Anthropic's `skill-creator` (teaches an agent to write, refine, and evaluate its own skills) plus hyperhive's own `base` plugin — one plugin bundling every skill that applies to *all* agents regardless of role (currently just `state-hygiene`, @@ -219,11 +222,15 @@ in 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 `base@hyperhive` alongside them. + A per-agent definition ADDS to that base set rather than + replacing it — unlike `claudeMarketplaces`, this list is merged + across every module that defines it (ordinary NixOS list-option + semantics: two plain definitions concatenate). An agent that sets + `services.hyperhive.agent.claudePlugins = [ "foo@bar" ]` gets the + base set plus `foo@bar`, with no need to repeat the base entries. + An operator who wants to replace the base set entirely, rather + than add to it, can still do that with `lib.mkForce` or + `lib.mkOverride`. ''; }; @@ -241,8 +248,20 @@ in }; config = { + # The base set, as a DEFINITION rather than the option's `default` — + # see the comment on the option above. A plain definition (no + # `lib.mkOrder` wrapper) merges at the same priority as a per-agent + # definition, so `lib.mkForce` on the agent side still wins outright. + services.hyperhive.agent.claudePlugins = [ + "skill-creator@claude-plugins-official" + "base@hyperhive" + ]; + environment.etc."hyperhive/claude-plugins.json".text = - builtins.toJSON config.services.hyperhive.agent.claudePlugins; + # `lib.unique` so an agent that lists `base@hyperhive` (or + # `skill-creator@…`) explicitly, on top of the base set this module + # already contributes, doesn't install it twice. + builtins.toJSON (lib.unique config.services.hyperhive.agent.claudePlugins); environment.etc."hyperhive/claude-marketplaces.json".text = builtins.toJSON config.services.hyperhive.agent.claudeMarketplaces; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 6db0065f..fd5b5126 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -697,6 +697,19 @@ let homeserver = "https://matrix.example.invalid"; }; }; + # `claudePlugins`'s additive-merge shape: a plain per-agent + # definition must ADD to the module's own base-set definition rather than + # replacing it, while `lib.mkForce` must still replace the whole list + # outright — the two arms below plus the unset default (read directly off + # `bare`-shaped `agent { }`, no fixture of its own needed) are the three + # cases that shape has to hold. + agentPluginsDefault = agent { }; + agentPluginsAdded = agent { claudePlugins = [ "foo@bar" ]; }; + agentPluginsForced = agent { claudePlugins = lib.mkForce [ "foo@bar" ]; }; + # The de-dup arm: an agent that names a base-set entry explicitly must not + # get it installed twice. + agentPluginsDuplicate = agent { claudePlugins = [ "base@hyperhive" ]; }; + agentPlugins = machine: machine.services.hyperhive.agent.claudePlugins; agentHarness = machine: machine.systemd.services.hive-agent; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; agentBaoIdentity = machine: machine.systemd.services.hive-agent-bao-identity; @@ -2080,6 +2093,51 @@ let # No hive homeserver, so nothing may claim one. && !(env ? HIVE_MATRIX_URL); } + { + # Case 1 of the additive-merge shape: an agent that declares + # nothing gets exactly the module's base set, no more and no less. + name = "an agent with no claudePlugins definition gets exactly the base set"; + ok = + agentPlugins agentPluginsDefault == [ + "skill-creator@claude-plugins-official" + "base@hyperhive" + ]; + } + { + # Case 2: a plain per-agent definition ADDS to the base set (list-typed + # options at equal priority concatenate) rather than replacing it — + # the property the operator ruling asked for instead of `mkDefault`. + # Sorted before comparing: concatenation order between two same- + # priority definitions is a module-system implementation detail this + # case isn't about — membership and count are. + name = "an agent's own claudePlugins definition adds to the base set"; + ok = + builtins.sort builtins.lessThan (agentPlugins agentPluginsAdded) == [ + "base@hyperhive" + "foo@bar" + "skill-creator@claude-plugins-official" + ]; + } + { + # Case 3: `lib.mkForce` is still the escape hatch — an operator who + # wants the base set gone, not extended, can still say so outright. + name = "an agent's mkForce claudePlugins replaces the base set outright"; + ok = agentPlugins agentPluginsForced == [ "foo@bar" ]; + } + { + # De-dup arm: an agent that names a base-set entry itself must not get + # it installed twice — `lib.unique` at the JSON-render site, not the + # option's merge (the merge is a plain concatenation on purpose, so + # the un-deduped list stays readable for `agentPlugins` above). + name = "an agent repeating a base-set plugin does not get it installed twice"; + ok = + builtins.sort builtins.lessThan ( + builtins.fromJSON agentPluginsDuplicate.environment.etc."hyperhive/claude-plugins.json".text + ) == [ + "base@hyperhive" + "skill-creator@claude-plugins-official" + ]; + } { # The doctrine three glue files state, as a property a rewrite has to # keep: a client is defined by holding a certificate the store accepts,