diff --git a/.crew/audit/prune.jsonl b/.crew/audit/prune.jsonl index 628ab43..294dccf 100644 --- a/.crew/audit/prune.jsonl +++ b/.crew/audit/prune.jsonl @@ -1,2 +1,4 @@ {"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-05-23T19:08:40.613Z"} {"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-05-23T19:55:51.755Z"} +{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-27T23:46:20.411Z"} +{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-27T23:48:27.473Z"} diff --git a/.crew/state/metrics/2026-06-27.jsonl b/.crew/state/metrics/2026-06-27.jsonl new file mode 100644 index 0000000..65f3e06 --- /dev/null +++ b/.crew/state/metrics/2026-06-27.jsonl @@ -0,0 +1,3 @@ +{"exportedAt":"2026-06-27T23:47:20.367Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]} +{"exportedAt":"2026-06-27T23:48:20.367Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]} +{"exportedAt":"2026-06-27T23:49:20.368Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]} diff --git a/.pi/flow.json b/.pi/flow.json new file mode 100644 index 0000000..4002a4b --- /dev/null +++ b/.pi/flow.json @@ -0,0 +1,3 @@ +{ + "history": [] +} diff --git a/README.md b/README.md index dd83a4b..54c1b82 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ harness-sync goes out of its way to avoid. | **crush** | `~/.config/crush/crush.json` (merged) | `mcp` (`type: stdio`/`http`/`sse`) | `providers` map + `default_model` | `~/.config/crush/skills/` | | **kilo** | `~/.config/kilo/kilo.json` (merged) | `mcp` (`type: local`/`remote`) | `provider` map + `model` + `small_model` | `~/.kilo/skills/` | | **opencode** | `~/.config/opencode/opencode.jsonc` (merged) | `mcp` (`type: local`/`remote`) | `provider` map + `model` | `~/.config/opencode/skills/` | +| **pi** | `~/.pi/agent/settings.json` (merged) + `~/.pi/agent/models.json` | — | `defaultProvider` + `defaultModel` + `models.json` provider | `~/.pi/agent/skills/` | | **goose** | `~/.config/goose/config.yaml` (merged) + `custom_providers/.json` | `extensions` map (`type: stdio`) | `GOOSE_PROVIDER` + `GOOSE_MODEL` | `~/.agents/skills/` | | **cagent** | `~/.config/cagent/default.yaml` (starter) | `mcps` | `providers` map + per-agent `model:` | — | | **zed** | `~/.config/zed/settings.json` (merged) | `context_servers` | `language_models.openai.{api_url, available_models}` + `agent.default_model` | — | @@ -156,6 +157,7 @@ Skills paths are grounded in each harness's official documentation (kilo.ai, ope | **crush** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ | | **kilo** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ᶠ | | **opencode** | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ᶠ | ✓ | +| **pi** | — | ✓ | ✓ | — | ✓ | — | ✓ᶠ | ✓ᶠ | | **goose** | — | ✓ | ✓ | ✓ | ✓ | — | ✓ᶠ | ✓ᶠ | | **cagent** | — | ✓ | ✓ | ✓ | — | — | ✓ᶠ | ✓ | | **zed** | — | ✓ | ✓ | — | — | — | ✓ᶠ | ✓ᶠ | diff --git a/cmd/harness-sync/main.go b/cmd/harness-sync/main.go index c6501f6..a4b3e28 100644 --- a/cmd/harness-sync/main.go +++ b/cmd/harness-sync/main.go @@ -12,6 +12,7 @@ import ( "github.com/lukaszraczylo/harness-sync/internal/adapters/goose" "github.com/lukaszraczylo/harness-sync/internal/adapters/kilo" "github.com/lukaszraczylo/harness-sync/internal/adapters/opencode" + "github.com/lukaszraczylo/harness-sync/internal/adapters/pi" "github.com/lukaszraczylo/harness-sync/internal/adapters/zed" "github.com/lukaszraczylo/harness-sync/internal/cli" "github.com/lukaszraczylo/oss-telemetry" @@ -28,6 +29,7 @@ func main() { reg.Register(crush.New()) reg.Register(kilo.New()) reg.Register(opencode.New()) + reg.Register(pi.New()) reg.Register(goose.New()) reg.Register(cagent.New()) reg.Register(zed.New()) diff --git a/internal/adapters/pi/import.go b/internal/adapters/pi/import.go new file mode 100644 index 0000000..0ef6a70 --- /dev/null +++ b/internal/adapters/pi/import.go @@ -0,0 +1,21 @@ +package pi + +import ( + "os" + "path/filepath" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" +) + +func importFrom(home string) (*adapter.ImportResult, error) { + base := filepath.Join(home, ".pi", "agent") + res := &adapter.ImportResult{} + + if body, err := os.ReadFile(filepath.Join(base, "AGENTS.md")); err == nil { + res.Instructions = string(body) + } else if !os.IsNotExist(err) { + return nil, err + } + + return res, nil +} diff --git a/internal/adapters/pi/pi.go b/internal/adapters/pi/pi.go new file mode 100644 index 0000000..87466f3 --- /dev/null +++ b/internal/adapters/pi/pi.go @@ -0,0 +1,148 @@ +// Package pi is the Pi coding agent harness adapter. +package pi + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +const name = "pi" + +// Adapter implements adapter.Adapter for the Pi coding agent harness. +type Adapter struct { + *adapter.Base +} + +// WithHome overrides the home directory used to resolve target paths. +func WithHome(h string) adapter.BaseOption { return func(b *adapter.Base) { b.Home = h } } + +// New returns a new Adapter with the given options applied. +func New(opts ...adapter.BaseOption) *Adapter { + return &Adapter{Base: adapter.NewBase(opts...)} +} + +// Name returns the harness identifier. +func (a *Adapter) Name() string { return name } + +// Capabilities declares what pi harness-sync manages. +func (a *Adapter) Capabilities() adapter.HarnessCapabilities { + return adapter.HarnessCapabilities{ + ManagesProviders: true, + ManagesModels: true, + ManagesMCP: false, + ManagesSkills: true, + ManagesAgents: false, + ManagesRules: true, + ManagesInstructions: true, + HasBuiltInSub: false, + } +} + +// Detect returns true when ~/.pi/agent exists. +func (a *Adapter) Detect() bool { + _, err := os.Stat(filepath.Join(a.Home, ".pi", "agent")) + return err == nil +} + +// Render produces the FileSet that applies the canonical bundle to Pi. +func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { + fs := adapter.NewFileSet() + base := filepath.Join(a.Home, ".pi", "agent") + + fs.Add(adapter.File{ + Dest: filepath.Join(base, "skills"), + Kind: adapter.SymlinkDir, + SymlinkTarget: filepath.Join(b.Root, "skills"), + }) + + if instr := b.InstructionTextWithRules(name); instr != "" { + fs.Add(adapter.File{ + Dest: filepath.Join(base, "AGENTS.md"), + Kind: adapter.RenderedFile, + Content: []byte(instr), + }) + } + + cfgPath := filepath.Join(base, "settings.json") + existing, err := common.ReadExistingFile(cfgPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", cfgPath, err) + } + + overlay := map[string]any{} + if b.Profile.Gateway.DefaultModel != "" { + overlay["defaultProvider"] = common.GatewayProviderKey(b.Profile.Gateway.URL) + overlay["defaultModel"] = b.Profile.Gateway.DefaultModel + } + if len(overlay) > 0 { + merged, err := common.MergeJSONKeys(existing, overlay) + if err != nil { + return nil, err + } + fs.Add(adapter.File{ + Dest: cfgPath, + Kind: adapter.RenderedFile, + Content: merged, + }) + } + + if modelsContent, err := piModelsJSON(b); err != nil { + return nil, err + } else if len(modelsContent) > 0 { + fs.Add(adapter.File{ + Dest: filepath.Join(base, "models.json"), + Kind: adapter.RenderedFile, + Content: modelsContent, + }) + } + + return fs, nil +} + +func piModelsJSON(b *canonical.Bundle) ([]byte, error) { + if b.Profile.Gateway.URL == "" { + return nil, nil + } + + modelEntries := make([]map[string]any, 0, len(b.Profile.Models)) + for _, m := range b.Profile.Models { + entry := map[string]any{"id": m.ID} + if m.Alias != "" { + entry["name"] = m.Alias + } + modelEntries = append(modelEntries, entry) + } + if len(modelEntries) == 0 && b.Profile.Gateway.DefaultModel != "" { + modelEntries = append(modelEntries, map[string]any{"id": b.Profile.Gateway.DefaultModel}) + } + + provider := map[string]any{ + "baseUrl": b.Profile.Gateway.URL, + "api": "openai-completions", + "apiKey": b.Profile.Gateway.Token, + } + if len(modelEntries) > 0 { + provider["models"] = modelEntries + } + + body, err := json.MarshalIndent(map[string]any{ + "providers": map[string]any{ + common.GatewayProviderKey(b.Profile.Gateway.URL): provider, + }, + }, "", " ") + if err != nil { + return nil, err + } + return append(body, '\n'), nil +} + +// Import reads Pi config from home and returns a canonical ImportResult. +func (a *Adapter) Import(home string) (*adapter.ImportResult, error) { + return importFrom(home) +} diff --git a/internal/adapters/pi/pi_test.go b/internal/adapters/pi/pi_test.go new file mode 100644 index 0000000..b9f7124 --- /dev/null +++ b/internal/adapters/pi/pi_test.go @@ -0,0 +1,114 @@ +package pi + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter" + "github.com/lukaszraczylo/harness-sync/internal/canonical" +) + +func TestPiRender(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{ + Root: "/canon", + Profile: canonical.Profile{ + Gateway: canonical.Gateway{URL: "https://gw", Token: "dummy", DefaultModel: "sonnet"}, + Models: []canonical.Model{{ID: "claude-sonnet-4-6", Alias: "Sonnet"}}, + }, + Instructions: canonical.Instructions{Global: "# global"}, + } + fs, err := ad.Render(b) + require.NoError(t, err) + + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + skillsDest := filepath.Join(home, ".pi", "agent", "skills") + assert.Equal(t, adapter.SymlinkDir, seen[skillsDest].Kind) + assert.Equal(t, "/canon/skills", seen[skillsDest].SymlinkTarget) + + agentsDest := filepath.Join(home, ".pi", "agent", "AGENTS.md") + assert.Equal(t, adapter.RenderedFile, seen[agentsDest].Kind) + assert.Contains(t, string(seen[agentsDest].Content), "# global") + + settingsDest := filepath.Join(home, ".pi", "agent", "settings.json") + var settings map[string]any + require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &settings)) + assert.Equal(t, "hs-gw", settings["defaultProvider"]) + assert.Equal(t, "sonnet", settings["defaultModel"]) + + modelsDest := filepath.Join(home, ".pi", "agent", "models.json") + var models map[string]any + require.NoError(t, json.Unmarshal(seen[modelsDest].Content, &models)) + providers := models["providers"].(map[string]any) + gw := providers["hs-gw"].(map[string]any) + assert.Equal(t, "https://gw", gw["baseUrl"]) + assert.Equal(t, "openai-completions", gw["api"]) + assert.Equal(t, "dummy", gw["apiKey"]) + modelList := gw["models"].([]any) + first := modelList[0].(map[string]any) + assert.Equal(t, "claude-sonnet-4-6", first["id"]) + assert.Equal(t, "Sonnet", first["name"]) +} + +func TestPiRenderMergesExistingSettings(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".pi", "agent") + require.NoError(t, os.MkdirAll(base, 0o750)) + + existing := map[string]any{ + "theme": "light", + "defaultThinkingLevel": "high", + } + body, _ := json.Marshal(existing) + require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600)) + + ad := New(WithHome(home)) + b := &canonical.Bundle{Profile: canonical.Profile{Gateway: canonical.Gateway{URL: "https://gw", DefaultModel: "sonnet"}}} + fs, err := ad.Render(b) + require.NoError(t, err) + + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(seen[filepath.Join(base, "settings.json")].Content, &parsed)) + assert.Equal(t, "light", parsed["theme"]) + assert.Equal(t, "high", parsed["defaultThinkingLevel"]) + assert.Equal(t, "hs-gw", parsed["defaultProvider"]) + assert.Equal(t, "sonnet", parsed["defaultModel"]) +} + +func TestPiRenderWithoutGatewaySkipsProviderFiles(t *testing.T) { + home := t.TempDir() + ad := New(WithHome(home)) + b := &canonical.Bundle{Root: "/canon"} + fs, err := ad.Render(b) + require.NoError(t, err) + + seen := map[string]adapter.File{} + fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) + + assert.Contains(t, seen, filepath.Join(home, ".pi", "agent", "skills")) + assert.NotContains(t, seen, filepath.Join(home, ".pi", "agent", "settings.json")) + assert.NotContains(t, seen, filepath.Join(home, ".pi", "agent", "models.json")) +} + +func TestPiImport(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".pi", "agent") + require.NoError(t, os.MkdirAll(base, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(base, "AGENTS.md"), []byte("# global"), 0o600)) + + ad := New(WithHome(home)) + res, err := ad.Import(home) + require.NoError(t, err) + assert.Equal(t, "# global", res.Instructions) +} diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 85238f6..0e91b2e 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -90,7 +90,7 @@ func TestDetectListsAllAdapters(t *testing.T) { // None should be detected in an empty HOME s := string(out) - for _, name := range []string{"claude-code", "crush", "kilo", "opencode", "goose", "cagent", "zed"} { + for _, name := range []string{"claude-code", "crush", "kilo", "opencode", "pi", "goose", "cagent", "zed"} { assert.Contains(t, s, name) } assert.Contains(t, s, "not detected")