Parent Epic
Part of #32 — Plugin system: toggleable internal subsystems
Blocks #35 (declarative surfaces) and #36 (hooks + allow-entries). Independent of all other sub-issues.
Task
Define the plugin manifest format and a registry that loads enabled manifests, plus the enabled-plugins list in user config. This sub-issue adds the mechanism only — it wires nothing into install or render yet, so dist cannot change.
Location
agent_notes/plugins/ — NEW package root
agent_notes/plugins/<name>/plugin.yaml — NEW manifest per plugin (none created here; format defined + validated by a fixture)
agent_notes/registries/plugin_registry.py — NEW
agent_notes/domain/plugin.py — NEW Plugin dataclass
agent_notes/services/user_config.py — add enabled_plugins read/default helper
tests/unit/registries/test_plugin_registry.py — NEW
Changes
Manifest format
name: cost-report # unique; matches directory name
description: <one line>
default: on | off # enabled when user config has no explicit choice
skills: [<skill-name>, ...] # names in data/skills/
agents: [<agent-name>, ...] # names in data/agents/
rules: [<rule-name>, ...] # names in data/rules/
includes: [<include-name>, ...] # shared include names (data/agents/shared/<name>.md)
hooks: [{event: Stop, command: "...", requires: stop_hook}]
allow: ["Bash(agent-notes cost-report)", ...]
requires on a hook or allow-entry names a backend.supports(...) capability; the entry is installed only when the backend has it. This is how the manifest composes with the existing capability axis (epic finding 2) rather than fighting it.
Plugin dataclass + registry
Plugin holds the parsed manifest fields.
plugin_registry.load() globs plugins/*/plugin.yaml, parses each, validates required fields (name, description, default), and rejects a manifest whose name != directory name.
registry.enabled(user_config) returns the plugins that are on: explicit user choice wins; otherwise default.
User config
enabled_plugins key in ~/.config/agent-notes/config: a {name: bool} map, absent by default.
- Helper:
enabled_plugin_names(config, registry) -> set[str].
Not in this sub-issue
No manifest files, no installer/render wiring, no CLI. Those are #35, #36, #34. Keeping this pure means the equivalence gate is trivially satisfied.
Verification
uv run pytest tests/unit/registries/test_plugin_registry.py -v
uv run pytest tests/ -v
Dist: byte-identical. This sub-issue adds unreferenced code; nothing in the build path calls it yet.
Parent Epic
Part of #32 — Plugin system: toggleable internal subsystems
Blocks #35 (declarative surfaces) and #36 (hooks + allow-entries). Independent of all other sub-issues.
Task
Define the plugin manifest format and a registry that loads enabled manifests, plus the enabled-plugins list in user config. This sub-issue adds the mechanism only — it wires nothing into install or render yet, so dist cannot change.
Location
agent_notes/plugins/— NEW package rootagent_notes/plugins/<name>/plugin.yaml— NEW manifest per plugin (none created here; format defined + validated by a fixture)agent_notes/registries/plugin_registry.py— NEWagent_notes/domain/plugin.py— NEWPlugindataclassagent_notes/services/user_config.py— addenabled_pluginsread/default helpertests/unit/registries/test_plugin_registry.py— NEWChanges
Manifest format
requireson a hook or allow-entry names abackend.supports(...)capability; the entry is installed only when the backend has it. This is how the manifest composes with the existing capability axis (epic finding 2) rather than fighting it.Plugindataclass + registryPluginholds the parsed manifest fields.plugin_registry.load()globsplugins/*/plugin.yaml, parses each, validates required fields (name,description,default), and rejects a manifest whosename!= directory name.registry.enabled(user_config)returns the plugins that are on: explicit user choice wins; otherwisedefault.User config
enabled_pluginskey in~/.config/agent-notes/config: a{name: bool}map, absent by default.enabled_plugin_names(config, registry) -> set[str].Not in this sub-issue
No manifest files, no installer/render wiring, no CLI. Those are #35, #36, #34. Keeping this pure means the equivalence gate is trivially satisfied.
Verification
Dist: byte-identical. This sub-issue adds unreferenced code; nothing in the build path calls it yet.