# Beaver Plugin Skill Mirroring Design ## Decision Beaver V1 plugins are declarative skill bundles. Enabling a plugin mirrors each declared `SKILL.md` and its supporting files into `SkillSpecStore`. From that point onward, the mirrored skill is a normal Beaver skill: - it has the same resolver priority as any workspace-managed skill; - runtime activation, receipts, performance scoring, replay evaluation, review, publish, rollback, and disable all use the existing skill lifecycle; - self-learning only writes Beaver-managed versions and never edits the plugin package; - plugin origin remains metadata, not a separate runtime class. An arbitrary in-process Python entrypoint, hooks, providers, and custom runtime code are out of scope for this plan. Tool-providing plugins should continue to use MCP until a separate executable-plugin security design is approved. ## Why The Proposed Flow Is Correct The proposed "mirror, learn on the mirror, merge on plugin update, then evaluate" flow is correct with one important refinement: plugin upgrades must be treated as a three-way merge, not a two-document rewrite. The three inputs are: 1. `B`, the last accepted upstream plugin snapshot; 2. `L`, the current Beaver-published skill, including local self-learning; 3. `U`, the newly discovered upstream plugin snapshot. This distinction prevents a plugin update from silently deleting local learning and prevents local learning from silently discarding new upstream safety or workflow changes. ## Package Contract Each plugin directory contains `beaver.plugin.json`: ```json { "schema_version": 1, "id": "baoyu-comic", "name": "Baoyu Comic", "version": "1.2.0", "skills": [ { "name": "baoyu-comic", "path": "skills/baoyu-comic" } ] } ``` Rules: - `id` and skill names use lowercase letters, digits, `_`, and `-`. - Skill paths are relative to the plugin root and cannot escape it. - Every skill directory must contain `SKILL.md`. - Symlinks are rejected while mirroring. - Two enabled plugins cannot own the same Beaver skill name. - A plugin cannot overwrite an existing non-plugin workspace skill. - Discovery does not enable a plugin. Enablement is an explicit admin action. ## Storage Model Plugin packages remain outside the managed skill version tree: ```text workspace/ plugins/ baoyu-comic/ beaver.plugin.json skills/baoyu-comic/SKILL.md .beaver/ plugins/state.json skills/ baoyu-comic/ skill.json current.json upstreams/ baoyu-comic/ / upstream.json SKILL.md assets/... versions/ v0001/ version.json SKILL.md assets/... ``` `upstreams/` stores immutable raw plugin snapshots. `versions/` stores runtime-visible Beaver versions. A merged Beaver version may differ from its upstream snapshot. Every upstream snapshot has two hashes: - `skill_content_hash`: canonical hash of normalized `SKILL.md`; used by the LLM merge and skill-content preservation checks. - `skill_tree_hash`: hash of every regular file in the skill tree, including normalized relative path, byte length, bytes, and executable-bit metadata. Symlinks are rejected. This is the supply-chain identity used for update detection and state. The tree hash includes `SKILL.md`, templates, assets, examples, and scripts. Full Unix mode bits are not hashed because umask and extraction tools can change them; only whether any executable bit is set is normalized into the hash. Beaver metadata files such as `version.json` and `upstream.json` are excluded. Every Beaver `SkillVersion` also stores a backward-compatible `tree_hash`. New versions compute it from the complete promoted version directory. Older versions without the field derive it on read, so `L.tree` is available for upgrade classification. Plugin state records: ```json { "plugins": { "baoyu-comic": { "enabled": true, "installed_version": "1.2.0", "manifest_path": "plugins/baoyu-comic/beaver.plugin.json", "updates_paused": false, "skills": { "baoyu-comic": { "accepted_upstream_tree_hash": "sha256...", "observed_upstream_tree_hash": "sha256...", "accepted_beaver_version": "v0003", "current_beaver_version": "v0003", "pending_candidate_id": null, "status": "synced" } } } } } ``` Skill versions and drafts also carry plugin provenance. State is operational metadata; version provenance is the durable audit record. ## Initial Enable Flow When an admin enables a valid plugin: 1. Discover and validate the manifest. 2. Copy each declared skill into an immutable upstream snapshot. 3. Reject ownership/name conflicts before changing any skill. 4. Run the existing deterministic skill safety checker against an in-memory initial-mirror draft and reject failed or critical results. 5. Publish an exact Beaver mirror as the next skill version. 6. Copy supporting files into that version. 7. Mark the skill `source_kind="plugin"` and active. 8. Record plugin ID, plugin version, source path, upstream hash, and mirror mode in `SkillVersion.provenance`. 9. Update plugin state only after all declared skills succeed. Initial enable is an explicit trust action, so it does not require LLM synthesis. Manifest validation, path validation, and the existing static skill safety checks still apply. All files are first written below a transaction staging directory on the same filesystem. Only after manifest validation, tree hashing, conflict checks, and safety checks pass are immutable upstream/version directories promoted with `os.replace()`. `current.json`, `skill.json`, and indexes are atomically replaced under the workspace write lock; plugin state is written last. A failed transaction may leave an unreferenced immutable directory, which cleanup can remove, but it cannot make a partial version runtime-visible. For a new skill, the complete staged skill directory is promoted once. For an existing skill, immutable directories and metadata are promoted first and `current.json` is replaced last as the runtime visibility switch. This provides per-skill atomic visibility; the workspace lock serializes writers across a multi-skill plugin operation. ## Runtime Priority Mirrored plugin skills are loaded exclusively from `SkillSpecStore`. They are not supplied through `SkillsLoader.extra_dirs`. This makes priority deterministic: 1. active published workspace versions, including plugin-origin versions; 2. builtin skills. `source_kind="plugin"` is displayed for provenance but does not lower selection priority or exclude the skill from self-learning. ## Upgrade Classification For each linked skill, compare upstream tree hashes: | Condition | Action | | --- | --- | | `U.tree == B.tree` | No upstream change; no action. | | `L.tree == U.tree` | Acknowledge the new upstream snapshot; no draft needed. | | `L.tree == B.tree` and `U.tree != B.tree` | Create a deterministic `fast_forward` plugin update draft containing `U`. | | `L.tree != B.tree` and `U.tree != B.tree` | Create a `three_way` plugin update candidate using `B`, `L`, and `U`. | Even the `fast_forward` case goes through safety, replay evaluation, review, and publish. It skips LLM merge synthesis because there is no local divergence. Candidate IDs are deterministic: ```text plugin-update::: ``` This makes boot-time sync idempotent. Supporting files use a deterministic path-level three-way merge: - local unchanged from `B`: take `U`; - upstream unchanged from `B`: keep `L`; - both sides equal: keep either; - a file added on only one side: keep it; - divergent edits, delete-versus-edit, or different additions at the same path: record an unresolved file conflict and block publication. The LLM merges only `SKILL.md`. It does not attempt to merge arbitrary or binary supporting files. ## Learning Integration Add candidate kind `plugin_skill_update`. Its evidence contains only references: ```json { "plugin_id": "baoyu-comic", "plugin_version": "1.2.0", "skill_name": "baoyu-comic", "merge_mode": "three_way", "base_upstream_tree_hash": "old-hash", "new_upstream_tree_hash": "new-hash", "local_version": "v0003" } ``` The learning service resolves the actual snapshots from `SkillSpecStore`; raw skill content is not duplicated into `learning-candidates.jsonl`. For `three_way`, the synthesizer receives: - old upstream `B`; - current local skill `L`; - new upstream `U`; - relevant historical run evidence for `L`, when available. The synthesizer must return the merged skill plus explicit merge decisions: ```json { "frontmatter": {}, "content": "...", "change_reason": "...", "preserved_local_sections": [], "adopted_upstream_sections": [], "resolved_conflicts": [], "dropped_sections": [] } ``` The generated draft uses `proposal_kind="plugin_skill_update"` and carries the complete plugin merge provenance. ## Evaluation And Publish Gates The existing flow remains authoritative: ```text candidate -> draft -> static safety -> replay eval -> review -> publish -> rollback if needed ``` Replay eval compares: - baseline arm: current local version `L`; - candidate arm: merged draft `M`. The preservation report is extended for plugin updates: - local preservation: important instructions from `L` are not silently removed; - upstream adoption: new important instructions from `U` are represented; - safety/tool preservation: Safety and Required Tools changes require explicit handling; - unresolved conflicts cause evaluation failure. Publishing is blocked when: - static safety fails; - replay evaluation regresses; - confidence is low under the existing gate; - local or upstream preservation fails; - merge decisions contain unresolved `SKILL.md` conflicts; - the supporting-file merge plan contains unresolved path/content conflicts. On publish, the pipeline notifies `PluginManager`, which advances `accepted_upstream_tree_hash`, clears the pending candidate, and records the new Beaver version. Observer delivery is not the source of truth. At the start of every sync, reconciliation inspects the current published version provenance. If it contains a valid, newer `plugin_skill_update` result and its upstream snapshot exists, plugin state is repaired: - advance `accepted_upstream_tree_hash`; - advance `accepted_beaver_version`; - clear the matching pending candidate; - set status to `synced`. Reconciliation never moves `accepted_beaver_version` backwards after a runtime rollback. An observer failure is audited but does not make an already-successful publish request fail, which avoids client retries creating misleading duplicate operations. ## Concurrent And Failure Behavior - All plugin sync, skill version allocation/publication, plugin state mutation, and learning-candidate mutation share a reentrant cross-process workspace write lock at `.beaver/locks/plugin-skill-write.lock`. - The lock uses the repository's existing `fcntl`/`msvcrt` pattern plus an in-process reentrant guard. Nested store calls reuse the held lock instead of deadlocking. - Candidate existence checks and JSONL writes happen inside the lock. - Version-number allocation and version promotion happen inside the lock. - Explicit enable/sync waits for the lock with a bounded timeout and returns a busy error on timeout. - Engine boot never calls an LLM. Its auto-sync uses a non-blocking lock attempt; when the lock is busy, boot proceeds with the current published skills and reports sync deferred. - Repeated and concurrent boot/sync is idempotent across processes, not only within one Python object. - If another active draft targets the same skill, the plugin update remains pending and is not synthesized until the skill is free. - If a newer plugin version appears while an older update is pending, the old candidate is marked superseded and a new candidate is created against the last accepted upstream. - Rejecting a draft preserves the plugin package, upstream snapshots, current skill, and candidate audit history. Regeneration remains possible. - Partial multi-skill plugin enable never promotes metadata/current pointers until every staged skill passes validation. - Plugin files are never modified by learning or publication. ## Pause, Disable, Missing, And Adopt - Pausing updates suspends discovery-to-candidate sync while linked skills remain active. - Resuming updates reconciles state and performs sync. - Disabling a plugin is an explicit destructive runtime action: it pauses updates and disables linked skills, but never deletes versions or upstream snapshots. The API requires an explicit `disable_linked_skills=true` confirmation. - Re-enabling restores linked skills and performs sync. - A missing plugin package is a supply-chain status only. It marks the plugin `missing`, suspends sync/update, and leaves the current Beaver skills active. - An explicit `adopt` action detaches a skill from its plugin, changes `source_kind` to `managed`, keeps the current version active, and prevents future plugin updates from targeting it. ## Management API And UI Backend endpoints: ```text GET /api/plugins POST /api/plugins/sync POST /api/plugins/{plugin_id}/enable POST /api/plugins/{plugin_id}/pause POST /api/plugins/{plugin_id}/resume POST /api/plugins/{plugin_id}/disable POST /api/plugins/{plugin_id}/skills/{skill_name}/adopt ``` API payloads never expose absolute server paths. Workspace manifests use workspace-relative paths. External manifests use a redacted display path such as `/baoyu-comic/beaver.plugin.json`. The existing Skills page gains a Plugins tab showing: - discovered/enabled/missing/error state; - installed and discovered plugin versions; - declared skills and their current Beaver versions; - sync state and pending learning candidate; - enable, pause, resume, disable, sync, and adopt actions. Plugin-origin skills continue to appear in the normal Published, Candidates, and Drafts tabs with provenance and merge-mode labels. ## Non-Goals - Importing arbitrary plugin Python modules into the Beaver process. - Plugin-defined hooks, providers, channels, or frontend bundles. - Automatic downloading from a plugin marketplace. - Automatically publishing plugin upgrades without review. - Editing or rebasing plugin source files in place. ## Acceptance Criteria 1. Enabling a plugin mirrors all declared skills and supporting files into managed versions. 2. Mirrored skills have the same resolver priority and learning eligibility as ordinary workspace skills. 3. Self-learning never modifies the plugin package. 4. Plugin updates create idempotent `plugin_skill_update` candidates. 5. Local divergence triggers a three-way merge; no divergence triggers a deterministic fast-forward draft. 6. Every plugin update passes the existing safety, replay, review, and publish gates. 7. Publishing advances plugin state and preserves complete provenance. 8. Pause, disable, missing package, rejection, restart, and newer-update races do not lose the current skill or its learned versions; missing packages leave current skills active. 9. Existing non-plugin skills and legacy candidate payloads remain backward compatible. 10. Supporting-file-only updates change the upstream tree hash and create an update candidate. 11. Concurrent boot, sync, and enable operations do not allocate duplicate versions or append duplicate candidates. 12. Sync reconciliation repairs plugin state after a published version succeeds but its observer/state update fails.