docs(plugin): update phase2/3 roadmap and plans
This commit is contained in:
parent
d3eb13c3be
commit
836da4470a
3 changed files with 930 additions and 0 deletions
235
docs/plans/2026-02-28-plugin-system-phase2-phase3-design.md
Normal file
235
docs/plans/2026-02-28-plugin-system-phase2-phase3-design.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# PR #473 Phase 2/3 Rethink
|
||||
|
||||
## What Changed
|
||||
|
||||
The previous draft mixed control-plane work with schema-heavy plugin config too early.
|
||||
This rethink narrows scope so implementation matches the current codebase:
|
||||
|
||||
- Phase 2 is only plugin selection and runtime wiring.
|
||||
- Phase 3 is introspection, linting, and optional plugin-specific settings.
|
||||
- `plugin.Plugin` stays unchanged in both phases.
|
||||
|
||||
## Hard Scope
|
||||
|
||||
- In-process built-in plugins only.
|
||||
- No runtime loader, no dynamic module loading, no hot reload.
|
||||
- Runtime loading remains Phase 4 and must reuse Phase 2/3 abstractions.
|
||||
|
||||
## Baseline
|
||||
|
||||
- Baseline is PR #473 phase-0/phase-1 behavior (`pkg/plugin`, `pkg/hooks`, `pkg/agent/loop.go`).
|
||||
- Existing deployments without `plugins` config must keep the same effective behavior.
|
||||
|
||||
## Implemented Snapshot
|
||||
|
||||
Implemented in current Phase 2/3 scope:
|
||||
|
||||
- Phase 2:
|
||||
- typed plugin config schema with `plugins.default_enabled`, `plugins.enabled`, `plugins.disabled`.
|
||||
- deterministic plugin resolver.
|
||||
- startup wiring in both `agent` and `gateway` paths.
|
||||
- Phase 3:
|
||||
- plugin metadata introspection for built-ins.
|
||||
- CLI support for listing and linting plugin config.
|
||||
|
||||
Command examples:
|
||||
|
||||
```bash
|
||||
picoclaw plugin list
|
||||
picoclaw plugin list --format json
|
||||
picoclaw plugin lint --config ~/.picoclaw/config.json
|
||||
```
|
||||
|
||||
Precision note:
|
||||
|
||||
- No dynamic runtime plugin loading/hot reload.
|
||||
- Plugin-specific `settings` remain optional future work and are not required by the implemented Phase 2 selection plane.
|
||||
|
||||
## Phase 2: Selection Plane (Minimal, Deterministic)
|
||||
|
||||
### Goal
|
||||
|
||||
Make plugin enable/disable operational from config with deterministic behavior and fail-fast handling.
|
||||
|
||||
### Config (Phase 2 only)
|
||||
|
||||
Project-facing examples should follow the repo default config format (JSON).
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"default_enabled": true,
|
||||
"enabled": ["policy-demo"],
|
||||
"disabled": ["legacy_policy"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No plugin-specific `settings` in Phase 2.
|
||||
|
||||
### Resolution Rules (authoritative)
|
||||
|
||||
For each built-in plugin name in sorted order:
|
||||
|
||||
1. normalize names (`trim`, `lowercase`) for matching.
|
||||
2. if in `disabled`, mark disabled.
|
||||
3. else if `enabled` list is non-empty, enable only if listed.
|
||||
4. else enable only if `default_enabled=true`.
|
||||
|
||||
### Error Policy (Phase 2)
|
||||
|
||||
- unknown name in `enabled`: startup error.
|
||||
- unknown name in `disabled`: warning only.
|
||||
- duplicates after normalization: dedupe and warn.
|
||||
- overlap between `enabled` and `disabled`: disabled wins.
|
||||
|
||||
### Required Code Changes
|
||||
|
||||
- `pkg/config/config.go`, `pkg/config/defaults.go`
|
||||
- add typed `plugins` block (`default_enabled`, `enabled`, `disabled`).
|
||||
- `pkg/plugin/manager.go`
|
||||
- add built-in registry map and deterministic resolver.
|
||||
- expose resolution result buckets (`enabled`, `disabled`, `unknown`).
|
||||
- `cmd/picoclaw/internal/agent/helpers.go`
|
||||
- resolve plugins from config and wire into `loop.EnablePlugins(...)`.
|
||||
- `cmd/picoclaw/internal/gateway/helpers.go`
|
||||
- same as agent path.
|
||||
- `pkg/agent/loop.go`
|
||||
- keep existing plugin interface and lifecycle.
|
||||
- add startup diagnostics with final resolved plugin names.
|
||||
|
||||
### PR Slicing (Review-Friendly)
|
||||
|
||||
Keep Phase 2 and Phase 3 as separate PR series.
|
||||
|
||||
1. Phase 2 PR-A: config schema + resolver only.
|
||||
2. Phase 2 PR-B: agent/gateway startup wiring + startup diagnostics.
|
||||
3. Phase 2 PR-C: tests + docs updates.
|
||||
4. Phase 3 PR-A: manager introspection + metadata side interface.
|
||||
5. Phase 3 PR-B: `plugin list` and `plugin lint` commands.
|
||||
6. Phase 3 PR-C: observability polishing + integration tests + docs.
|
||||
|
||||
### Phase 2 Acceptance Gate
|
||||
|
||||
- No `plugins` block: behavior matches baseline.
|
||||
- Unknown name in `enabled` fails startup with actionable message.
|
||||
- Resolution order and result are deterministic.
|
||||
- Entry points actually wire resolved plugins (no silent no-op).
|
||||
- Startup logs show enabled/disabled plugin sets.
|
||||
|
||||
### Phase 2 Test Matrix
|
||||
|
||||
1. No `plugins` block.
|
||||
- Expected: same enabled plugin set as baseline.
|
||||
- Expected log keys: `plugins.enabled=[]`, `plugins.disabled=[]`, `plugins.mode=baseline`.
|
||||
2. `enabled=["policy-demo"]`, empty `disabled`.
|
||||
- Expected: only `policy-demo` loaded.
|
||||
- Expected log keys: `plugins.enabled=["policy-demo"]`, `plugins.disabled=[]`.
|
||||
3. `disabled=["policy-demo"]`, empty `enabled`.
|
||||
- Expected: `policy-demo` not loaded.
|
||||
- Expected log keys: `plugins.enabled=[]`, `plugins.disabled=["policy-demo"]`.
|
||||
4. Overlap: `enabled=["policy-demo"]`, `disabled=["policy-demo"]`.
|
||||
- Expected: plugin disabled (disabled wins).
|
||||
- Expected log keys: `plugins.conflict=["policy-demo"]`, `plugins.disabled=["policy-demo"]`.
|
||||
5. Unknown in `enabled`: `enabled=["not_exists"]`.
|
||||
- Expected: startup fails.
|
||||
- Expected error text includes: `unknown plugin in enabled`.
|
||||
6. Unknown in `disabled`: `disabled=["not_exists"]`.
|
||||
- Expected: startup continues with warning.
|
||||
- Expected warning text includes: `unknown plugin in disabled`.
|
||||
7. Duplicates/case variants: `enabled=["Policy_Demo","policy_demo"]`.
|
||||
- Expected: deduped after normalization and warning emitted.
|
||||
- Expected warning text includes: `duplicate plugin name after normalization`.
|
||||
|
||||
## Phase 3: Introspection Plane (DX + Validation)
|
||||
|
||||
### Goal
|
||||
|
||||
Make plugin state inspectable and config validation review-friendly.
|
||||
|
||||
### Capabilities
|
||||
|
||||
- Manager introspection:
|
||||
- `DescribeAll() []PluginInfo`
|
||||
- `DescribeEnabled() []PluginInfo`
|
||||
- Optional metadata side interface for plugins:
|
||||
|
||||
```go
|
||||
type PluginDescriptor interface {
|
||||
Info() PluginInfo
|
||||
}
|
||||
```
|
||||
|
||||
- CLI:
|
||||
- `picoclaw plugin list` (text/json).
|
||||
- `picoclaw plugin lint --config <path>`.
|
||||
- Diagnostics:
|
||||
- structured startup fields: `plugin`, `status`, `disabled_reason`, `error_code`.
|
||||
- hook invocation outcome fields: `plugin`, `hook`, `result`, `duration_ms`.
|
||||
|
||||
### Optional Phase 3 Extension
|
||||
|
||||
If needed after list/lint lands, introduce plugin-specific `settings` with strict schema validation.
|
||||
This is explicitly Phase 3, not Phase 2.
|
||||
|
||||
### Phase 3 Acceptance Gate
|
||||
|
||||
- `plugin list` output is stable in text and JSON.
|
||||
- `plugin lint` returns non-zero on invalid plugin names/config.
|
||||
- Logs/events include plugin resolution and hook invocation outcomes.
|
||||
- Tests cover one disabled path and one lint failure path.
|
||||
|
||||
### Phase 3 Test Matrix
|
||||
|
||||
1. `picoclaw plugin list` text output.
|
||||
- Expected: deterministic ordering and fields: `name`, `status`, `api_version`.
|
||||
2. `picoclaw plugin list --format json`.
|
||||
- Expected: stable JSON schema and deterministic ordering.
|
||||
3. `picoclaw plugin lint --config <path>` valid config.
|
||||
- Expected: exit code `0`.
|
||||
4. `picoclaw plugin lint --config <path>` invalid plugin name.
|
||||
- Expected: non-zero exit.
|
||||
- Expected error text includes: `unknown plugin`.
|
||||
5. Hook invocation with disabled plugin.
|
||||
- Expected: no callback execution from disabled plugin.
|
||||
- Expected diagnostic includes: `disabled_reason`.
|
||||
|
||||
## Rollback Runbook
|
||||
|
||||
1. Revert to baseline behavior.
|
||||
- Action: remove `plugins` block from config and restart process.
|
||||
- Success signal: startup logs contain `plugins.mode=baseline`.
|
||||
2. Recover from bad selection config.
|
||||
- Action: clear `plugins.enabled` and `plugins.disabled`, restart.
|
||||
- Success signal: startup logs contain resolved set equal to baseline set.
|
||||
3. Recover from Phase 3 command regressions.
|
||||
- Action: disable plugin command surface with feature flag and restart.
|
||||
- Success signal: `plugin` command group hidden/disabled in CLI help.
|
||||
4. Incident confirmation checks.
|
||||
- Verify startup logs include:
|
||||
- `plugins.enabled`
|
||||
- `plugins.disabled`
|
||||
- `plugins.unknown_enabled`
|
||||
- `plugins.unknown_disabled`
|
||||
- `plugins.resolution_status`
|
||||
|
||||
## Why This Is More Sound
|
||||
|
||||
- Matches current interfaces (`EnablePlugins` with concrete plugin instances).
|
||||
- Avoids premature schema coupling before metadata/lint tooling exists.
|
||||
- Eliminates silent rollout risk by making entrypoint wiring a Phase 2 gate.
|
||||
- Keeps a clean migration path to Phase 4 runtime sources.
|
||||
|
||||
## External Alignment
|
||||
|
||||
This phase split aligns with common Go OSS progression:
|
||||
- compile-time plugin selection first
|
||||
- discovery/validation CLI second
|
||||
- runtime loader and trust model last
|
||||
|
||||
Reference patterns:
|
||||
- Go `plugin` package caveats: `https://pkg.go.dev/plugin`
|
||||
- HashiCorp `go-plugin` runtime model: `https://pkg.go.dev/github.com/hashicorp/go-plugin`
|
||||
- module listing/validation command patterns (Caddy/Terraform style):
|
||||
- `https://caddyserver.com/docs/command-line`
|
||||
- `https://developer.hashicorp.com/terraform/cli/commands/providers/schema`
|
||||
|
|
@ -0,0 +1,576 @@
|
|||
# Plugin System Phase 2/3 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Implement Phase 2 (config-driven plugin selection + runtime wiring) and Phase 3 (plugin introspection + CLI list/lint + diagnostics) with small, reviewable PRs.
|
||||
|
||||
**Architecture:** Keep in-process compile-time plugins as the only runtime model. Build a deterministic selection plane first (`config` + `plugin` resolver + bootstrap wiring), then add a non-breaking introspection plane (`PluginInfo`, list/lint commands) without changing `plugin.Plugin` contract. Use TDD for each slice and keep changes split into small commits.
|
||||
|
||||
**Tech Stack:** Go, Cobra CLI, Testify, existing PicoClaw `pkg/config`, `pkg/plugin`, `pkg/agent`, `cmd/picoclaw/internal/*`.
|
||||
|
||||
---
|
||||
|
||||
## Preflight Notes
|
||||
|
||||
- Execute this plan inside the dedicated worktree created during design/brainstorming.
|
||||
- Use `@test-driven-development` for every task (`red -> green -> refactor`).
|
||||
- Before final handoff, use `@verification-before-completion`.
|
||||
- Before opening PRs, use `@requesting-code-review`.
|
||||
|
||||
### Task 1: Add Plugin Config Schema and Defaults (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/config/config.go`
|
||||
- Modify: `pkg/config/defaults.go`
|
||||
- Test: `pkg/config/config_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestDefaultConfig_PluginsDefaults(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Plugins.DefaultEnabled {
|
||||
t.Fatal("plugins.default_enabled should default to true")
|
||||
}
|
||||
if len(cfg.Plugins.Enabled) != 0 || len(cfg.Plugins.Disabled) != 0 {
|
||||
t.Fatal("plugins enabled/disabled should default empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_PluginsJSONUnmarshal(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
err := json.Unmarshal([]byte(`{"plugins":{"default_enabled":false,"enabled":["policy-demo"],"disabled":["x"]}}`), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Plugins.DefaultEnabled {
|
||||
t.Fatal("expected default_enabled=false from JSON")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/config -run 'TestDefaultConfig_PluginsDefaults|TestConfig_PluginsJSONUnmarshal' -v`
|
||||
Expected: FAIL with `cfg.Plugins undefined`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
type PluginsConfig struct {
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
Enabled []string `json:"enabled,omitempty"`
|
||||
Disabled []string `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
// ...existing fields...
|
||||
Plugins PluginsConfig `json:"plugins,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
Plugins: PluginsConfig{
|
||||
DefaultEnabled: true,
|
||||
Enabled: []string{},
|
||||
Disabled: []string{},
|
||||
},
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/config -run 'TestDefaultConfig_PluginsDefaults|TestConfig_PluginsJSONUnmarshal' -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/config/config.go pkg/config/defaults.go pkg/config/config_test.go
|
||||
git commit -m "feat(config): add plugins selection config schema"
|
||||
```
|
||||
|
||||
### Task 2: Build Deterministic Selection Resolver (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/plugin/manager.go`
|
||||
- Test: `pkg/plugin/manager_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestResolveSelection_DefaultEnabled(t *testing.T) {}
|
||||
func TestResolveSelection_EnabledListOnly(t *testing.T) {}
|
||||
func TestResolveSelection_DisabledWinsOverlap(t *testing.T) {}
|
||||
func TestResolveSelection_UnknownEnabledFails(t *testing.T) {}
|
||||
func TestResolveSelection_UnknownDisabledWarns(t *testing.T) {}
|
||||
func TestResolveSelection_NormalizeAndDedupe(t *testing.T) {}
|
||||
```
|
||||
|
||||
In each test, assert deterministic sorted resolution and expected error/warning behavior.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/plugin -run 'TestResolveSelection_' -v`
|
||||
Expected: FAIL with undefined resolver types/functions.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
type SelectionInput struct {
|
||||
DefaultEnabled bool
|
||||
Enabled []string
|
||||
Disabled []string
|
||||
}
|
||||
|
||||
type SelectionResult struct {
|
||||
EnabledNames []string
|
||||
DisabledNames []string
|
||||
UnknownEnabled []string
|
||||
UnknownDisabled []string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func NormalizePluginName(s string) string { /* strings.TrimSpace + strings.ToLower */ }
|
||||
func ResolveSelection(available []string, in SelectionInput) (SelectionResult, error) { /* deterministic rules */ }
|
||||
```
|
||||
|
||||
Rules implemented exactly:
|
||||
- unknown in `enabled` => error
|
||||
- unknown in `disabled` => warning bucket
|
||||
- overlap => disabled wins
|
||||
- sorted deterministic output
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/plugin -run 'TestResolveSelection_' -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/plugin/manager.go pkg/plugin/manager_test.go
|
||||
git commit -m "feat(plugin): add deterministic plugin selection resolver"
|
||||
```
|
||||
|
||||
### Task 3: Add Built-in Plugin Catalog Without Import Cycles (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Create: `pkg/plugin/builtin/catalog.go`
|
||||
- Test: `pkg/plugin/builtin/catalog_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestCatalogContainsPolicyDemo(t *testing.T) {
|
||||
c := Catalog()
|
||||
fn, ok := c["policy-demo"]
|
||||
if !ok {
|
||||
t.Fatal("expected policy-demo in builtin catalog")
|
||||
}
|
||||
if fn() == nil {
|
||||
t.Fatal("expected non-nil plugin instance")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/plugin/builtin -run TestCatalogContainsPolicyDemo -v`
|
||||
Expected: FAIL with package/file missing.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/plugin"
|
||||
"github.com/sipeed/picoclaw/pkg/plugin/demoplugin"
|
||||
)
|
||||
|
||||
type Factory func() plugin.Plugin
|
||||
|
||||
func Catalog() map[string]Factory {
|
||||
return map[string]Factory{
|
||||
"policy-demo": func() plugin.Plugin {
|
||||
return demoplugin.NewPolicyDemoPlugin(demoplugin.PolicyDemoConfig{})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Names() []string { /* return sorted keys */ }
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/plugin/builtin -run TestCatalogContainsPolicyDemo -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/plugin/builtin/catalog.go pkg/plugin/builtin/catalog_test.go
|
||||
git commit -m "feat(plugin): add builtin plugin catalog package"
|
||||
```
|
||||
|
||||
### Task 4: Add Bootstrap Resolver Module for Agent/Gateway (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Create: `cmd/picoclaw/internal/pluginruntime/bootstrap.go`
|
||||
- Test: `cmd/picoclaw/internal/pluginruntime/bootstrap_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestResolveConfiguredPlugins_UnknownEnabledReturnsError(t *testing.T) {}
|
||||
func TestResolveConfiguredPlugins_ReturnsDeterministicInstances(t *testing.T) {}
|
||||
func TestResolveConfiguredPlugins_UnknownDisabledWarns(t *testing.T) {}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/pluginruntime -run 'TestResolveConfiguredPlugins_' -v`
|
||||
Expected: FAIL with package/file missing.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
type Summary struct {
|
||||
Enabled []string
|
||||
Disabled []string
|
||||
UnknownEnabled []string
|
||||
UnknownDisabled []string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
func ResolveConfiguredPlugins(cfg *config.Config) ([]plugin.Plugin, Summary, error) {
|
||||
// 1) get catalog names from builtin.Names()
|
||||
// 2) call plugin.ResolveSelection(...)
|
||||
// 3) instantiate enabled plugins via builtin.Catalog factories
|
||||
// 4) return instances + summary
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/pluginruntime -run 'TestResolveConfiguredPlugins_' -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/picoclaw/internal/pluginruntime/bootstrap.go cmd/picoclaw/internal/pluginruntime/bootstrap_test.go
|
||||
git commit -m "feat(cli): add plugin runtime bootstrap resolver"
|
||||
```
|
||||
|
||||
### Task 5: Wire Phase 2 Plugin Bootstrap into `agent` and `gateway`
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/picoclaw/internal/agent/helpers.go`
|
||||
- Modify: `cmd/picoclaw/internal/gateway/helpers.go`
|
||||
- Test: `cmd/picoclaw/internal/agent/command_test.go`
|
||||
- Test: `cmd/picoclaw/internal/gateway/command_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add focused assertions that command constructors remain stable after importing plugin bootstrap package and wiring helper calls (no regressions in command metadata).
|
||||
If needed, add table-driven compile/runtime smoke tests in a new `_test.go` under each package.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/agent ./cmd/picoclaw/internal/gateway -run 'TestNew.*Command|Test.*Plugin.*' -v`
|
||||
Expected: FAIL once bootstrap calls are referenced but not integrated correctly.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
pluginsToEnable, summary, err := pluginruntime.ResolveConfiguredPlugins(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve plugins: %w", err)
|
||||
}
|
||||
if len(pluginsToEnable) > 0 {
|
||||
if err := agentLoop.EnablePlugins(pluginsToEnable...); err != nil {
|
||||
return fmt.Errorf("enable plugins: %w", err)
|
||||
}
|
||||
}
|
||||
logger.InfoCF("plugin", "Plugin selection resolved", map[string]any{
|
||||
"enabled": summary.Enabled, "disabled": summary.Disabled,
|
||||
"unknown_disabled": summary.UnknownDisabled,
|
||||
})
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/agent ./cmd/picoclaw/internal/gateway -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/picoclaw/internal/agent/helpers.go cmd/picoclaw/internal/gateway/helpers.go cmd/picoclaw/internal/agent/command_test.go cmd/picoclaw/internal/gateway/command_test.go
|
||||
git commit -m "feat(cli): wire plugin selection into agent and gateway startup"
|
||||
```
|
||||
|
||||
### Task 6: Expose Plugin Resolution in Startup Diagnostics (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/agent/loop.go`
|
||||
- Test: `pkg/agent/loop_test.go`
|
||||
- Test: `pkg/agent/plugin_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestGetStartupInfo_IncludesPluginSummary(t *testing.T) {
|
||||
// create AgentLoop, enable a test plugin, call GetStartupInfo
|
||||
// assert "plugins" key exists with enabled list
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/agent -run 'TestGetStartupInfo_IncludesPluginSummary' -v`
|
||||
Expected: FAIL because `plugins` section is absent.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
if al.pluginManager != nil {
|
||||
info["plugins"] = map[string]any{
|
||||
"enabled": al.pluginManager.Names(),
|
||||
"count": len(al.pluginManager.Names()),
|
||||
}
|
||||
} else {
|
||||
info["plugins"] = map[string]any{
|
||||
"enabled": []string{},
|
||||
"count": 0,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/agent -run 'TestGetStartupInfo_IncludesPluginSummary|TestSetPluginManagerInstallsHookRegistry' -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/agent/loop.go pkg/agent/loop_test.go pkg/agent/plugin_test.go
|
||||
git commit -m "feat(agent): include plugin summary in startup diagnostics"
|
||||
```
|
||||
|
||||
### Task 7: Add Non-Breaking Plugin Metadata Introspection (Phase 3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `pkg/plugin/manager.go`
|
||||
- Test: `pkg/plugin/manager_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestDescribeAll_UsesDescriptorWhenImplemented(t *testing.T) {}
|
||||
func TestDescribeAll_FallsBackForPlainPlugin(t *testing.T) {}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./pkg/plugin -run 'TestDescribeAll_' -v`
|
||||
Expected: FAIL with undefined `PluginInfo` / `DescribeAll`.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
APIVersion string `json:"api_version"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PluginDescriptor interface {
|
||||
Info() PluginInfo
|
||||
}
|
||||
|
||||
func (m *Manager) DescribeAll() []PluginInfo { /* include fallback info */ }
|
||||
func (m *Manager) DescribeEnabled() []PluginInfo { /* status=enabled */ }
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./pkg/plugin -run 'TestDescribeAll_|TestRegisterPluginAndTriggerHook' -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add pkg/plugin/manager.go pkg/plugin/manager_test.go
|
||||
git commit -m "feat(plugin): add non-breaking plugin metadata introspection"
|
||||
```
|
||||
|
||||
### Task 8: Add `picoclaw plugin list` Command (Phase 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `cmd/picoclaw/internal/plugin/command.go`
|
||||
- Create: `cmd/picoclaw/internal/plugin/list.go`
|
||||
- Test: `cmd/picoclaw/internal/plugin/command_test.go`
|
||||
- Test: `cmd/picoclaw/internal/plugin/list_test.go`
|
||||
- Modify: `cmd/picoclaw/main.go`
|
||||
- Modify: `cmd/picoclaw/main_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestNewPluginCommand(t *testing.T) {}
|
||||
func TestNewListSubcommand(t *testing.T) {}
|
||||
func TestNewPicoclawCommand_IncludesPluginCommand(t *testing.T) {}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/plugin ./cmd/picoclaw -run 'TestNewPluginCommand|TestNewListSubcommand|TestNewPicoclawCommand' -v`
|
||||
Expected: FAIL with missing package/command registration.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
func NewPluginCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Inspect and validate plugins",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() },
|
||||
}
|
||||
cmd.AddCommand(newListCommand())
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
`newListCommand()` should load config, resolve selection, and print text or JSON list.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/plugin ./cmd/picoclaw -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/picoclaw/internal/plugin/command.go cmd/picoclaw/internal/plugin/list.go cmd/picoclaw/internal/plugin/command_test.go cmd/picoclaw/internal/plugin/list_test.go cmd/picoclaw/main.go cmd/picoclaw/main_test.go
|
||||
git commit -m "feat(cli): add plugin list command"
|
||||
```
|
||||
|
||||
### Task 9: Add `picoclaw plugin lint` Command (Phase 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `cmd/picoclaw/internal/plugin/lint.go`
|
||||
- Test: `cmd/picoclaw/internal/plugin/lint_test.go`
|
||||
- Modify: `cmd/picoclaw/internal/plugin/command.go`
|
||||
- Modify: `cmd/picoclaw/internal/pluginruntime/bootstrap.go`
|
||||
- Modify: `cmd/picoclaw/internal/pluginruntime/bootstrap_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
```go
|
||||
func TestPluginLint_ValidConfigExitZero(t *testing.T) {}
|
||||
func TestPluginLint_UnknownEnabledExitNonZero(t *testing.T) {}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/plugin ./cmd/picoclaw/internal/pluginruntime -run 'TestPluginLint_|TestResolveConfiguredPlugins_' -v`
|
||||
Expected: FAIL with missing lint command/validation path.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
```go
|
||||
func newLintCommand() *cobra.Command {
|
||||
var configPath string
|
||||
cmd := &cobra.Command{
|
||||
Use: "lint",
|
||||
Short: "Validate plugin configuration",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil { return err }
|
||||
_, _, err = pluginruntime.ResolveConfiguredPlugins(cfg)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configPath, "config", internal.GetConfigPath(), "Path to config.json")
|
||||
return cmd
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./cmd/picoclaw/internal/plugin ./cmd/picoclaw/internal/pluginruntime -v`
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cmd/picoclaw/internal/plugin/lint.go cmd/picoclaw/internal/plugin/lint_test.go cmd/picoclaw/internal/plugin/command.go cmd/picoclaw/internal/pluginruntime/bootstrap.go cmd/picoclaw/internal/pluginruntime/bootstrap_test.go
|
||||
git commit -m "feat(cli): add plugin lint command"
|
||||
```
|
||||
|
||||
### Task 10: Documentation and Final Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plugin-system-roadmap.md`
|
||||
- Modify: `docs/plans/2026-02-28-plugin-system-phase2-phase3-design.md`
|
||||
- Optional Modify: `README.md` (if command docs are surfaced there)
|
||||
|
||||
**Step 1: Write docs-oriented failing checks**
|
||||
|
||||
Add/update checklist assertions in docs PR description:
|
||||
- Phase 2 gates explicitly checked.
|
||||
- Phase 3 list/lint behavior and exit semantics documented.
|
||||
|
||||
**Step 2: Run verification commands**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./pkg/config ./pkg/plugin ./pkg/plugin/builtin ./pkg/agent ./cmd/picoclaw ./cmd/picoclaw/internal/plugin ./cmd/picoclaw/internal/pluginruntime -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 3: Minimal doc implementation**
|
||||
|
||||
Document:
|
||||
- JSON `plugins` config examples
|
||||
- deterministic precedence rules
|
||||
- `plugin list` usage (`--format json`)
|
||||
- `plugin lint --config` usage and non-zero behavior
|
||||
|
||||
**Step 4: Re-run verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./pkg/config ./pkg/plugin ./pkg/plugin/builtin ./pkg/agent ./cmd/picoclaw ./cmd/picoclaw/internal/plugin ./cmd/picoclaw/internal/pluginruntime -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/plugin-system-roadmap.md docs/plans/2026-02-28-plugin-system-phase2-phase3-design.md README.md
|
||||
git commit -m "docs(plugin): document phase2/phase3 behavior and cli usage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR Plan (maintainer-friendly)
|
||||
|
||||
1. PR-1: Tasks 1-2 (`config` + resolver).
|
||||
2. PR-2: Tasks 3-6 (catalog + bootstrap + startup diagnostics).
|
||||
3. PR-3: Tasks 7-9 (metadata + plugin list/lint CLI).
|
||||
4. PR-4: Task 10 docs-only cleanup if needed.
|
||||
|
||||
Each PR should include:
|
||||
- complete PR template fields
|
||||
- AI disclosure
|
||||
- test environment and command evidence
|
||||
- unresolved comments = 0 before merge
|
||||
|
||||
119
docs/plugin-system-roadmap.md
Normal file
119
docs/plugin-system-roadmap.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Plugin System Roadmap
|
||||
|
||||
This document defines how PicoClaw evolves from hook-based extension points to a fuller plugin system in low-risk phases.
|
||||
|
||||
## Current Status (Phase 0: Foundation)
|
||||
|
||||
Implemented in current hooks PR:
|
||||
|
||||
- Typed lifecycle hooks (`pkg/hooks`)
|
||||
- Priority-based handler ordering
|
||||
- Cancellation support for modifying hooks
|
||||
- Panic recovery and error isolation
|
||||
- Agent-loop integration via `agentLoop.SetHooks(...)`
|
||||
|
||||
Compatibility:
|
||||
|
||||
- If no hooks are registered, runtime behavior is unchanged.
|
||||
- No config migration is required.
|
||||
|
||||
## Non-Goals in Phase 0
|
||||
|
||||
- No dynamic runtime plugin loading
|
||||
- No remote plugin marketplace/distribution
|
||||
- No plugin sandboxing model
|
||||
- No stable external plugin ABI yet
|
||||
- No Go `.so` plugin loading as default direction
|
||||
|
||||
## Phase Plan
|
||||
|
||||
## Phase 1: Static Plugin Contract (Compile-time) — Implemented
|
||||
|
||||
Goal: define a minimal public plugin contract for Go modules.
|
||||
|
||||
Implemented:
|
||||
|
||||
- Add `pkg/plugin` with a small interface:
|
||||
- `Name() string`
|
||||
- `APIVersion() string`
|
||||
- `Register(*hooks.HookRegistry) error`
|
||||
- Register plugins at startup in code.
|
||||
- Add compatibility metadata (`plugin.APIVersion`) and registration-time checks.
|
||||
|
||||
Exit criteria (met):
|
||||
|
||||
- Example plugin module builds against the contract.
|
||||
- Startup validation logs loaded plugins and registration errors clearly.
|
||||
|
||||
## Phase 2: Config-driven Enable/Disable — Implemented
|
||||
|
||||
Goal: operational control without code changes.
|
||||
|
||||
Implemented:
|
||||
|
||||
- Add typed plugin selection config in `config.json`:
|
||||
- `plugins.default_enabled`
|
||||
- `plugins.enabled`
|
||||
- `plugins.disabled`
|
||||
- Add deterministic plugin resolution and conflict handling in the plugin manager.
|
||||
- Wire resolved plugins into startup for both `agent` and `gateway` entrypoints.
|
||||
|
||||
Exit criteria (met):
|
||||
|
||||
- Users can toggle built-in plugins without rebuilding.
|
||||
- Invalid plugin selection in config is surfaced during startup/lint flow.
|
||||
|
||||
## Phase 3: Metadata Introspection + CLI — Implemented
|
||||
|
||||
Goal: make plugin state inspectable and config validation straightforward.
|
||||
|
||||
Implemented:
|
||||
|
||||
- Add plugin metadata introspection in the plugin manager.
|
||||
- Add CLI inspection commands:
|
||||
- `picoclaw plugin list`
|
||||
- `picoclaw plugin list --format json`
|
||||
- Add CLI lint command:
|
||||
- `picoclaw plugin lint --config <path>`
|
||||
|
||||
Exit criteria (met):
|
||||
|
||||
- Operators can inspect plugin metadata in text/JSON outputs.
|
||||
- Operators can validate plugin config before startup.
|
||||
|
||||
## Future DX Work (Post-Phase 3)
|
||||
|
||||
- Provide `examples/plugins/*` reference implementations.
|
||||
- Publish plugin authoring guide (lifecycle map, best practices, safety constraints).
|
||||
- Add plugin-focused test harness patterns for hook behavior verification.
|
||||
|
||||
## Phase 4: Optional Dynamic Loading (Separate RFC)
|
||||
|
||||
Goal: support runtime-loaded plugins only if security and operability are acceptable.
|
||||
|
||||
Preferred direction:
|
||||
|
||||
- Runtime plugins run as subprocesses.
|
||||
- Host and plugin communicate via RPC/gRPC.
|
||||
- Host manages lifecycle (spawn/health/timeout/restart), not in-process dynamic loading.
|
||||
|
||||
Why this direction:
|
||||
|
||||
- Go native `.so` plugin loading has strict toolchain/ABI coupling with host binary.
|
||||
- Subprocess RPC model reduces coupling and improves fault isolation.
|
||||
- Process boundary provides a cleaner place for permissions and sandbox controls.
|
||||
|
||||
Preconditions:
|
||||
|
||||
- Threat model approved
|
||||
- Signature/trust model defined
|
||||
- Sandboxing and permission boundaries defined
|
||||
- Rollback and safe-disable behavior validated
|
||||
- Versioned RPC handshake and capability negotiation defined
|
||||
- Process supervision policy defined (timeouts, retries, crash loop backoff)
|
||||
|
||||
Until then, compile-time registration remains the recommended model.
|
||||
|
||||
## Maintainer Review Notes
|
||||
|
||||
The current hooks PR should be reviewed as Phase 0+1 only. It intentionally establishes extension points while avoiding high-risk runtime plugin mechanics.
|
||||
Loading…
Add table
Reference in a new issue