From a0dc9c32e7f1be08ef5145b28742aef03ceb9aa4 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 06:13:41 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20migrate=20core/agent=20to=20Core=20?= =?UTF-8?q?primitives=20=E2=80=94=20reference=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: go-io/go-log → core.Fs{}, core.E(), core.Error/Info/Warn Phase 2: strings/fmt → core.Contains, core.Sprintf, core.Split etc Phase 3: embed.FS → core.Mount/core.Embed, core.Extract Phase 4: cmd/main.go → core.Command(), c.Cli().Run(), no cli package All packages migrated: - pkg/lib (Codex): core.Mount, core.Extract, Result returns, AX comments - pkg/setup (Codex): core.Fs, core.E, fixed missing lib helpers - pkg/brain (Codex): Core primitives, AX comments - pkg/monitor (Codex): Core string/logging primitives - pkg/agentic (Codex): 20 files, Core primitives throughout - cmd/main.go: pure Core CLI, no fmt/log/filepath/strings/cli Remaining stdlib: path/filepath (Core doesn't wrap OS paths), fmt.Sscanf/strings.Map (no Core equivalent). Co-Authored-By: Virgil --- .gitignore | 1 - cmd/main.go | 153 +++++++++++++++---------------- go.mod | 18 ++-- go.sum | 16 ++++ pkg/agentic/auto_pr.go | 27 +++--- pkg/agentic/dispatch.go | 26 +++--- pkg/agentic/epic.go | 46 +++++----- pkg/agentic/events.go | 7 +- pkg/agentic/ingest.go | 18 ++-- pkg/agentic/mirror.go | 45 +++++----- pkg/agentic/paths.go | 42 ++++++--- pkg/agentic/plan.go | 36 ++++++-- pkg/agentic/pr.go | 55 +++++++----- pkg/agentic/prep.go | 161 ++++++++++++++++++--------------- pkg/agentic/prep_test.go | 4 +- pkg/agentic/queue.go | 54 ++++++----- pkg/agentic/queue_test.go | 4 +- pkg/agentic/remote.go | 38 ++++---- pkg/agentic/remote_client.go | 10 +-- pkg/agentic/remote_status.go | 4 + pkg/agentic/resume.go | 36 ++++---- pkg/agentic/review_queue.go | 74 +++++++-------- pkg/agentic/scan.go | 21 +++-- pkg/agentic/status.go | 67 +++++++------- pkg/agentic/status_test.go | 4 +- pkg/agentic/verify.go | 38 ++++---- pkg/agentic/watch.go | 17 ++-- pkg/brain/brain.go | 38 ++++++-- pkg/brain/bridge_test.go | 7 +- pkg/brain/direct.go | 112 +++++++++++++++-------- pkg/brain/messaging.go | 39 ++++++-- pkg/brain/provider.go | 4 +- pkg/lib/lib.go | 23 +++-- pkg/monitor/harvest.go | 71 ++++++++------- pkg/monitor/monitor.go | 112 +++++++++++++++++------ pkg/monitor/monitor_test.go | 37 +++++--- pkg/monitor/sync.go | 25 +++--- pkg/setup/config.go | 153 +++++++++++++++++++------------ pkg/setup/detect.go | 36 +++++++- pkg/setup/setup.go | 170 ++++++++++++++++++++++++----------- pkg/setup/setup_test.go | 59 ++++++++++++ 41 files changed, 1198 insertions(+), 710 deletions(-) create mode 100644 pkg/setup/setup_test.go diff --git a/.gitignore b/.gitignore index dae4197..cdc6f76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ .vscode/ *.log .core/ -var/ diff --git a/cmd/main.go b/cmd/main.go index bcb5b1a..cd2b211 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,40 +1,35 @@ package main import ( - "fmt" - "log" + "context" "os" - "path/filepath" + "os/signal" + "syscall" + + "dappco.re/go/core" + "dappco.re/go/core/process" "dappco.re/go/agent/pkg/agentic" "dappco.re/go/agent/pkg/brain" "dappco.re/go/agent/pkg/monitor" - "forge.lthn.ai/core/cli/pkg/cli" - "dappco.re/go/core/process" - "dappco.re/go/core" "forge.lthn.ai/core/mcp/pkg/mcp" ) func main() { - if err := cli.Init(cli.Options{ - AppName: "core-agent", - Version: "0.2.0", - }); err != nil { - log.Fatal(err) - } + c := core.New(core.Options{ + {Key: "name", Value: "core-agent"}, + }) + c.App().Version = "0.2.0" - // Shared setup for both mcp and serve commands + // Shared setup — creates MCP service with all subsystems wired initServices := func() (*mcp.Service, *monitor.Subsystem, error) { - c := core.New(core.Options{ - {Key: "name", Value: "core-agent"}, - }) procFactory := process.NewService(process.Options{}) procResult, err := procFactory(c) if err != nil { - return nil, nil, cli.Wrap(err, "init process service") + return nil, nil, core.E("main", "init process service", err) } if procSvc, ok := procResult.(*process.Service); ok { - process.SetDefault(procSvc) + _ = process.SetDefault(procSvc) } mon := monitor.New() @@ -45,82 +40,90 @@ func main() { Subsystems: []mcp.Subsystem{brain.NewDirect(), prep, mon}, }) if err != nil { - return nil, nil, cli.Wrap(err, "create MCP service") + return nil, nil, core.E("main", "create MCP service", err) } - // Wire channel notifications — monitor pushes events into MCP sessions mon.SetNotifier(mcpSvc) - return mcpSvc, mon, nil } + // Signal-aware context for clean shutdown + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + // mcp — stdio transport (Claude Code integration) - mcpCmd := cli.NewCommand("mcp", "Start the MCP server on stdio", "", func(cmd *cli.Command, args []string) error { - mcpSvc, mon, err := initServices() - if err != nil { - return err - } - mon.Start(cmd.Context()) - return mcpSvc.Run(cmd.Context()) + c.Command("mcp", core.Command{ + Description: "Start the MCP server on stdio", + Action: func(opts core.Options) core.Result { + mcpSvc, mon, err := initServices() + if err != nil { + return core.Result{err, false} + } + mon.Start(ctx) + if err := mcpSvc.Run(ctx); err != nil { + return core.Result{err, false} + } + return core.Result{OK: true} + }, }) // serve — persistent HTTP daemon (Charon, CI, cross-agent) - serveCmd := cli.NewCommand("serve", "Start as a persistent HTTP daemon", "", func(cmd *cli.Command, args []string) error { - mcpSvc, mon, err := initServices() - if err != nil { - return err - } + c.Command("serve", core.Command{ + Description: "Start as a persistent HTTP daemon", + Action: func(opts core.Options) core.Result { + mcpSvc, mon, err := initServices() + if err != nil { + return core.Result{err, false} + } - // Determine address - addr := os.Getenv("MCP_HTTP_ADDR") - if addr == "" { - addr = "0.0.0.0:9101" - } + addr := os.Getenv("MCP_HTTP_ADDR") + if addr == "" { + addr = "0.0.0.0:9101" + } - // Determine health address - healthAddr := os.Getenv("HEALTH_ADDR") - if healthAddr == "" { - healthAddr = "0.0.0.0:9102" - } + healthAddr := os.Getenv("HEALTH_ADDR") + if healthAddr == "" { + healthAddr = "0.0.0.0:9102" + } - // Set up daemon with PID file, health check, and registry - home, _ := os.UserHomeDir() - pidFile := filepath.Join(home, ".core", "core-agent.pid") + home, _ := os.UserHomeDir() + pidFile := core.Concat(home, "/.core/core-agent.pid") - daemon := process.NewDaemon(process.DaemonOptions{ - PIDFile: pidFile, - HealthAddr: healthAddr, - Registry: process.DefaultRegistry(), - RegistryEntry: process.DaemonEntry{ - Code: "core", - Daemon: "agent", - Project: "core-agent", - Binary: "core-agent", - }, - }) + daemon := process.NewDaemon(process.DaemonOptions{ + PIDFile: pidFile, + HealthAddr: healthAddr, + Registry: process.DefaultRegistry(), + RegistryEntry: process.DaemonEntry{ + Code: "core", + Daemon: "agent", + Project: "core-agent", + Binary: "core-agent", + }, + }) - if err := daemon.Start(); err != nil { - return cli.Wrap(err, "daemon start") - } + if err := daemon.Start(); err != nil { + return core.Result{core.E("main", "daemon start", err), false} + } - // Start monitor - mon.Start(cmd.Context()) + mon.Start(ctx) + daemon.SetReady(true) + core.Print(os.Stderr, "core-agent serving on %s (health: %s, pid: %s)", addr, healthAddr, pidFile) - // Mark ready - daemon.SetReady(true) - fmt.Fprintf(os.Stderr, "core-agent serving on %s (health: %s, pid: %s)\n", addr, healthAddr, pidFile) + os.Setenv("MCP_HTTP_ADDR", addr) - // Set env so mcp.Run picks HTTP transport - os.Setenv("MCP_HTTP_ADDR", addr) - - // Run MCP server (blocks until context cancelled) - return mcpSvc.Run(cmd.Context()) + if err := mcpSvc.Run(ctx); err != nil { + return core.Result{err, false} + } + return core.Result{OK: true} + }, }) - cli.RootCmd().AddCommand(mcpCmd) - cli.RootCmd().AddCommand(serveCmd) - - if err := cli.Execute(); err != nil { - log.Fatal(err) + // Run CLI — resolves os.Args to command path + r := c.Cli().Run() + if !r.OK { + if err, ok := r.Value.(error); ok { + core.Error(err.Error()) + } + os.Exit(1) } } diff --git a/go.mod b/go.mod index 6775854..07694de 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.26.0 require ( dappco.re/go/core v0.5.0 + dappco.re/go/core/api v0.2.0 dappco.re/go/core/process v0.3.0 dappco.re/go/core/ws v0.3.0 - forge.lthn.ai/core/api v0.1.5 + forge.lthn.ai/core/api v0.1.6 forge.lthn.ai/core/cli v0.3.7 - forge.lthn.ai/core/mcp v0.4.0 + forge.lthn.ai/core/mcp v0.4.4 github.com/gin-gonic/gin v1.12.0 github.com/gorilla/websocket v1.5.3 github.com/modelcontextprotocol/go-sdk v1.4.1 @@ -17,8 +18,11 @@ require ( ) require ( + dappco.re/go/core/i18n v0.2.0 dappco.re/go/core/io v0.2.0 // indirect dappco.re/go/core/log v0.1.0 // indirect + dappco.re/go/core/scm v0.4.0 + dappco.re/go/core/store v0.2.0 forge.lthn.ai/core/go v0.3.3 // indirect forge.lthn.ai/core/go-ai v0.1.12 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect @@ -27,7 +31,7 @@ require ( forge.lthn.ai/core/go-log v0.0.4 // indirect forge.lthn.ai/core/go-process v0.2.9 // indirect forge.lthn.ai/core/go-rag v0.1.11 // indirect - forge.lthn.ai/core/go-webview v0.1.6 // indirect + forge.lthn.ai/core/go-webview v0.1.7 // indirect forge.lthn.ai/core/go-ws v0.2.5 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect @@ -36,7 +40,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect @@ -109,7 +113,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/ollama/ollama v0.18.1 // indirect + github.com/ollama/ollama v0.18.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/qdrant/go-client v1.17.1 // indirect @@ -150,7 +154,7 @@ require ( golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect - google.golang.org/grpc v1.79.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 44b3d75..d7c347c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0= +dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= @@ -10,6 +12,8 @@ dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ= dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic= forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o= forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII= +forge.lthn.ai/core/api v0.1.6 h1:DwJ9s/B5yEAVx497oB6Ja9wlj4qZ6HLvsyZOcN7RivA= +forge.lthn.ai/core/api v0.1.6/go.mod h1:l7EeqKgu3New2kAeg65We8KJoVlzkO0P3bK7tQNniXg= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= @@ -30,10 +34,14 @@ forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8 forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8= forge.lthn.ai/core/go-webview v0.1.6 h1:szXQxRJf2bOZJKh3v1P01B1Vf9mgXaBCXzh0EZu9aoc= forge.lthn.ai/core/go-webview v0.1.6/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek= +forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc= +forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek= forge.lthn.ai/core/go-ws v0.2.5 h1:ZIV7Yrv01R/xpJUogA5vrfP9yB9li1w7EV3eZFMt8h0= forge.lthn.ai/core/go-ws v0.2.5/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4= forge.lthn.ai/core/mcp v0.4.0 h1:t4HMTI6CpoGB/VmE1aTklSEM8EI4Z/uKWyjGHxa1f4M= forge.lthn.ai/core/mcp v0.4.0/go.mod h1:eU35WT/8Mc0oJDVWdKaXEtNp27+Hc8KvnTKPf4DAqXE= +forge.lthn.ai/core/mcp v0.4.4 h1:VTCOA1Dj/L7S8JCRg9BfYw7KfowW/Vvrp39bxc0dYyw= +forge.lthn.ai/core/mcp v0.4.4/go.mod h1:eU35WT/8Mc0oJDVWdKaXEtNp27+Hc8KvnTKPf4DAqXE= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= @@ -63,6 +71,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -247,6 +257,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ollama/ollama v0.18.1 h1:7K6anW64C2keASpToYfuOa00LuP8aCmofLKcT2c1mlY= github.com/ollama/ollama v0.18.1/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw= +github.com/ollama/ollama v0.18.2 h1:RsOY8oZ6TufRiPgsSlKJp4/V/X+oBREscUlEHZfd554= +github.com/ollama/ollama v0.18.2/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -396,8 +408,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/agentic/auto_pr.go b/pkg/agentic/auto_pr.go index 620dedd..6442d90 100644 --- a/pkg/agentic/auto_pr.go +++ b/pkg/agentic/auto_pr.go @@ -4,11 +4,10 @@ package agentic import ( "context" - "fmt" "os/exec" - "path/filepath" - "strings" "time" + + core "dappco.re/go/core" ) // autoCreatePR pushes the agent's branch and creates a PR on Forge @@ -19,7 +18,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { return } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") // Detect default branch for this repo base := DefaultBranch(srcDir) @@ -28,12 +27,12 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { diffCmd := exec.Command("git", "log", "--oneline", "origin/"+base+"..HEAD") diffCmd.Dir = srcDir out, err := diffCmd.Output() - if err != nil || len(strings.TrimSpace(string(out))) == 0 { + if err != nil || len(core.Trim(string(out))) == 0 { // No commits — nothing to PR return } - commitCount := len(strings.Split(strings.TrimSpace(string(out)), "\n")) + commitCount := len(core.Split(core.Trim(string(out)), "\n")) // Get the repo's forge remote URL to extract org/repo org := st.Org @@ -42,20 +41,20 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { } // Push the branch to forge - forgeRemote := fmt.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) + forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) pushCmd := exec.Command("git", "push", forgeRemote, st.Branch) pushCmd.Dir = srcDir if pushErr := pushCmd.Run(); pushErr != nil { // Push failed — update status with error but don't block if st2, err := readStatus(wsDir); err == nil { - st2.Question = fmt.Sprintf("PR push failed: %v", pushErr) + st2.Question = core.Sprintf("PR push failed: %v", pushErr) writeStatus(wsDir, st2) } return } // Create PR via Forge API - title := fmt.Sprintf("[agent/%s] %s", st.Agent, truncate(st.Task, 60)) + title := core.Sprintf("[agent/%s] %s", st.Agent, truncate(st.Task, 60)) body := s.buildAutoPRBody(st, commitCount) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -64,7 +63,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { prURL, _, err := s.forgeCreatePR(ctx, org, st.Repo, st.Branch, base, title, body) if err != nil { if st2, err := readStatus(wsDir); err == nil { - st2.Question = fmt.Sprintf("PR creation failed: %v", err) + st2.Question = core.Sprintf("PR creation failed: %v", err) writeStatus(wsDir, st2) } return @@ -78,13 +77,13 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { } func (s *PrepSubsystem) buildAutoPRBody(st *WorkspaceStatus, commits int) string { - var b strings.Builder + b := core.NewBuilder() b.WriteString("## Task\n\n") b.WriteString(st.Task) b.WriteString("\n\n") - b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent)) - b.WriteString(fmt.Sprintf("**Commits:** %d\n", commits)) - b.WriteString(fmt.Sprintf("**Branch:** `%s`\n", st.Branch)) + b.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent)) + b.WriteString(core.Sprintf("**Commits:** %d\n", commits)) + b.WriteString(core.Sprintf("**Branch:** `%s`\n", st.Branch)) b.WriteString("\n---\n") b.WriteString("Auto-created by core-agent dispatch system.\n") b.WriteString("Co-Authored-By: Virgil \n") diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 1eb8d3b..ef98bc5 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -4,10 +4,8 @@ package agentic import ( "context" - "fmt" "os" "path/filepath" - "strings" "syscall" "time" @@ -17,6 +15,8 @@ import ( ) // DispatchInput is the input for agentic_dispatch. +// +// input := agentic.DispatchInput{Repo: "go-io", Task: "Fix the failing tests", Agent: "codex"} type DispatchInput struct { Repo string `json:"repo"` // Target repo (e.g. "go-io") Org string `json:"org,omitempty"` // Forge org (default "core") @@ -31,6 +31,8 @@ type DispatchInput struct { } // DispatchOutput is the output for agentic_dispatch. +// +// out := agentic.DispatchOutput{Success: true, Agent: "codex", Repo: "go-io", WorkspaceDir: ".core/workspace/go-io-123"} type DispatchOutput struct { Success bool `json:"success"` Agent string `json:"agent"` @@ -51,7 +53,7 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) { // agentCommand returns the command and args for a given agent type. // Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku". func agentCommand(agent, prompt string) (string, []string, error) { - parts := strings.SplitN(agent, ":", 2) + parts := core.SplitN(agent, ":", 2) base := parts[0] model := "" if len(parts) > 1 { @@ -100,7 +102,7 @@ func agentCommand(agent, prompt string) (string, []string, error) { return "coderabbit", args, nil case "local": home, _ := os.UserHomeDir() - script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh") + script := core.JoinPath(home, "Code", "core", "agent", "scripts", "local-agent.sh") return "bash", []string{script, prompt}, nil default: return "", nil, core.E("agentCommand", "unknown agent: "+agent, nil) @@ -119,11 +121,11 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st return 0, "", err } - outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent)) + outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", agent)) // Clean up stale BLOCKED.md from previous runs so it doesn't // prevent this run from completing - os.Remove(filepath.Join(srcDir, "BLOCKED.md")) + os.Remove(core.JoinPath(srcDir, "BLOCKED.md")) proc, err := process.StartWithOptions(context.Background(), process.RunOptions{ Command: command, @@ -170,14 +172,14 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st procStatus := proc.Info().Status question := "" - blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md") - if r := fs.Read(blockedPath); r.OK && strings.TrimSpace(r.Value.(string)) != "" { + blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md") + if r := fs.Read(blockedPath); r.OK && core.Trim(r.Value.(string)) != "" { finalStatus = "blocked" - question = strings.TrimSpace(r.Value.(string)) + question = core.Trim(r.Value.(string)) } else if exitCode != 0 || procStatus == "failed" || procStatus == "killed" { finalStatus = "failed" if exitCode != 0 { - question = fmt.Sprintf("Agent exited with code %d", exitCode) + question = core.Sprintf("Agent exited with code %d", exitCode) } } @@ -246,14 +248,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, } wsDir := prepOut.WorkspaceDir - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") // The prompt is just: read PROMPT.md and do the work prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the current directory. Work in this directory." if input.DryRun { // Read PROMPT.md for the dry run output - r := fs.Read(filepath.Join(srcDir, "PROMPT.md")) + r := fs.Read(core.JoinPath(srcDir, "PROMPT.md")) promptContent := "" if r.OK { promptContent = r.Value.(string) diff --git a/pkg/agentic/epic.go b/pkg/agentic/epic.go index b5c4957..6295d21 100644 --- a/pkg/agentic/epic.go +++ b/pkg/agentic/epic.go @@ -6,9 +6,7 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" - "strings" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -17,28 +15,34 @@ import ( // --- agentic_create_epic --- // EpicInput is the input for agentic_create_epic. +// +// input := agentic.EpicInput{Repo: "go-scm", Title: "Port agentic plans", Tasks: []string{"Read PHP flow", "Implement Go MCP tools"}} type EpicInput struct { Repo string `json:"repo"` // Target repo (e.g. "go-scm") - Org string `json:"org,omitempty"` // Forge org (default "core") - Title string `json:"title"` // Epic title - Body string `json:"body,omitempty"` // Epic description (above checklist) - Tasks []string `json:"tasks"` // Sub-task titles (become child issues) - Labels []string `json:"labels,omitempty"` // Labels for epic + children (e.g. ["agentic"]) - Dispatch bool `json:"dispatch,omitempty"` // Auto-dispatch agents to each child - Agent string `json:"agent,omitempty"` // Agent type for dispatch (default "claude") - Template string `json:"template,omitempty"` // Prompt template for dispatch (default "coding") + Org string `json:"org,omitempty"` // Forge org (default "core") + Title string `json:"title"` // Epic title + Body string `json:"body,omitempty"` // Epic description (above checklist) + Tasks []string `json:"tasks"` // Sub-task titles (become child issues) + Labels []string `json:"labels,omitempty"` // Labels for epic + children (e.g. ["agentic"]) + Dispatch bool `json:"dispatch,omitempty"` // Auto-dispatch agents to each child + Agent string `json:"agent,omitempty"` // Agent type for dispatch (default "claude") + Template string `json:"template,omitempty"` // Prompt template for dispatch (default "coding") } // EpicOutput is the output for agentic_create_epic. +// +// out := agentic.EpicOutput{Success: true, EpicNumber: 42, EpicURL: "https://forge.example/core/go-scm/issues/42"} type EpicOutput struct { - Success bool `json:"success"` - EpicNumber int `json:"epic_number"` - EpicURL string `json:"epic_url"` - Children []ChildRef `json:"children"` - Dispatched int `json:"dispatched,omitempty"` + Success bool `json:"success"` + EpicNumber int `json:"epic_number"` + EpicURL string `json:"epic_url"` + Children []ChildRef `json:"children"` + Dispatched int `json:"dispatched,omitempty"` } // ChildRef references a child issue. +// +// child := agentic.ChildRef{Number: 43, Title: "Implement plan list", URL: "https://forge.example/core/go-scm/issues/43"} type ChildRef struct { Number int `json:"number"` Title string `json:"title"` @@ -99,14 +103,14 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest } // Step 2: Build epic body with checklist - var body strings.Builder + body := core.NewBuilder() if input.Body != "" { body.WriteString(input.Body) body.WriteString("\n\n") } body.WriteString("## Tasks\n\n") for _, child := range children { - body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) + body.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title)) } // Step 3: Create epic issue @@ -156,7 +160,7 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body } data, _ := json.Marshal(payload) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -168,7 +172,7 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body defer resp.Body.Close() if resp.StatusCode != 201 { - return ChildRef{}, core.E("createIssue", fmt.Sprintf("create issue returned %d", resp.StatusCode), nil) + return ChildRef{}, core.E("createIssue", core.Sprintf("create issue returned %d", resp.StatusCode), nil) } var result struct { @@ -191,7 +195,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n } // Fetch existing labels - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -250,7 +254,7 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string) "color": colour, }) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) diff --git a/pkg/agentic/events.go b/pkg/agentic/events.go index ac66c90..4b0a66f 100644 --- a/pkg/agentic/events.go +++ b/pkg/agentic/events.go @@ -5,12 +5,15 @@ package agentic import ( "encoding/json" "os" - "path/filepath" "time" + + core "dappco.re/go/core" ) // CompletionEvent is emitted when a dispatched agent finishes. // Written to ~/.core/workspace/events.jsonl as append-only log. +// +// event := agentic.CompletionEvent{Type: "agent_completed", Agent: "codex", Workspace: "go-io-123", Status: "completed"} type CompletionEvent struct { Type string `json:"type"` Agent string `json:"agent"` @@ -23,7 +26,7 @@ type CompletionEvent struct { // The plugin's hook watches this file to notify the orchestrating agent. // Status should be the actual terminal state: completed, failed, or blocked. func emitCompletionEvent(agent, workspace, status string) { - eventsFile := filepath.Join(WorkspaceRoot(), "events.jsonl") + eventsFile := core.JoinPath(WorkspaceRoot(), "events.jsonl") event := CompletionEvent{ Type: "agent_completed", diff --git a/pkg/agentic/ingest.go b/pkg/agentic/ingest.go index 8b2facd..27b8eb7 100644 --- a/pkg/agentic/ingest.go +++ b/pkg/agentic/ingest.go @@ -5,11 +5,11 @@ package agentic import ( "bytes" "encoding/json" - "fmt" "net/http" "os" "path/filepath" - "strings" + + core "dappco.re/go/core" ) // ingestFindings reads the agent output log and creates issues via the API @@ -21,7 +21,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { } // Read the log file - logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) + logFiles, _ := filepath.Glob(core.JoinPath(wsDir, "agent-*.log")) if len(logFiles) == 0 { return } @@ -34,7 +34,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { body := r.Value.(string) // Skip quota errors - if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") { + if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") { return } @@ -47,13 +47,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) { // Determine issue type from the template used issueType := "task" priority := "normal" - if strings.Contains(body, "security") || strings.Contains(body, "Security") { + if core.Contains(body, "security") || core.Contains(body, "Security") { issueType = "bug" priority = "high" } // Create a single issue per repo with all findings in the body - title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) + title := core.Sprintf("Scan findings for %s (%d items)", st.Repo, findings) // Truncate body to reasonable size for issue description description := body @@ -76,7 +76,7 @@ func countFileRefs(body string) int { } if j < len(body) && body[j] == '`' { ref := body[i+1 : j] - if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") { + if core.Contains(ref, ".go:") || core.Contains(ref, ".php:") { count++ } } @@ -93,11 +93,11 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p // Read the agent API key from file home, _ := os.UserHomeDir() - r := fs.Read(filepath.Join(home, ".claude", "agent-api.key")) + r := fs.Read(core.JoinPath(home, ".claude", "agent-api.key")) if !r.OK { return } - apiKey := strings.TrimSpace(r.Value.(string)) + apiKey := core.Trim(r.Value.(string)) payload, _ := json.Marshal(map[string]string{ "title": title, diff --git a/pkg/agentic/mirror.go b/pkg/agentic/mirror.go index 0f39a5d..934da95 100644 --- a/pkg/agentic/mirror.go +++ b/pkg/agentic/mirror.go @@ -4,10 +4,8 @@ package agentic import ( "context" - "fmt" "os" "os/exec" - "path/filepath" "strings" core "dappco.re/go/core" @@ -17,13 +15,17 @@ import ( // --- agentic_mirror tool --- // MirrorInput is the input for agentic_mirror. +// +// input := agentic.MirrorInput{Repo: "go-io", DryRun: true, MaxFiles: 50} type MirrorInput struct { - Repo string `json:"repo,omitempty"` // Specific repo, or empty for all - DryRun bool `json:"dry_run,omitempty"` // Preview without pushing + Repo string `json:"repo,omitempty"` // Specific repo, or empty for all + DryRun bool `json:"dry_run,omitempty"` // Preview without pushing MaxFiles int `json:"max_files,omitempty"` // Max files per PR (default 50, CodeRabbit limit) } // MirrorOutput is the output for agentic_mirror. +// +// out := agentic.MirrorOutput{Success: true, Count: 1, Synced: []agentic.MirrorSync{{Repo: "go-io"}}} type MirrorOutput struct { Success bool `json:"success"` Synced []MirrorSync `json:"synced"` @@ -32,6 +34,8 @@ type MirrorOutput struct { } // MirrorSync records one repo sync. +// +// sync := agentic.MirrorSync{Repo: "go-io", CommitsAhead: 3, FilesChanged: 12} type MirrorSync struct { Repo string `json:"repo"` CommitsAhead int `json:"commits_ahead"` @@ -57,9 +61,9 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu basePath := s.codePath if basePath == "" { home, _ := os.UserHomeDir() - basePath = filepath.Join(home, "Code", "core") + basePath = core.JoinPath(home, "Code", "core") } else { - basePath = filepath.Join(basePath, "core") + basePath = core.JoinPath(basePath, "core") } // Build list of repos to sync @@ -74,7 +78,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu var skipped []string for _, repo := range repos { - repoDir := filepath.Join(basePath, repo) + repoDir := core.JoinPath(basePath, repo) // Check if github remote exists if !hasRemote(repoDir, "github") { @@ -105,7 +109,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu // Skip if too many files for one PR if files > maxFiles { - sync.Skipped = fmt.Sprintf("%d files exceeds limit of %d", files, maxFiles) + sync.Skipped = core.Sprintf("%d files exceeds limit of %d", files, maxFiles) synced = append(synced, sync) continue } @@ -124,7 +128,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu pushCmd := exec.CommandContext(ctx, "git", "push", "github", base+":refs/heads/dev", "--force") pushCmd.Dir = repoDir if err := pushCmd.Run(); err != nil { - sync.Skipped = fmt.Sprintf("push failed: %v", err) + sync.Skipped = core.Sprintf("push failed: %v", err) synced = append(synced, sync) continue } @@ -133,7 +137,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu // Create PR: dev → main on GitHub prURL, err := s.createGitHubPR(ctx, repoDir, repo, ahead, files) if err != nil { - sync.Skipped = fmt.Sprintf("PR creation failed: %v", err) + sync.Skipped = core.Sprintf("PR creation failed: %v", err) } else { sync.PRURL = prURL } @@ -152,11 +156,11 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu // createGitHubPR creates a PR from dev → main using the gh CLI. func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string, commits, files int) (string, error) { // Check if there's already an open PR from dev - ghRepo := fmt.Sprintf("%s/%s", GitHubOrg(), repo) + ghRepo := core.Sprintf("%s/%s", GitHubOrg(), repo) checkCmd := exec.CommandContext(ctx, "gh", "pr", "list", "--repo", ghRepo, "--head", "dev", "--state", "open", "--json", "url", "--limit", "1") checkCmd.Dir = repoDir out, err := checkCmd.Output() - if err == nil && strings.Contains(string(out), "url") { + if err == nil && core.Contains(string(out), "url") { // PR already exists — extract URL // Format: [{"url":"https://..."}] url := extractJSONField(string(out), "url") @@ -166,7 +170,7 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string } // Build PR body - body := fmt.Sprintf("## Forge → GitHub Sync\n\n"+ + body := core.Sprintf("## Forge → GitHub Sync\n\n"+ "**Commits:** %d\n"+ "**Files changed:** %d\n\n"+ "Automated sync from Forge (forge.lthn.ai) to GitHub mirror.\n"+ @@ -175,7 +179,7 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string "Co-Authored-By: Virgil ", commits, files) - title := fmt.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files) + title := core.Sprintf("[sync] %s: %d commits, %d files", repo, commits, files) prCmd := exec.CommandContext(ctx, "gh", "pr", "create", "--repo", ghRepo, @@ -191,7 +195,7 @@ func (s *PrepSubsystem) createGitHubPR(ctx context.Context, repoDir, repo string } // gh pr create outputs the PR URL on the last line - lines := strings.Split(strings.TrimSpace(string(prOut)), "\n") + lines := core.Split(core.Trim(string(prOut)), "\n") if len(lines) > 0 { return lines[len(lines)-1], nil } @@ -222,9 +226,7 @@ func commitsAhead(repoDir, base, head string) int { if err != nil { return 0 } - var n int - fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &n) - return n + return parseInt(string(out)) } // filesChanged returns the number of files changed between two refs. @@ -235,7 +237,7 @@ func filesChanged(repoDir, base, head string) int { if err != nil { return 0 } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lines := core.Split(core.Trim(string(out)), "\n") if len(lines) == 1 && lines[0] == "" { return 0 } @@ -254,7 +256,7 @@ func (s *PrepSubsystem) listLocalRepos(basePath string) []string { continue } // Must have a .git directory - if _, err := os.Stat(filepath.Join(basePath, e.Name(), ".git")); err == nil { + if _, err := os.Stat(core.JoinPath(basePath, e.Name(), ".git")); err == nil { repos = append(repos, e.Name()) } } @@ -264,7 +266,7 @@ func (s *PrepSubsystem) listLocalRepos(basePath string) []string { // extractJSONField extracts a simple string field from JSON array output. func extractJSONField(jsonStr, field string) string { // Quick and dirty — works for gh CLI output like [{"url":"https://..."}] - key := fmt.Sprintf(`"%s":"`, field) + key := core.Sprintf(`"%s":"`, field) idx := strings.Index(jsonStr, key) if idx < 0 { return "" @@ -276,4 +278,3 @@ func extractJSONField(jsonStr, field string) string { } return jsonStr[start : start+end] } - diff --git a/pkg/agentic/paths.go b/pkg/agentic/paths.go index 9f7fd20..89c5657 100644 --- a/pkg/agentic/paths.go +++ b/pkg/agentic/paths.go @@ -5,8 +5,7 @@ package agentic import ( "os" "os/exec" - "path/filepath" - "strings" + "strconv" "unsafe" core "dappco.re/go/core" @@ -15,7 +14,7 @@ import ( // fs provides unrestricted filesystem access (root "/" = no sandbox). // // r := fs.Read("/etc/hostname") -// if r.OK { fmt.Println(r.Value.(string)) } +// if r.OK { core.Print(nil, "%s", r.Value.(string)) } var fs = newFs("/") // newFs creates a core.Fs with the given root directory. @@ -28,29 +27,36 @@ func newFs(root string) *core.Fs { } // LocalFs returns an unrestricted filesystem instance for use by other packages. +// +// r := agentic.LocalFs().Read("/tmp/agent-status.json") +// if r.OK { core.Print(nil, "%s", r.Value.(string)) } func LocalFs() *core.Fs { return fs } // WorkspaceRoot returns the root directory for agent workspaces. // Checks CORE_WORKSPACE env var first, falls back to ~/Code/.core/workspace. // -// wsDir := filepath.Join(agentic.WorkspaceRoot(), "go-io-1774149757") +// wsDir := core.JoinPath(agentic.WorkspaceRoot(), "go-io-1774149757") func WorkspaceRoot() string { - return filepath.Join(CoreRoot(), "workspace") + return core.JoinPath(CoreRoot(), "workspace") } // CoreRoot returns the root directory for core ecosystem files. // Checks CORE_WORKSPACE env var first, falls back to ~/Code/.core. +// +// root := agentic.CoreRoot() func CoreRoot() string { if root := os.Getenv("CORE_WORKSPACE"); root != "" { return root } home, _ := os.UserHomeDir() - return filepath.Join(home, "Code", ".core") + return core.JoinPath(home, "Code", ".core") } // PlansRoot returns the root directory for agent plans. +// +// plansDir := agentic.PlansRoot() func PlansRoot() string { - return filepath.Join(CoreRoot(), "plans") + return core.JoinPath(CoreRoot(), "plans") } // AgentName returns the name of this agent based on hostname. @@ -62,21 +68,23 @@ func AgentName() string { return name } hostname, _ := os.Hostname() - h := strings.ToLower(hostname) - if strings.Contains(h, "snider") || strings.Contains(h, "studio") || strings.Contains(h, "mac") { + h := core.Lower(hostname) + if core.Contains(h, "snider") || core.Contains(h, "studio") || core.Contains(h, "mac") { return "cladius" } return "charon" } // DefaultBranch detects the default branch of a repo (main, master, etc.). +// +// base := agentic.DefaultBranch("./src") func DefaultBranch(repoDir string) string { cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short") cmd.Dir = repoDir if out, err := cmd.Output(); err == nil { - ref := strings.TrimSpace(string(out)) - if strings.HasPrefix(ref, "origin/") { - return strings.TrimPrefix(ref, "origin/") + ref := core.Trim(string(out)) + if core.HasPrefix(ref, "origin/") { + return core.TrimPrefix(ref, "origin/") } return ref } @@ -91,9 +99,19 @@ func DefaultBranch(repoDir string) string { } // GitHubOrg returns the GitHub org for mirror operations. +// +// org := agentic.GitHubOrg() // "dAppCore" func GitHubOrg() string { if org := os.Getenv("GITHUB_ORG"); org != "" { return org } return "dAppCore" } + +func parseInt(value string) int { + n, err := strconv.Atoi(core.Trim(value)) + if err != nil { + return 0 + } + return n +} diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index bd5bb1b..2e447fb 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -23,7 +23,7 @@ import ( type Plan struct { ID string `json:"id"` Title string `json:"title"` - Status string `json:"status"` // draft, ready, in_progress, needs_verification, verified, approved + Status string `json:"status"` // draft, ready, in_progress, needs_verification, verified, approved Repo string `json:"repo,omitempty"` Org string `json:"org,omitempty"` Objective string `json:"objective"` @@ -35,10 +35,12 @@ type Plan struct { } // Phase represents a phase within an implementation plan. +// +// phase := agentic.Phase{Number: 1, Name: "Migrate strings", Status: "in_progress"} type Phase struct { Number int `json:"number"` Name string `json:"name"` - Status string `json:"status"` // pending, in_progress, done + Status string `json:"status"` // pending, in_progress, done Criteria []string `json:"criteria,omitempty"` Tests int `json:"tests,omitempty"` Notes string `json:"notes,omitempty"` @@ -47,6 +49,8 @@ type Phase struct { // --- Input/Output types --- // PlanCreateInput is the input for agentic_plan_create. +// +// input := agentic.PlanCreateInput{Title: "Migrate pkg/agentic", Objective: "Use Core primitives everywhere"} type PlanCreateInput struct { Title string `json:"title"` Objective string `json:"objective"` @@ -57,6 +61,8 @@ type PlanCreateInput struct { } // PlanCreateOutput is the output for agentic_plan_create. +// +// out := agentic.PlanCreateOutput{Success: true, ID: "migrate-pkg-agentic-abc123"} type PlanCreateOutput struct { Success bool `json:"success"` ID string `json:"id"` @@ -64,17 +70,23 @@ type PlanCreateOutput struct { } // PlanReadInput is the input for agentic_plan_read. +// +// input := agentic.PlanReadInput{ID: "migrate-pkg-agentic-abc123"} type PlanReadInput struct { ID string `json:"id"` } // PlanReadOutput is the output for agentic_plan_read. +// +// out := agentic.PlanReadOutput{Success: true, Plan: agentic.Plan{ID: "migrate-pkg-agentic-abc123"}} type PlanReadOutput struct { Success bool `json:"success"` Plan Plan `json:"plan"` } // PlanUpdateInput is the input for agentic_plan_update. +// +// input := agentic.PlanUpdateInput{ID: "migrate-pkg-agentic-abc123", Status: "verified"} type PlanUpdateInput struct { ID string `json:"id"` Status string `json:"status,omitempty"` @@ -86,29 +98,39 @@ type PlanUpdateInput struct { } // PlanUpdateOutput is the output for agentic_plan_update. +// +// out := agentic.PlanUpdateOutput{Success: true, Plan: agentic.Plan{Status: "verified"}} type PlanUpdateOutput struct { Success bool `json:"success"` Plan Plan `json:"plan"` } // PlanDeleteInput is the input for agentic_plan_delete. +// +// input := agentic.PlanDeleteInput{ID: "migrate-pkg-agentic-abc123"} type PlanDeleteInput struct { ID string `json:"id"` } // PlanDeleteOutput is the output for agentic_plan_delete. +// +// out := agentic.PlanDeleteOutput{Success: true, Deleted: "migrate-pkg-agentic-abc123"} type PlanDeleteOutput struct { Success bool `json:"success"` Deleted string `json:"deleted"` } // PlanListInput is the input for agentic_plan_list. +// +// input := agentic.PlanListInput{Repo: "go-io", Status: "ready"} type PlanListInput struct { Status string `json:"status,omitempty"` Repo string `json:"repo,omitempty"` } // PlanListOutput is the output for agentic_plan_list. +// +// out := agentic.PlanListOutput{Success: true, Count: 2, Plans: []agentic.Plan{{ID: "migrate-pkg-agentic-abc123"}}} type PlanListOutput struct { Success bool `json:"success"` Count int `json:"count"` @@ -286,11 +308,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu var plans []Plan for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") { continue } - id := strings.TrimSuffix(entry.Name(), ".json") + id := core.TrimSuffix(entry.Name(), ".json") plan, err := readPlan(dir, id) if err != nil { continue @@ -322,7 +344,7 @@ func planPath(dir, id string) string { if safe == "." || safe == ".." || safe == "" { safe = "invalid" } - return filepath.Join(dir, safe+".json") + return core.JoinPath(dir, safe+".json") } func generatePlanID(title string) string { @@ -340,8 +362,8 @@ func generatePlanID(title string) string { }, title) // Trim consecutive dashes and cap length - for strings.Contains(slug, "--") { - slug = strings.ReplaceAll(slug, "--", "-") + for core.Contains(slug, "--") { + slug = core.Replace(slug, "--", "-") } slug = strings.Trim(slug, "-") if len(slug) > 30 { diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index 4ce041b..a4f8d5e 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -6,12 +6,9 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" "os" "os/exec" - "path/filepath" - "strings" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -20,15 +17,19 @@ import ( // --- agentic_create_pr --- // CreatePRInput is the input for agentic_create_pr. +// +// input := agentic.CreatePRInput{Workspace: "go-io-1773581873", Title: "Fix watcher panic"} type CreatePRInput struct { - Workspace string `json:"workspace"` // workspace name (e.g. "mcp-1773581873") - Title string `json:"title,omitempty"` // PR title (default: task description) - Body string `json:"body,omitempty"` // PR body (default: auto-generated) - Base string `json:"base,omitempty"` // base branch (default: "main") - DryRun bool `json:"dry_run,omitempty"` // preview without creating + Workspace string `json:"workspace"` // workspace name (e.g. "mcp-1773581873") + Title string `json:"title,omitempty"` // PR title (default: task description) + Body string `json:"body,omitempty"` // PR body (default: auto-generated) + Base string `json:"base,omitempty"` // base branch (default: "main") + DryRun bool `json:"dry_run,omitempty"` // preview without creating } // CreatePROutput is the output for agentic_create_pr. +// +// out := agentic.CreatePROutput{Success: true, PRURL: "https://forge.example/core/go-io/pulls/12", PRNum: 12} type CreatePROutput struct { Success bool `json:"success"` PRURL string `json:"pr_url,omitempty"` @@ -54,8 +55,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in return nil, CreatePROutput{}, core.E("createPR", "no Forge token configured", nil) } - wsDir := filepath.Join(WorkspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.JoinPath(WorkspaceRoot(), input.Workspace) + srcDir := core.JoinPath(wsDir, "src") if _, err := os.Stat(srcDir); err != nil { return nil, CreatePROutput{}, core.E("createPR", "workspace not found: "+input.Workspace, nil) @@ -75,7 +76,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in if err != nil { return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", err) } - st.Branch = strings.TrimSpace(string(out)) + st.Branch = core.Trim(string(out)) } org := st.Org @@ -93,7 +94,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in title = st.Task } if title == "" { - title = fmt.Sprintf("Agent work on %s", st.Branch) + title = core.Sprintf("Agent work on %s", st.Branch) } // Build PR body @@ -112,7 +113,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } // Push branch to Forge (origin is the local clone, not Forge) - forgeRemote := fmt.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) + forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) pushCmd := exec.CommandContext(ctx, "git", "push", forgeRemote, st.Branch) pushCmd.Dir = srcDir pushOut, err := pushCmd.CombinedOutput() @@ -132,7 +133,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in // Comment on issue if tracked if st.Issue > 0 { - comment := fmt.Sprintf("Pull request created: %s", prURL) + comment := core.Sprintf("Pull request created: %s", prURL) s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment) } @@ -148,17 +149,17 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string { - var b strings.Builder + b := core.NewBuilder() b.WriteString("## Summary\n\n") if st.Task != "" { b.WriteString(st.Task) b.WriteString("\n\n") } if st.Issue > 0 { - b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue)) + b.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue)) } - b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent)) - b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs)) + b.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent)) + b.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs)) b.WriteString("\n---\n*Created by agentic dispatch*\n") return b.String() } @@ -171,7 +172,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base "base": base, }) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -186,7 +187,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base var errBody map[string]any json.NewDecoder(resp.Body).Decode(&errBody) msg, _ := errBody["message"].(string) - return "", 0, core.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) + return "", 0, core.E("forgeCreatePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) } var pr struct { @@ -201,7 +202,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, issue int, comment string) { payload, _ := json.Marshal(map[string]string{"body": comment}) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -216,14 +217,18 @@ func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, is // --- agentic_list_prs --- // ListPRsInput is the input for agentic_list_prs. +// +// input := agentic.ListPRsInput{Org: "core", Repo: "go-io", State: "open", Limit: 10} type ListPRsInput struct { Org string `json:"org,omitempty"` // forge org (default "core") - Repo string `json:"repo,omitempty"` // specific repo, or empty for all + Repo string `json:"repo,omitempty"` // specific repo, or empty for all State string `json:"state,omitempty"` // "open" (default), "closed", "all" Limit int `json:"limit,omitempty"` // max results (default 20) } // ListPRsOutput is the output for agentic_list_prs. +// +// out := agentic.ListPRsOutput{Success: true, Count: 2, PRs: []agentic.PRInfo{{Repo: "go-io", Number: 12}}} type ListPRsOutput struct { Success bool `json:"success"` Count int `json:"count"` @@ -231,6 +236,8 @@ type ListPRsOutput struct { } // PRInfo represents a pull request. +// +// pr := agentic.PRInfo{Repo: "go-io", Number: 12, Title: "Migrate pkg/fs", Branch: "agent/migrate-fs"} type PRInfo struct { Repo string `json:"repo"` Number int `json:"number"` @@ -303,7 +310,7 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp } func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", + url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10", s.forgeURL, org, repo, state) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -315,7 +322,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, core.E("listRepoPRs", fmt.Sprintf("HTTP %d listing PRs for %s", resp.StatusCode, repo), nil) + return nil, core.E("listRepoPRs", core.Sprintf("HTTP %d listing PRs for %s", resp.StatusCode, repo), nil) } var prs []struct { diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 8d27da3..cd97916 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -8,7 +8,6 @@ import ( "context" "encoding/base64" "encoding/json" - "fmt" "io" "net/http" "os" @@ -18,14 +17,16 @@ import ( "sync" "time" - core "dappco.re/go/core" "dappco.re/go/agent/pkg/lib" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" "gopkg.in/yaml.v3" ) // CompletionNotifier is called when an agent completes, to trigger // immediate notifications to connected clients. +// +// prep.SetCompletionNotifier(monitor) type CompletionNotifier interface { Poke() } @@ -35,15 +36,15 @@ type CompletionNotifier interface { // sub := agentic.NewPrep() // sub.RegisterTools(server) type PrepSubsystem struct { - forgeURL string - forgeToken string - brainURL string - brainKey string - specsPath string - codePath string - client *http.Client - onComplete CompletionNotifier - drainMu sync.Mutex // protects drainQueue from concurrent execution + forgeURL string + forgeToken string + brainURL string + brainKey string + specsPath string + codePath string + client *http.Client + onComplete CompletionNotifier + drainMu sync.Mutex // protects drainQueue from concurrent execution } // NewPrep creates an agentic subsystem. @@ -61,8 +62,8 @@ func NewPrep() *PrepSubsystem { brainKey := os.Getenv("CORE_BRAIN_KEY") if brainKey == "" { - if r := fs.Read(filepath.Join(home, ".claude", "brain.key")); r.OK { - brainKey = strings.TrimSpace(r.Value.(string)) + if r := fs.Read(core.JoinPath(home, ".claude", "brain.key")); r.OK { + brainKey = core.Trim(r.Value.(string)) } } @@ -71,13 +72,15 @@ func NewPrep() *PrepSubsystem { forgeToken: forgeToken, brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainKey: brainKey, - specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "specs")), - codePath: envOr("CODE_PATH", filepath.Join(home, "Code")), + specsPath: envOr("SPECS_PATH", core.JoinPath(home, "Code", "specs")), + codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")), client: &http.Client{Timeout: 30 * time.Second}, } } // SetCompletionNotifier wires up the monitor for immediate push on agent completion. +// +// prep.SetCompletionNotifier(monitor) func (s *PrepSubsystem) SetCompletionNotifier(n CompletionNotifier) { s.onComplete = n } @@ -90,9 +93,13 @@ func envOr(key, fallback string) string { } // Name implements mcp.Subsystem. +// +// name := prep.Name() // "agentic" func (s *PrepSubsystem) Name() string { return "agentic" } // RegisterTools implements mcp.Subsystem. +// +// prep.RegisterTools(server) func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "agentic_prep_workspace", @@ -120,11 +127,15 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { } // Shutdown implements mcp.SubsystemWithShutdown. +// +// _ = prep.Shutdown(context.Background()) func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil } // --- Input/Output types --- // PrepInput is the input for agentic_prep_workspace. +// +// input := agentic.PrepInput{Repo: "go-io", Task: "Migrate pkg/fs to Core primitives"} type PrepInput struct { Repo string `json:"repo"` // e.g. "go-io" Org string `json:"org,omitempty"` // default "core" @@ -137,16 +148,18 @@ type PrepInput struct { } // PrepOutput is the output for agentic_prep_workspace. +// +// out := agentic.PrepOutput{Success: true, WorkspaceDir: ".core/workspace/go-io-123", Branch: "agent/migrate-fs"} type PrepOutput struct { - Success bool `json:"success"` - WorkspaceDir string `json:"workspace_dir"` - Branch string `json:"branch"` - WikiPages int `json:"wiki_pages"` - SpecFiles int `json:"spec_files"` - Memories int `json:"memories"` - Consumers int `json:"consumers"` - ClaudeMd bool `json:"claude_md"` - GitLog int `json:"git_log_entries"` + Success bool `json:"success"` + WorkspaceDir string `json:"workspace_dir"` + Branch string `json:"branch"` + WikiPages int `json:"wiki_pages"` + SpecFiles int `json:"spec_files"` + Memories int `json:"memories"` + Consumers int `json:"consumers"` + ClaudeMd bool `json:"claude_md"` + GitLog int `json:"git_log_entries"` } func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) { @@ -162,8 +175,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques // Workspace root: .core/workspace/{repo}-{timestamp}/ wsRoot := WorkspaceRoot() - wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().UnixNano()) - wsDir := filepath.Join(wsRoot, wsName) + wsName := core.Sprintf("%s-%d", input.Repo, time.Now().UnixNano()) + wsDir := core.JoinPath(wsRoot, wsName) // Create workspace structure // kb/ and specs/ will be created inside src/ after clone @@ -180,10 +193,10 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques if repoName == "." || repoName == ".." || repoName == "" { return nil, PrepOutput{}, core.E("prep", "invalid repo name: "+input.Repo, nil) } - repoPath := filepath.Join(s.codePath, "core", repoName) + repoPath := core.JoinPath(s.codePath, "core", repoName) // 1. Clone repo into src/ and create feature branch - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir) if err := cloneCmd.Run(); err != nil { return nil, PrepOutput{}, core.E("prep", "git clone failed for "+input.Repo, err) @@ -205,23 +218,23 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques taskSlug = strings.Trim(taskSlug, "-") if taskSlug == "" { // Fallback for issue-only dispatches with no task text - taskSlug = fmt.Sprintf("issue-%d", input.Issue) + taskSlug = core.Sprintf("issue-%d", input.Issue) if input.Issue == 0 { - taskSlug = fmt.Sprintf("work-%d", time.Now().Unix()) + taskSlug = core.Sprintf("work-%d", time.Now().Unix()) } } - branchName := fmt.Sprintf("agent/%s", taskSlug) + branchName := core.Sprintf("agent/%s", taskSlug) branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName) branchCmd.Dir = srcDir if err := branchCmd.Run(); err != nil { - return nil, PrepOutput{}, core.E("prep.branch", fmt.Sprintf("failed to create branch %q", branchName), err) + return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), err) } out.Branch = branchName // Create context dirs inside src/ - fs.EnsureDir(filepath.Join(srcDir, "kb")) - fs.EnsureDir(filepath.Join(srcDir, "specs")) + fs.EnsureDir(core.JoinPath(srcDir, "kb")) + fs.EnsureDir(core.JoinPath(srcDir, "specs")) // Remote stays as local clone origin — agent cannot push to forge. // Reviewer pulls changes from workspace and pushes after verification. @@ -258,14 +271,14 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques out.ClaudeMd = true // Copy repo's own CLAUDE.md over template if it exists - claudeMdPath := filepath.Join(repoPath, "CLAUDE.md") + claudeMdPath := core.JoinPath(repoPath, "CLAUDE.md") if r := fs.Read(claudeMdPath); r.OK { - fs.Write(filepath.Join(srcDir, "CLAUDE.md"), r.Value.(string)) + fs.Write(core.JoinPath(srcDir, "CLAUDE.md"), r.Value.(string)) } // Copy GEMINI.md from core/agent (ethics framework for all agents) - agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md") + agentGeminiMd := core.JoinPath(s.codePath, "core", "agent", "GEMINI.md") if r := fs.Read(agentGeminiMd); r.OK { - fs.Write(filepath.Join(srcDir, "GEMINI.md"), r.Value.(string)) + fs.Write(core.JoinPath(srcDir, "GEMINI.md"), r.Value.(string)) } // 3. Generate TODO.md from issue (overrides template) @@ -312,7 +325,7 @@ func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) { } } - fs.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) + fs.Write(core.JoinPath(wsDir, "src", "PROMPT.md"), prompt) } // --- Plan template rendering --- @@ -330,8 +343,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map // Substitute variables ({{variable_name}} → value) for key, value := range variables { - content = strings.ReplaceAll(content, "{{"+key+"}}", value) - content = strings.ReplaceAll(content, "{{ "+key+" }}", value) + content = core.Replace(content, "{{"+key+"}}", value) + content = core.Replace(content, "{{ "+key+" }}", value) } // Parse the YAML to render as markdown @@ -340,9 +353,9 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map Description string `yaml:"description"` Guidelines []string `yaml:"guidelines"` Phases []struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Tasks []any `yaml:"tasks"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Tasks []any `yaml:"tasks"` } `yaml:"phases"` } @@ -351,7 +364,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map } // Render as PLAN.md - var plan strings.Builder + plan := core.NewBuilder() plan.WriteString("# Plan: " + tmpl.Name + "\n\n") if task != "" { plan.WriteString("**Task:** " + task + "\n\n") @@ -369,7 +382,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map } for i, phase := range tmpl.Phases { - plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) + plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) if phase.Description != "" { plan.WriteString(phase.Description + "\n\n") } @@ -386,7 +399,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") } - fs.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) + fs.Write(core.JoinPath(wsDir, "src", "PLAN.md"), plan.String()) } // --- Helpers (unchanged) --- @@ -396,7 +409,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i return 0 } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -423,7 +436,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i subURL = page.Title } - pageURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) + pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) pageReq, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil) pageReq.Header.Set("Authorization", "token "+s.forgeToken) @@ -454,7 +467,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i return '-' }, page.Title) + ".md" - fs.Write(filepath.Join(wsDir, "src", "kb", filename), string(content)) + fs.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content)) count++ } @@ -466,9 +479,9 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int { count := 0 for _, file := range specFiles { - src := filepath.Join(s.specsPath, file) + src := core.JoinPath(s.specsPath, file) if r := fs.Read(src); r.OK { - fs.Write(filepath.Join(wsDir, "src", "specs", file), r.Value.(string)) + fs.Write(core.JoinPath(wsDir, "src", "specs", file), r.Value.(string)) count++ } } @@ -488,7 +501,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) "agent_id": "cladius", }) - req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body))) + req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+s.brainKey) @@ -509,7 +522,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) } json.Unmarshal(respData, &result) - var content strings.Builder + content := core.NewBuilder() content.WriteString("# Context — " + repo + "\n\n") content.WriteString("> Relevant knowledge from OpenBrain.\n\n") @@ -518,15 +531,15 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) memContent, _ := mem["content"].(string) memProject, _ := mem["project"].(string) score, _ := mem["score"].(float64) - content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) + content.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) } - fs.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) + fs.Write(core.JoinPath(wsDir, "src", "CONTEXT.md"), content.String()) return len(result.Memories) } func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { - goWorkPath := filepath.Join(s.codePath, "go.work") + goWorkPath := core.JoinPath(s.codePath, "go.work") modulePath := "forge.lthn.ai/core/" + repo r := fs.Read(goWorkPath) @@ -536,19 +549,19 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { workData := r.Value.(string) var consumers []string - for _, line := range strings.Split(workData, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "./") { + for _, line := range core.Split(workData, "\n") { + line = core.Trim(line) + if !core.HasPrefix(line, "./") { continue } - dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./")) - goMod := filepath.Join(dir, "go.mod") + dir := core.JoinPath(s.codePath, core.TrimPrefix(line, "./")) + goMod := core.JoinPath(dir, "go.mod") mr := fs.Read(goMod) if !mr.OK { continue } modData := mr.Value.(string) - if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) { + if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) { consumers = append(consumers, filepath.Base(dir)) } } @@ -559,8 +572,8 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { for _, c := range consumers { content += "- " + c + "\n" } - content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) - fs.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) + content += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) + fs.Write(core.JoinPath(wsDir, "src", "CONSUMERS.md"), content) } return len(consumers) @@ -574,10 +587,10 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int { return 0 } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") + lines := core.Split(core.Trim(string(output)), "\n") if len(lines) > 0 && lines[0] != "" { content := "# Recent Changes\n\n```\n" + string(output) + "```\n" - fs.Write(filepath.Join(wsDir, "src", "RECENT.md"), content) + fs.Write(core.JoinPath(wsDir, "src", "RECENT.md"), content) } return len(lines) @@ -588,7 +601,7 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu return } - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) @@ -608,13 +621,13 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu } json.NewDecoder(resp.Body).Decode(&issueData) - content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title) - content += fmt.Sprintf("**Status:** ready\n") - content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) - content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) + content := core.Sprintf("# TASK: %s\n\n", issueData.Title) + content += core.Sprintf("**Status:** ready\n") + content += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) + content += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) content += "## Objective\n\n" + issueData.Body + "\n" - fs.Write(filepath.Join(wsDir, "src", "TODO.md"), content) + fs.Write(core.JoinPath(wsDir, "src", "TODO.md"), content) } // detectLanguage guesses the primary language from repo contents. @@ -633,7 +646,7 @@ func detectLanguage(repoPath string) string { {"Dockerfile", "docker"}, } for _, c := range checks { - if _, err := os.Stat(filepath.Join(repoPath, c.file)); err == nil { + if _, err := os.Stat(core.JoinPath(repoPath, c.file)); err == nil { return c.lang } } diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index ca0687b..e8de33b 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -3,10 +3,10 @@ package agentic import ( - "path/filepath" - "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "path/filepath" + "testing" ) func TestEnvOr_Good_EnvSet(t *testing.T) { diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index cae1bac..e5034ff 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -3,17 +3,18 @@ package agentic import ( - "fmt" "os" - "path/filepath" - "strings" + "strconv" "syscall" "time" + core "dappco.re/go/core" "gopkg.in/yaml.v3" ) // DispatchConfig controls agent dispatch behaviour. +// +// cfg := agentic.DispatchConfig{DefaultAgent: "claude", DefaultTemplate: "coding"} type DispatchConfig struct { DefaultAgent string `yaml:"default_agent"` DefaultTemplate string `yaml:"default_template"` @@ -21,28 +22,32 @@ type DispatchConfig struct { } // RateConfig controls pacing between task dispatches. +// +// rate := agentic.RateConfig{ResetUTC: "06:00", SustainedDelay: 120, BurstWindow: 2, BurstDelay: 15} type RateConfig struct { ResetUTC string `yaml:"reset_utc"` // Daily quota reset time (UTC), e.g. "06:00" - DailyLimit int `yaml:"daily_limit"` // Max requests per day (0 = unknown) - MinDelay int `yaml:"min_delay"` // Minimum seconds between task starts - SustainedDelay int `yaml:"sustained_delay"` // Delay when pacing for full-day use - BurstWindow int `yaml:"burst_window"` // Hours before reset where burst kicks in - BurstDelay int `yaml:"burst_delay"` // Delay during burst window + DailyLimit int `yaml:"daily_limit"` // Max requests per day (0 = unknown) + MinDelay int `yaml:"min_delay"` // Minimum seconds between task starts + SustainedDelay int `yaml:"sustained_delay"` // Delay when pacing for full-day use + BurstWindow int `yaml:"burst_window"` // Hours before reset where burst kicks in + BurstDelay int `yaml:"burst_delay"` // Delay during burst window } // AgentsConfig is the root of config/agents.yaml. +// +// cfg := agentic.AgentsConfig{Version: 1, Dispatch: agentic.DispatchConfig{DefaultAgent: "claude"}} type AgentsConfig struct { - Version int `yaml:"version"` - Dispatch DispatchConfig `yaml:"dispatch"` - Concurrency map[string]int `yaml:"concurrency"` + Version int `yaml:"version"` + Dispatch DispatchConfig `yaml:"dispatch"` + Concurrency map[string]int `yaml:"concurrency"` Rates map[string]RateConfig `yaml:"rates"` } // loadAgentsConfig reads config/agents.yaml from the code path. func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { paths := []string{ - filepath.Join(CoreRoot(), "agents.yaml"), - filepath.Join(s.codePath, "core", "agent", "config", "agents.yaml"), + core.JoinPath(CoreRoot(), "agents.yaml"), + core.JoinPath(s.codePath, "core", "agent", "config", "agents.yaml"), } for _, path := range paths { @@ -74,10 +79,7 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { func (s *PrepSubsystem) delayForAgent(agent string) time.Duration { cfg := s.loadAgentsConfig() // Strip variant suffix (claude:opus → claude) for config lookup - base := agent - if idx := strings.Index(agent, ":"); idx >= 0 { - base = agent[:idx] - } + base := baseAgent(agent) rate, ok := cfg.Rates[base] if !ok || rate.SustainedDelay == 0 { return 0 @@ -85,7 +87,15 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration { // Parse reset time resetHour, resetMin := 6, 0 - fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin) + parts := core.Split(rate.ResetUTC, ":") + if len(parts) >= 2 { + if hour, err := strconv.Atoi(core.Trim(parts[0])); err == nil { + resetHour = hour + } + if min, err := strconv.Atoi(core.Trim(parts[1])); err == nil { + resetMin = min + } + } now := time.Now().UTC() resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC) @@ -120,7 +130,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { continue } - st, err := readStatus(filepath.Join(wsRoot, entry.Name())) + st, err := readStatus(core.JoinPath(wsRoot, entry.Name())) if err != nil || st.Status != "running" { continue } @@ -138,7 +148,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int { // baseAgent strips the model variant (gemini:flash → gemini). func baseAgent(agent string) string { - return strings.SplitN(agent, ":", 2)[0] + return core.SplitN(agent, ":", 2)[0] } // canDispatchAgent checks if we're under the concurrency limit for a specific agent type. @@ -171,7 +181,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - wsDir := filepath.Join(wsRoot, entry.Name()) + wsDir := core.JoinPath(wsRoot, entry.Name()) st, err := readStatus(wsDir) if err != nil || st.Status != "queued" { continue @@ -192,7 +202,7 @@ func (s *PrepSubsystem) drainQueue() { continue } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the current directory. Work in this directory." pid, _, err := s.spawnAgent(st.Agent, prompt, wsDir, srcDir) diff --git a/pkg/agentic/queue_test.go b/pkg/agentic/queue_test.go index 1cb91fe..c3a1645 100644 --- a/pkg/agentic/queue_test.go +++ b/pkg/agentic/queue_test.go @@ -3,10 +3,10 @@ package agentic import ( - "path/filepath" - "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "path/filepath" + "testing" ) func TestBaseAgent_Ugly_Empty(t *testing.T) { diff --git a/pkg/agentic/remote.go b/pkg/agentic/remote.go index cab7eca..b3ec52a 100644 --- a/pkg/agentic/remote.go +++ b/pkg/agentic/remote.go @@ -5,10 +5,8 @@ package agentic import ( "context" "encoding/json" - "fmt" "net/http" "os" - "strings" "time" core "dappco.re/go/core" @@ -18,18 +16,22 @@ import ( // --- agentic_dispatch_remote tool --- // RemoteDispatchInput dispatches a task to a remote core-agent over HTTP. +// +// input := agentic.RemoteDispatchInput{Host: "charon", Repo: "go-io", Task: "Run the review queue"} type RemoteDispatchInput struct { - Host string `json:"host"` // Remote agent host (e.g. "charon", "10.69.69.165:9101") - Repo string `json:"repo"` // Target repo - Task string `json:"task"` // What the agent should do - Agent string `json:"agent,omitempty"` // Agent type (default: claude:opus) - Template string `json:"template,omitempty"` // Prompt template - Persona string `json:"persona,omitempty"` // Persona slug - Org string `json:"org,omitempty"` // Forge org (default: core) + Host string `json:"host"` // Remote agent host (e.g. "charon", "10.69.69.165:9101") + Repo string `json:"repo"` // Target repo + Task string `json:"task"` // What the agent should do + Agent string `json:"agent,omitempty"` // Agent type (default: claude:opus) + Template string `json:"template,omitempty"` // Prompt template + Persona string `json:"persona,omitempty"` // Persona slug + Org string `json:"org,omitempty"` // Forge org (default: core) Variables map[string]string `json:"variables,omitempty"` // Template variables } // RemoteDispatchOutput is the response from a remote dispatch. +// +// out := agentic.RemoteDispatchOutput{Success: true, Host: "charon", Repo: "go-io", Agent: "claude:opus"} type RemoteDispatchOutput struct { Success bool `json:"success"` Host string `json:"host"` @@ -95,7 +97,7 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque }, } - url := fmt.Sprintf("http://%s/mcp", addr) + url := core.Sprintf("http://%s/mcp", addr) client := &http.Client{Timeout: 30 * time.Second} // Step 1: Initialize session @@ -103,7 +105,7 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque if err != nil { return nil, RemoteDispatchOutput{ Host: input.Host, - Error: fmt.Sprintf("init failed: %v", err), + Error: core.Sprintf("init failed: %v", err), }, core.E("dispatchRemote", "MCP initialize failed", err) } @@ -113,7 +115,7 @@ func (s *PrepSubsystem) dispatchRemote(ctx context.Context, _ *mcp.CallToolReque if err != nil { return nil, RemoteDispatchOutput{ Host: input.Host, - Error: fmt.Sprintf("call failed: %v", err), + Error: core.Sprintf("call failed: %v", err), }, core.E("dispatchRemote", "tool call failed", err) } @@ -162,12 +164,12 @@ func resolveHost(host string) string { "local": "127.0.0.1:9101", } - if addr, ok := aliases[strings.ToLower(host)]; ok { + if addr, ok := aliases[core.Lower(host)]; ok { return addr } // If no port specified, add default - if !strings.Contains(host, ":") { + if !core.Contains(host, ":") { return host + ":9101" } @@ -177,7 +179,7 @@ func resolveHost(host string) string { // remoteToken gets the auth token for a remote agent. func remoteToken(host string) string { // Check environment first - envKey := fmt.Sprintf("AGENT_TOKEN_%s", strings.ToUpper(host)) + envKey := core.Sprintf("AGENT_TOKEN_%s", core.Upper(host)) if token := os.Getenv(envKey); token != "" { return token } @@ -190,12 +192,12 @@ func remoteToken(host string) string { // Try reading from file home, _ := os.UserHomeDir() tokenFiles := []string{ - fmt.Sprintf("%s/.core/tokens/%s.token", home, strings.ToLower(host)), - fmt.Sprintf("%s/.core/agent-token", home), + core.Sprintf("%s/.core/tokens/%s.token", home, core.Lower(host)), + core.Sprintf("%s/.core/agent-token", home), } for _, f := range tokenFiles { if r := fs.Read(f); r.OK { - return strings.TrimSpace(r.Value.(string)) + return core.Trim(r.Value.(string)) } } diff --git a/pkg/agentic/remote_client.go b/pkg/agentic/remote_client.go index 3f64aa4..ae78355 100644 --- a/pkg/agentic/remote_client.go +++ b/pkg/agentic/remote_client.go @@ -7,9 +7,7 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" - "strings" core "dappco.re/go/core" ) @@ -46,7 +44,7 @@ func mcpInitialize(ctx context.Context, client *http.Client, url, token string) defer resp.Body.Close() if resp.StatusCode != 200 { - return "", core.E("mcpInitialize", fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + return "", core.E("mcpInitialize", core.Sprintf("HTTP %d", resp.StatusCode), nil) } sessionID := resp.Header.Get("Mcp-Session-Id") @@ -88,7 +86,7 @@ func mcpCall(ctx context.Context, client *http.Client, url, token, sessionID str defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, core.E("mcpCall", fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + return nil, core.E("mcpCall", core.Sprintf("HTTP %d", resp.StatusCode), nil) } // Parse SSE response — extract data: lines @@ -100,8 +98,8 @@ func readSSEData(resp *http.Response) ([]byte, error) { scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { line := scanner.Text() - if strings.HasPrefix(line, "data: ") { - return []byte(strings.TrimPrefix(line, "data: ")), nil + if core.HasPrefix(line, "data: ") { + return []byte(core.TrimPrefix(line, "data: ")), nil } } return nil, core.E("readSSEData", "no data in SSE response", nil) diff --git a/pkg/agentic/remote_status.go b/pkg/agentic/remote_status.go index 379b4cf..428a2db 100644 --- a/pkg/agentic/remote_status.go +++ b/pkg/agentic/remote_status.go @@ -15,11 +15,15 @@ import ( // --- agentic_status_remote tool --- // RemoteStatusInput queries a remote core-agent for workspace status. +// +// input := agentic.RemoteStatusInput{Host: "charon"} type RemoteStatusInput struct { Host string `json:"host"` // Remote agent host (e.g. "charon") } // RemoteStatusOutput is the response from a remote status check. +// +// out := agentic.RemoteStatusOutput{Success: true, Host: "charon", Count: 2} type RemoteStatusOutput struct { Success bool `json:"success"` Host string `json:"host"` diff --git a/pkg/agentic/resume.go b/pkg/agentic/resume.go index 35a498f..0f45f1a 100644 --- a/pkg/agentic/resume.go +++ b/pkg/agentic/resume.go @@ -4,30 +4,32 @@ package agentic import ( "context" - "fmt" "os" - "path/filepath" core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) // ResumeInput is the input for agentic_resume. +// +// input := agentic.ResumeInput{Workspace: "go-scm-1773581173", Answer: "Use the existing queue config"} type ResumeInput struct { - Workspace string `json:"workspace"` // workspace name (e.g. "go-scm-1773581173") - Answer string `json:"answer,omitempty"` // answer to the blocked question (written to ANSWER.md) - Agent string `json:"agent,omitempty"` // override agent type (default: same as original) - DryRun bool `json:"dry_run,omitempty"` // preview without executing + Workspace string `json:"workspace"` // workspace name (e.g. "go-scm-1773581173") + Answer string `json:"answer,omitempty"` // answer to the blocked question (written to ANSWER.md) + Agent string `json:"agent,omitempty"` // override agent type (default: same as original) + DryRun bool `json:"dry_run,omitempty"` // preview without executing } // ResumeOutput is the output for agentic_resume. +// +// out := agentic.ResumeOutput{Success: true, Workspace: "go-scm-1773581173", Agent: "codex"} type ResumeOutput struct { - Success bool `json:"success"` - Workspace string `json:"workspace"` - Agent string `json:"agent"` - PID int `json:"pid,omitempty"` - OutputFile string `json:"output_file,omitempty"` - Prompt string `json:"prompt,omitempty"` + Success bool `json:"success"` + Workspace string `json:"workspace"` + Agent string `json:"agent"` + PID int `json:"pid,omitempty"` + OutputFile string `json:"output_file,omitempty"` + Prompt string `json:"prompt,omitempty"` } func (s *PrepSubsystem) registerResumeTool(server *mcp.Server) { @@ -42,8 +44,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu return nil, ResumeOutput{}, core.E("resume", "workspace is required", nil) } - wsDir := filepath.Join(WorkspaceRoot(), input.Workspace) - srcDir := filepath.Join(wsDir, "src") + wsDir := core.JoinPath(WorkspaceRoot(), input.Workspace) + srcDir := core.JoinPath(wsDir, "src") // Verify workspace exists if _, err := os.Stat(srcDir); err != nil { @@ -68,8 +70,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu // Write ANSWER.md if answer provided if input.Answer != "" { - answerPath := filepath.Join(srcDir, "ANSWER.md") - content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer) + answerPath := core.JoinPath(srcDir, "ANSWER.md") + content := core.Sprintf("# Answer\n\n%s\n", input.Answer) if r := fs.Write(answerPath, content); !r.OK { err, _ := r.Value.(error) return nil, ResumeOutput{}, core.E("resume", "failed to write ANSWER.md", err) @@ -110,6 +112,6 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu Workspace: input.Workspace, Agent: agent, PID: pid, - OutputFile: filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", agent)), + OutputFile: core.JoinPath(wsDir, core.Sprintf("agent-%s.log", agent)), }, nil } diff --git a/pkg/agentic/review_queue.go b/pkg/agentic/review_queue.go index 1eae089..7e47232 100644 --- a/pkg/agentic/review_queue.go +++ b/pkg/agentic/review_queue.go @@ -5,13 +5,9 @@ package agentic import ( "context" "encoding/json" - "fmt" "os" "os/exec" - "path/filepath" "regexp" - "strconv" - "strings" "time" core "dappco.re/go/core" @@ -21,14 +17,18 @@ import ( // --- agentic_review_queue tool --- // ReviewQueueInput controls the review queue runner. +// +// input := agentic.ReviewQueueInput{Reviewer: "coderabbit", Limit: 4, DryRun: true} type ReviewQueueInput struct { - Limit int `json:"limit,omitempty"` // Max PRs to process this run (default: 4) - Reviewer string `json:"reviewer,omitempty"` // "coderabbit" (default), "codex", or "both" - DryRun bool `json:"dry_run,omitempty"` // Preview without acting - LocalOnly bool `json:"local_only,omitempty"` // Run review locally, don't touch GitHub + Limit int `json:"limit,omitempty"` // Max PRs to process this run (default: 4) + Reviewer string `json:"reviewer,omitempty"` // "coderabbit" (default), "codex", or "both" + DryRun bool `json:"dry_run,omitempty"` // Preview without acting + LocalOnly bool `json:"local_only,omitempty"` // Run review locally, don't touch GitHub } // ReviewQueueOutput reports what happened. +// +// out := agentic.ReviewQueueOutput{Success: true, Processed: []agentic.ReviewResult{{Repo: "go-io", Verdict: "clean"}}} type ReviewQueueOutput struct { Success bool `json:"success"` Processed []ReviewResult `json:"processed"` @@ -37,6 +37,8 @@ type ReviewQueueOutput struct { } // ReviewResult is the outcome of reviewing one repo. +// +// result := agentic.ReviewResult{Repo: "go-io", Verdict: "findings", Findings: 3, Action: "fix_dispatched"} type ReviewResult struct { Repo string `json:"repo"` Verdict string `json:"verdict"` // clean, findings, rate_limited, error @@ -46,10 +48,12 @@ type ReviewResult struct { } // RateLimitInfo tracks CodeRabbit rate limit state. +// +// limit := agentic.RateLimitInfo{Limited: true, Message: "retry after 2026-03-22T06:00:00Z"} type RateLimitInfo struct { - Limited bool `json:"limited"` - RetryAt time.Time `json:"retry_at,omitempty"` - Message string `json:"message,omitempty"` + Limited bool `json:"limited"` + RetryAt time.Time `json:"retry_at,omitempty"` + Message string `json:"message,omitempty"` } func (s *PrepSubsystem) registerReviewQueueTool(server *mcp.Server) { @@ -65,7 +69,7 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, limit = 4 } - basePath := filepath.Join(s.codePath, "core") + basePath := core.JoinPath(s.codePath, "core") // Find repos with draft PRs (ahead of GitHub) candidates := s.findReviewCandidates(basePath) @@ -92,7 +96,7 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest, continue } - repoDir := filepath.Join(basePath, repo) + repoDir := core.JoinPath(basePath, repo) reviewer := input.Reviewer if reviewer == "" { reviewer = "coderabbit" @@ -140,7 +144,7 @@ func (s *PrepSubsystem) findReviewCandidates(basePath string) []string { if !e.IsDir() { continue } - repoDir := filepath.Join(basePath, e.Name()) + repoDir := core.JoinPath(basePath, e.Name()) if !hasRemote(repoDir, "github") { continue } @@ -159,7 +163,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer // Check saved rate limit if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) { result.Verdict = "rate_limited" - result.Detail = fmt.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339)) + result.Detail = core.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339)) return result } @@ -172,14 +176,14 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer output := string(out) // Parse rate limit (both reviewers use similar patterns) - if strings.Contains(output, "Rate limit exceeded") || strings.Contains(output, "rate limit") { + if core.Contains(output, "Rate limit exceeded") || core.Contains(output, "rate limit") { result.Verdict = "rate_limited" result.Detail = output return result } // Parse error - if err != nil && !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") { + if err != nil && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { result.Verdict = "error" result.Detail = output return result @@ -189,7 +193,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer s.storeReviewOutput(repoDir, repo, reviewer, output) // Parse verdict - if strings.Contains(output, "No findings") || strings.Contains(output, "no issues") || strings.Contains(output, "LGTM") { + if core.Contains(output, "No findings") || core.Contains(output, "no issues") || core.Contains(output, "LGTM") { result.Verdict = "clean" result.Findings = 0 @@ -221,11 +225,11 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer } // Save findings for agent dispatch - findingsFile := filepath.Join(repoDir, ".core", "coderabbit-findings.txt") + findingsFile := core.JoinPath(repoDir, ".core", "coderabbit-findings.txt") fs.Write(findingsFile, output) // Dispatch fix agent with the findings - task := fmt.Sprintf("Fix CodeRabbit findings. The review output is in .core/coderabbit-findings.txt. "+ + task := core.Sprintf("Fix CodeRabbit findings. The review output is in .core/coderabbit-findings.txt. "+ "Read it, verify each finding against the code, fix what's valid. Run tests. "+ "Commit: fix(coderabbit): address review findings\n\nFindings summary (%d issues):\n%s", result.Findings, truncate(output, 1500)) @@ -287,15 +291,15 @@ func (s *PrepSubsystem) dispatchFixFromQueue(ctx context.Context, repo, task str func countFindings(output string) int { // Count lines that look like findings count := 0 - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || - strings.Contains(trimmed, "Issue:") || strings.Contains(trimmed, "Finding:") || - strings.Contains(trimmed, "⚠") || strings.Contains(trimmed, "❌") { + for _, line := range core.Split(output, "\n") { + trimmed := core.Trim(line) + if core.HasPrefix(trimmed, "- ") || core.HasPrefix(trimmed, "* ") || + core.Contains(trimmed, "Issue:") || core.Contains(trimmed, "Finding:") || + core.Contains(trimmed, "⚠") || core.Contains(trimmed, "❌") { count++ } } - if count == 0 && !strings.Contains(output, "No findings") { + if count == 0 && !core.Contains(output, "No findings") { count = 1 // At least one finding if not clean } return count @@ -307,10 +311,10 @@ func parseRetryAfter(message string) time.Duration { re := regexp.MustCompile(`(\d+)\s*minutes?\s*(?:and\s*)?(\d+)?\s*seconds?`) matches := re.FindStringSubmatch(message) if len(matches) >= 2 { - mins, _ := strconv.Atoi(matches[1]) + mins := parseInt(matches[1]) secs := 0 if len(matches) >= 3 && matches[2] != "" { - secs, _ = strconv.Atoi(matches[2]) + secs = parseInt(matches[2]) } return time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second } @@ -334,14 +338,14 @@ func (s *PrepSubsystem) buildReviewCommand(ctx context.Context, repoDir, reviewe // storeReviewOutput saves raw review output for training data collection. func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) { home, _ := os.UserHomeDir() - dataDir := filepath.Join(home, ".core", "training", "reviews") + dataDir := core.JoinPath(home, ".core", "training", "reviews") fs.EnsureDir(dataDir) timestamp := time.Now().Format("2006-01-02T15-04-05") - filename := fmt.Sprintf("%s_%s_%s.txt", repo, reviewer, timestamp) + filename := core.Sprintf("%s_%s_%s.txt", repo, reviewer, timestamp) // Write raw output - fs.Write(filepath.Join(dataDir, filename), output) + fs.Write(core.JoinPath(dataDir, filename), output) // Append to JSONL for structured training entry := map[string]string{ @@ -351,12 +355,12 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string "output": output, "verdict": "clean", } - if !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") { + if !core.Contains(output, "No findings") && !core.Contains(output, "no issues") { entry["verdict"] = "findings" } jsonLine, _ := json.Marshal(entry) - jsonlPath := filepath.Join(dataDir, "reviews.jsonl") + jsonlPath := core.JoinPath(dataDir, "reviews.jsonl") f, err := os.OpenFile(jsonlPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err == nil { defer f.Close() @@ -367,7 +371,7 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string // saveRateLimitState persists rate limit info for cross-run awareness. func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { home, _ := os.UserHomeDir() - path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") + path := core.JoinPath(home, ".core", "coderabbit-ratelimit.json") data, _ := json.Marshal(info) fs.Write(path, string(data)) } @@ -375,7 +379,7 @@ func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { // loadRateLimitState reads persisted rate limit info. func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { home, _ := os.UserHomeDir() - path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") + path := core.JoinPath(home, ".core", "coderabbit-ratelimit.json") r := fs.Read(path) if !r.OK { return nil diff --git a/pkg/agentic/scan.go b/pkg/agentic/scan.go index cd7450f..107e0b6 100644 --- a/pkg/agentic/scan.go +++ b/pkg/agentic/scan.go @@ -5,7 +5,6 @@ package agentic import ( "context" "encoding/json" - "fmt" "net/http" "strings" @@ -14,6 +13,8 @@ import ( ) // ScanInput is the input for agentic_scan. +// +// input := agentic.ScanInput{Org: "core", Labels: []string{"agentic", "bug"}, Limit: 20} type ScanInput struct { Org string `json:"org,omitempty"` // default "core" Labels []string `json:"labels,omitempty"` // filter by labels (default: agentic, help-wanted, bug) @@ -21,6 +22,8 @@ type ScanInput struct { } // ScanOutput is the output for agentic_scan. +// +// out := agentic.ScanOutput{Success: true, Count: 1, Issues: []agentic.ScanIssue{{Repo: "go-io", Number: 12}}} type ScanOutput struct { Success bool `json:"success"` Count int `json:"count"` @@ -28,6 +31,8 @@ type ScanOutput struct { } // ScanIssue is a single actionable issue. +// +// issue := agentic.ScanIssue{Repo: "go-io", Number: 12, Title: "Replace fmt.Errorf"} type ScanIssue struct { Repo string `json:"repo"` Number int `json:"number"` @@ -81,7 +86,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input seen := make(map[string]bool) var unique []ScanIssue for _, issue := range allIssues { - key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number) + key := core.Sprintf("%s#%d", issue.Repo, issue.Number) if !seen[key] { seen[key] = true unique = append(unique, issue) @@ -104,7 +109,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, page := 1 for { - u := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50&page=%d", s.forgeURL, org, page) + u := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50&page=%d", s.forgeURL, org, page) req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, core.E("scan.listOrgRepos", "failed to create request", err) @@ -118,8 +123,8 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, if resp.StatusCode != 200 { resp.Body.Close() - return nil, core.E("scan.listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) - } + return nil, core.E("scan.listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) + } var repos []struct { Name string `json:"name"` @@ -141,10 +146,10 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, } func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) { - u := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&limit=10&type=issues", + u := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&limit=10&type=issues", s.forgeURL, org, repo) if label != "" { - u += "&labels=" + strings.ReplaceAll(strings.ReplaceAll(label, " ", "%20"), "&", "%26") + u += "&labels=" + core.Replace(core.Replace(label, " ", "%20"), "&", "%26") } req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { @@ -159,7 +164,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, core.E("scan.listRepoIssues", fmt.Sprintf("HTTP %d listing issues for %s", resp.StatusCode, repo), nil) + return nil, core.E("scan.listRepoIssues", core.Sprintf("HTTP %d listing issues for %s", resp.StatusCode, repo), nil) } var issues []struct { diff --git a/pkg/agentic/status.go b/pkg/agentic/status.go index a575d4f..7ea8fbf 100644 --- a/pkg/agentic/status.go +++ b/pkg/agentic/status.go @@ -5,10 +5,8 @@ package agentic import ( "context" "encoding/json" - "fmt" "os" "path/filepath" - "strings" "syscall" "time" @@ -34,19 +32,19 @@ import ( // st, err := readStatus(wsDir) // if err == nil && st.Status == "completed" { autoCreatePR(wsDir) } type WorkspaceStatus struct { - Status string `json:"status"` // running, completed, blocked, failed - Agent string `json:"agent"` // gemini, claude, codex - Repo string `json:"repo"` // target repo - Org string `json:"org,omitempty"` // forge org (e.g. "core") - Task string `json:"task"` // task description - Branch string `json:"branch,omitempty"` // git branch name - Issue int `json:"issue,omitempty"` // forge issue number - PID int `json:"pid,omitempty"` // process ID (if running) - StartedAt time.Time `json:"started_at"` // when dispatch started - UpdatedAt time.Time `json:"updated_at"` // last status change - Question string `json:"question,omitempty"` // from BLOCKED.md - Runs int `json:"runs"` // how many times dispatched/resumed - PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created) + Status string `json:"status"` // running, completed, blocked, failed + Agent string `json:"agent"` // gemini, claude, codex + Repo string `json:"repo"` // target repo + Org string `json:"org,omitempty"` // forge org (e.g. "core") + Task string `json:"task"` // task description + Branch string `json:"branch,omitempty"` // git branch name + Issue int `json:"issue,omitempty"` // forge issue number + PID int `json:"pid,omitempty"` // process ID (if running) + StartedAt time.Time `json:"started_at"` // when dispatch started + UpdatedAt time.Time `json:"updated_at"` // last status change + Question string `json:"question,omitempty"` // from BLOCKED.md + Runs int `json:"runs"` // how many times dispatched/resumed + PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created) } func writeStatus(wsDir string, status *WorkspaceStatus) error { @@ -55,7 +53,7 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error { if err != nil { return err } - if r := fs.Write(filepath.Join(wsDir, "status.json"), string(data)); !r.OK { + if r := fs.Write(core.JoinPath(wsDir, "status.json"), string(data)); !r.OK { err, _ := r.Value.(error) return core.E("writeStatus", "failed to write status", err) } @@ -63,7 +61,7 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error { } func readStatus(wsDir string) (*WorkspaceStatus, error) { - r := fs.Read(filepath.Join(wsDir, "status.json")) + r := fs.Read(core.JoinPath(wsDir, "status.json")) if !r.OK { return nil, core.E("readStatus", "status not found", nil) } @@ -76,24 +74,33 @@ func readStatus(wsDir string) (*WorkspaceStatus, error) { // --- agentic_status tool --- +// StatusInput is the input for agentic_status. +// +// input := agentic.StatusInput{Workspace: "go-io-123"} type StatusInput struct { Workspace string `json:"workspace,omitempty"` // specific workspace name, or empty for all } +// StatusOutput is the output for agentic_status. +// +// out := agentic.StatusOutput{Count: 1, Workspaces: []agentic.WorkspaceInfo{{Name: "go-io-123"}}} type StatusOutput struct { Workspaces []WorkspaceInfo `json:"workspaces"` Count int `json:"count"` } +// WorkspaceInfo summarises one workspace returned by agentic_status. +// +// info := agentic.WorkspaceInfo{Name: "go-io-123", Status: "running", Agent: "codex", Repo: "go-io"} type WorkspaceInfo struct { - Name string `json:"name"` - Status string `json:"status"` - Agent string `json:"agent"` - Repo string `json:"repo"` - Task string `json:"task"` - Age string `json:"age"` - Question string `json:"question,omitempty"` - Runs int `json:"runs"` + Name string `json:"name"` + Status string `json:"status"` + Agent string `json:"agent"` + Repo string `json:"repo"` + Task string `json:"task"` + Age string `json:"age"` + Question string `json:"question,omitempty"` + Runs int `json:"runs"` } func (s *PrepSubsystem) registerStatusTool(server *mcp.Server) { @@ -125,14 +132,14 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu continue } - wsDir := filepath.Join(wsRoot, name) + wsDir := core.JoinPath(wsRoot, name) info := WorkspaceInfo{Name: name} // Try reading status.json st, err := readStatus(wsDir) if err != nil { // Legacy workspace (no status.json) — check for log file - logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) + logFiles, _ := filepath.Glob(core.JoinPath(wsDir, "agent-*.log")) if len(logFiles) > 0 { info.Status = "completed" } else { @@ -157,16 +164,16 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu if st.Status == "running" && st.PID > 0 { if err := syscall.Kill(st.PID, 0); err != nil { // Process died — check for BLOCKED.md - blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md") + blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md") if r := fs.Read(blockedPath); r.OK { info.Status = "blocked" - info.Question = strings.TrimSpace(r.Value.(string)) + info.Question = core.Trim(r.Value.(string)) st.Status = "blocked" st.Question = info.Question } else { // Dead PID without BLOCKED.md — check exit code from log // If no evidence of success, mark as failed (not completed) - logFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent)) + logFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", st.Agent)) if r := fs.Read(logFile); !r.OK { info.Status = "failed" st.Status = "failed" diff --git a/pkg/agentic/status_test.go b/pkg/agentic/status_test.go index 0a85ca7..9130529 100644 --- a/pkg/agentic/status_test.go +++ b/pkg/agentic/status_test.go @@ -4,11 +4,11 @@ package agentic import ( "encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "path/filepath" "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestWriteStatus_Good(t *testing.T) { diff --git a/pkg/agentic/verify.go b/pkg/agentic/verify.go index 615b296..05890c2 100644 --- a/pkg/agentic/verify.go +++ b/pkg/agentic/verify.go @@ -6,12 +6,10 @@ import ( "bytes" "context" "encoding/json" - "fmt" "net/http" "os" "os/exec" "path/filepath" - "strings" "time" core "dappco.re/go/core" @@ -30,7 +28,7 @@ func (s *PrepSubsystem) autoVerifyAndMerge(wsDir string) { return } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.JoinPath(wsDir, "src") org := st.Org if org == "" { org = "core" @@ -88,7 +86,7 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(srcDir, org, repo, branch string, testResult := s.runVerification(srcDir) if !testResult.passed { - comment := fmt.Sprintf("## Verification Failed\n\n**Command:** `%s`\n\n```\n%s\n```\n\n**Exit code:** %d", + comment := core.Sprintf("## Verification Failed\n\n**Command:** `%s`\n\n```\n%s\n```\n\n**Exit code:** %d", testResult.testCmd, truncate(testResult.output, 2000), testResult.exitCode) s.commentOnIssue(context.Background(), org, repo, prNum, comment) return testFailed @@ -99,12 +97,12 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(srcDir, org, repo, branch string, defer cancel() if err := s.forgeMergePR(ctx, org, repo, prNum); err != nil { - comment := fmt.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed: %v", testResult.testCmd, err) + comment := core.Sprintf("## Tests Passed — Merge Failed\n\n`%s` passed but merge failed: %v", testResult.testCmd, err) s.commentOnIssue(context.Background(), org, repo, prNum, comment) return mergeConflict } - comment := fmt.Sprintf("## Auto-Verified & Merged\n\n**Tests:** `%s` — PASS\n\nAuto-merged by core-agent dispatch system.", testResult.testCmd) + comment := core.Sprintf("## Auto-Verified & Merged\n\n**Tests:** `%s` — PASS\n\nAuto-merged by core-agent dispatch system.", testResult.testCmd) s.commentOnIssue(context.Background(), org, repo, prNum, comment) return mergeSuccess } @@ -141,7 +139,7 @@ func (s *PrepSubsystem) rebaseBranch(srcDir, branch string) bool { } repo = st.Repo } - forgeRemote := fmt.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo) + forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo) push := exec.Command("git", "push", "--force-with-lease", forgeRemote, branch) push.Dir = srcDir return push.Run() == nil @@ -159,7 +157,7 @@ func (s *PrepSubsystem) flagForReview(org, repo string, prNum int, result mergeR payload, _ := json.Marshal(map[string]any{ "labels": []int{s.getLabelID(ctx, org, repo, "needs-review")}, }) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", s.forgeURL, org, repo, prNum) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", s.forgeURL, org, repo, prNum) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -173,7 +171,7 @@ func (s *PrepSubsystem) flagForReview(org, repo string, prNum int, result mergeR if result == mergeConflict { reason = "Merge conflict persists after rebase" } - comment := fmt.Sprintf("## Needs Review\n\n%s. Auto-merge gave up after retry.\n\nLabelled `needs-review` for human attention.", reason) + comment := core.Sprintf("## Needs Review\n\n%s. Auto-merge gave up after retry.\n\nLabelled `needs-review` for human attention.", reason) s.commentOnIssue(ctx, org, repo, prNum, comment) } @@ -183,7 +181,7 @@ func (s *PrepSubsystem) ensureLabel(ctx context.Context, org, repo, name, colour "name": name, "color": "#" + colour, }) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -195,7 +193,7 @@ func (s *PrepSubsystem) ensureLabel(ctx context.Context, org, repo, name, colour // getLabelID fetches the ID of a label by name. func (s *PrepSubsystem) getLabelID(ctx context.Context, org, repo, name string) int { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) @@ -227,13 +225,13 @@ type verifyResult struct { // runVerification detects the project type and runs the appropriate test suite. func (s *PrepSubsystem) runVerification(srcDir string) verifyResult { - if fileExists(filepath.Join(srcDir, "go.mod")) { + if fileExists(core.JoinPath(srcDir, "go.mod")) { return s.runGoTests(srcDir) } - if fileExists(filepath.Join(srcDir, "composer.json")) { + if fileExists(core.JoinPath(srcDir, "composer.json")) { return s.runPHPTests(srcDir) } - if fileExists(filepath.Join(srcDir, "package.json")) { + if fileExists(core.JoinPath(srcDir, "package.json")) { return s.runNodeTests(srcDir) } return verifyResult{passed: true, testCmd: "none", output: "No test runner detected"} @@ -281,7 +279,7 @@ func (s *PrepSubsystem) runPHPTests(srcDir string) verifyResult { } func (s *PrepSubsystem) runNodeTests(srcDir string) verifyResult { - r := fs.Read(filepath.Join(srcDir, "package.json")) + r := fs.Read(core.JoinPath(srcDir, "package.json")) if !r.OK { return verifyResult{passed: true, testCmd: "none", output: "Could not read package.json"} } @@ -317,7 +315,7 @@ func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNu "delete_branch_after_merge": true, }) - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", s.forgeURL, org, repo, prNum) + url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/merge", s.forgeURL, org, repo, prNum) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "token "+s.forgeToken) @@ -332,7 +330,7 @@ func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNu var errBody map[string]any json.NewDecoder(resp.Body).Decode(&errBody) msg, _ := errBody["message"].(string) - return core.E("forgeMergePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) + return core.E("forgeMergePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) } return nil @@ -340,13 +338,11 @@ func (s *PrepSubsystem) forgeMergePR(ctx context.Context, org, repo string, prNu // extractPRNumber gets the PR number from a Forge PR URL. func extractPRNumber(prURL string) int { - parts := strings.Split(prURL, "/") + parts := core.Split(prURL, "/") if len(parts) == 0 { return 0 } - var num int - fmt.Sscanf(parts[len(parts)-1], "%d", &num) - return num + return parseInt(parts[len(parts)-1]) } // fileExists checks if a file exists. diff --git a/pkg/agentic/watch.go b/pkg/agentic/watch.go index 1b3952a..f1c9a01 100644 --- a/pkg/agentic/watch.go +++ b/pkg/agentic/watch.go @@ -4,7 +4,6 @@ package agentic import ( "context" - "fmt" "path/filepath" "time" @@ -13,6 +12,8 @@ import ( ) // WatchInput is the input for agentic_watch. +// +// input := agentic.WatchInput{Workspaces: []string{"go-io-123"}, PollInterval: 5, Timeout: 600} type WatchInput struct { // Workspaces to watch. If empty, watches all running/queued workspaces. Workspaces []string `json:"workspaces,omitempty"` @@ -23,6 +24,8 @@ type WatchInput struct { } // WatchOutput is the result when all watched workspaces complete. +// +// out := agentic.WatchOutput{Success: true, Completed: []agentic.WatchResult{{Workspace: "go-io-123", Status: "completed"}}} type WatchOutput struct { Success bool `json:"success"` Completed []WatchResult `json:"completed"` @@ -31,6 +34,8 @@ type WatchOutput struct { } // WatchResult describes one completed workspace. +// +// result := agentic.WatchResult{Workspace: "go-io-123", Agent: "codex", Repo: "go-io", Status: "completed"} type WatchResult struct { Workspace string `json:"workspace"` Agent string `json:"agent"` @@ -128,7 +133,7 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp ProgressToken: progressToken, Progress: progressCount, Total: total, - Message: fmt.Sprintf("%s completed (%s)", st.Repo, st.Agent), + Message: core.Sprintf("%s completed (%s)", st.Repo, st.Agent), }) } @@ -149,7 +154,7 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp ProgressToken: progressToken, Progress: progressCount, Total: total, - Message: fmt.Sprintf("%s %s (%s)", st.Repo, st.Status, st.Agent), + Message: core.Sprintf("%s %s (%s)", st.Repo, st.Status, st.Agent), }) } @@ -169,7 +174,7 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp ProgressToken: progressToken, Progress: progressCount, Total: total, - Message: fmt.Sprintf("%s %s (%s)", st.Repo, st.Status, st.Agent), + Message: core.Sprintf("%s %s (%s)", st.Repo, st.Status, st.Agent), }) } } @@ -187,7 +192,7 @@ func (s *PrepSubsystem) watch(ctx context.Context, req *mcp.CallToolRequest, inp // findActiveWorkspaces returns workspace names that are running or queued. func (s *PrepSubsystem) findActiveWorkspaces() []string { wsRoot := WorkspaceRoot() - entries, err := filepath.Glob(filepath.Join(wsRoot, "*/status.json")) + entries, err := filepath.Glob(core.JoinPath(wsRoot, "*/status.json")) if err != nil { return nil } @@ -211,5 +216,5 @@ func (s *PrepSubsystem) resolveWorkspaceDir(name string) string { if filepath.IsAbs(name) { return name } - return filepath.Join(WorkspaceRoot(), name) + return core.JoinPath(WorkspaceRoot(), name) } diff --git a/pkg/brain/brain.go b/pkg/brain/brain.go index 5ef8608..5e04998 100644 --- a/pkg/brain/brain.go +++ b/pkg/brain/brain.go @@ -7,36 +7,60 @@ package brain import ( "context" + "dappco.re/go/agent/pkg/agentic" core "dappco.re/go/core" "forge.lthn.ai/core/mcp/pkg/mcp/ide" "github.com/modelcontextprotocol/go-sdk/mcp" ) +// fs provides unrestricted filesystem access for shared brain credentials. +// +// keyPath := core.Concat(home, "/.claude/brain.key") +// if r := fs.Read(keyPath); r.OK { +// apiKey = core.Trim(r.Value.(string)) +// } +var fs = agentic.LocalFs() + +func fieldString(values map[string]any, key string) string { + return core.Sprint(values[key]) +} + // errBridgeNotAvailable is returned when a tool requires the Laravel bridge // but it has not been initialised (headless mode). var errBridgeNotAvailable = core.E("brain", "bridge not available", nil) -// Subsystem implements mcp.Subsystem for OpenBrain knowledge store operations. -// It proxies brain_* tool calls to the Laravel backend via the shared IDE bridge. +// Subsystem proxies brain_* MCP tools through the shared IDE bridge. +// +// sub := brain.New(bridge) +// sub.RegisterTools(server) type Subsystem struct { bridge *ide.Bridge } -// New creates a brain subsystem that uses the given IDE bridge for Laravel communication. -// Pass nil if headless (tools will return errBridgeNotAvailable). +// New creates a bridge-backed brain subsystem. +// +// sub := brain.New(bridge) +// _ = sub.Shutdown(context.Background()) func New(bridge *ide.Bridge) *Subsystem { return &Subsystem{bridge: bridge} } -// Name implements mcp.Subsystem. +// Name returns the MCP subsystem name. +// +// name := sub.Name() // "brain" func (s *Subsystem) Name() string { return "brain" } -// RegisterTools implements mcp.Subsystem. +// RegisterTools adds the bridge-backed brain tools to an MCP server. +// +// sub := brain.New(bridge) +// sub.RegisterTools(server) func (s *Subsystem) RegisterTools(server *mcp.Server) { s.registerBrainTools(server) } -// Shutdown implements mcp.SubsystemWithShutdown. +// Shutdown closes the subsystem without additional cleanup. +// +// _ = sub.Shutdown(context.Background()) func (s *Subsystem) Shutdown(_ context.Context) error { return nil } diff --git a/pkg/brain/bridge_test.go b/pkg/brain/bridge_test.go index 6e8d3aa..9c46fb4 100644 --- a/pkg/brain/bridge_test.go +++ b/pkg/brain/bridge_test.go @@ -11,7 +11,8 @@ import ( "testing" "time" - ws "dappco.re/go/core/ws" + providerws "dappco.re/go/core/ws" + bridgews "forge.lthn.ai/core/go-ws" "forge.lthn.ai/core/mcp/pkg/mcp/ide" "github.com/gorilla/websocket" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -45,7 +46,7 @@ func testBridge(t *testing.T) *ide.Bridge { srv := testWSServer(t) wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - hub := ws.NewHub() + hub := bridgews.NewHub() bridge := ide.NewBridge(hub, ide.Config{ LaravelWSURL: wsURL, ReconnectInterval: 100 * time.Millisecond, @@ -193,7 +194,7 @@ func TestStatusHandler_Good_WithBridge(t *testing.T) { // --- emitEvent with hub --- func TestEmitEvent_Good_WithHub(t *testing.T) { - hub := ws.NewHub() + hub := providerws.NewHub() p := NewProvider(nil, hub) p.emitEvent("brain.test", map[string]any{"key": "value"}) } diff --git a/pkg/brain/direct.go b/pkg/brain/direct.go index 54ac75e..fc3cd8f 100644 --- a/pkg/brain/direct.go +++ b/pkg/brain/direct.go @@ -6,25 +6,16 @@ import ( "bytes" "context" "encoding/json" - "fmt" - "io" "net/http" "os" - "path/filepath" - "strings" "time" - core "dappco.re/go/core" "dappco.re/go/agent/pkg/agentic" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) -// fs provides unrestricted filesystem access for the brain package. -var fs = agentic.LocalFs() - -// DirectSubsystem implements mcp.Subsystem for OpenBrain via direct HTTP calls. -// Unlike Subsystem (which uses the IDE WebSocket bridge), this calls the -// Laravel API directly — suitable for standalone core-mcp usage. +// DirectSubsystem calls the OpenBrain HTTP API without the IDE bridge. // // sub := brain.NewDirect() // sub.RegisterTools(server) @@ -34,9 +25,10 @@ type DirectSubsystem struct { client *http.Client } -// NewDirect creates a brain subsystem that calls the OpenBrain API directly. -// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back -// to ~/.claude/brain.key for the API key. +// NewDirect creates a direct HTTP brain subsystem. +// +// sub := brain.NewDirect() +// sub.RegisterTools(server) func NewDirect() *DirectSubsystem { apiURL := os.Getenv("CORE_BRAIN_URL") if apiURL == "" { @@ -44,12 +36,22 @@ func NewDirect() *DirectSubsystem { } apiKey := os.Getenv("CORE_BRAIN_KEY") + keyPath := "" if apiKey == "" { home, _ := os.UserHomeDir() - if r := fs.Read(filepath.Join(home, ".claude", "brain.key")); r.OK { - apiKey = strings.TrimSpace(r.Value.(string)) + keyPath = brainKeyPath(home) + if keyPath != "" { + if r := fs.Read(keyPath); r.OK { + apiKey = core.Trim(r.Value.(string)) + if apiKey != "" { + core.Info("brain direct subsystem loaded API key from file", "path", keyPath) + } + } } } + if apiKey == "" { + core.Warn("brain direct subsystem has no API key configured", "path", keyPath) + } return &DirectSubsystem{ apiURL: apiURL, @@ -58,10 +60,15 @@ func NewDirect() *DirectSubsystem { } } -// Name implements mcp.Subsystem. +// Name returns the MCP subsystem name. +// +// name := sub.Name() // "brain" func (s *DirectSubsystem) Name() string { return "brain" } -// RegisterTools implements mcp.Subsystem. +// RegisterTools adds the direct OpenBrain tools to an MCP server. +// +// sub := brain.NewDirect() +// sub.RegisterTools(server) func (s *DirectSubsystem) RegisterTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "brain_remember", @@ -82,48 +89,68 @@ func (s *DirectSubsystem) RegisterTools(server *mcp.Server) { s.RegisterMessagingTools(server) } -// Shutdown implements mcp.SubsystemWithShutdown. +// Shutdown closes the direct subsystem without additional cleanup. +// +// _ = sub.Shutdown(context.Background()) func (s *DirectSubsystem) Shutdown(_ context.Context) error { return nil } +func brainKeyPath(home string) string { + if home == "" { + return "" + } + return core.JoinPath(core.TrimSuffix(home, "/"), ".claude", "brain.key") +} + func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) { if s.apiKey == "" { return nil, core.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil) } - var reqBody io.Reader + var reqBody *bytes.Reader if body != nil { data, err := json.Marshal(body) if err != nil { + core.Error("brain API request marshal failed", "method", method, "path", path, "err", err) return nil, core.E("brain.apiCall", "marshal request", err) } reqBody = bytes.NewReader(data) } - req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody) + requestURL := core.Concat(s.apiURL, path) + req, err := http.NewRequestWithContext(ctx, method, requestURL, nil) + if reqBody != nil { + req, err = http.NewRequestWithContext(ctx, method, requestURL, reqBody) + } if err != nil { + core.Error("brain API request creation failed", "method", method, "path", path, "err", err) return nil, core.E("brain.apiCall", "create request", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Authorization", core.Concat("Bearer ", s.apiKey)) resp, err := s.client.Do(req) if err != nil { + core.Error("brain API call failed", "method", method, "path", path, "err", err) return nil, core.E("brain.apiCall", "API call failed", err) } defer resp.Body.Close() - respData, err := io.ReadAll(resp.Body) - if err != nil { + respBuffer := bytes.NewBuffer(nil) + if _, err := respBuffer.ReadFrom(resp.Body); err != nil { + core.Error("brain API response read failed", "method", method, "path", path, "err", err) return nil, core.E("brain.apiCall", "read response", err) } + respData := respBuffer.Bytes() if resp.StatusCode >= 400 { - return nil, core.E("brain.apiCall", fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(respData)), nil) + core.Warn("brain API returned error status", "method", method, "path", path, "status", resp.StatusCode) + return nil, core.E("brain.apiCall", core.Sprintf("API returned %d: %s", resp.StatusCode, string(respData)), nil) } var result map[string]any if err := json.Unmarshal(respData, &result); err != nil { + core.Error("brain API response parse failed", "method", method, "path", path, "err", err) return nil, core.E("brain.apiCall", "parse response", err) } @@ -132,11 +159,14 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body func (s *DirectSubsystem) remember(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) { result, err := s.apiCall(ctx, "POST", "/v1/brain/remember", map[string]any{ - "content": input.Content, - "type": input.Type, - "tags": input.Tags, - "project": input.Project, - "agent_id": agentic.AgentName(), + "content": input.Content, + "type": input.Type, + "tags": input.Tags, + "project": input.Project, + "confidence": input.Confidence, + "supersedes": input.Supersedes, + "expires_in": input.ExpiresIn, + "agent_id": agentic.AgentName(), }) if err != nil { return nil, RememberOutput{}, err @@ -165,6 +195,9 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in if input.Filter.Type != nil { body["type"] = input.Filter.Type } + if input.Filter.MinConfidence != 0 { + body["min_confidence"] = input.Filter.MinConfidence + } if input.TopK == 0 { body["top_k"] = 10 } @@ -179,11 +212,11 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in for _, m := range mems { if mm, ok := m.(map[string]any); ok { mem := Memory{ - Content: fmt.Sprintf("%v", mm["content"]), - Type: fmt.Sprintf("%v", mm["type"]), - Project: fmt.Sprintf("%v", mm["project"]), - AgentID: fmt.Sprintf("%v", mm["agent_id"]), - CreatedAt: fmt.Sprintf("%v", mm["created_at"]), + Content: fieldString(mm, "content"), + Type: fieldString(mm, "type"), + Project: fieldString(mm, "project"), + AgentID: fieldString(mm, "agent_id"), + CreatedAt: fieldString(mm, "created_at"), } if id, ok := mm["id"].(string); ok { mem.ID = id @@ -191,8 +224,13 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in if score, ok := mm["score"].(float64); ok { mem.Confidence = score } + if tags, ok := mm["tags"].([]any); ok { + for _, tag := range tags { + mem.Tags = append(mem.Tags, core.Sprint(tag)) + } + } if source, ok := mm["source"].(string); ok { - mem.Tags = append(mem.Tags, "source:"+source) + mem.Tags = append(mem.Tags, core.Concat("source:", source)) } memories = append(memories, mem) } @@ -207,7 +245,7 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in } func (s *DirectSubsystem) forget(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) { - _, err := s.apiCall(ctx, "DELETE", "/v1/brain/forget/"+input.ID, nil) + _, err := s.apiCall(ctx, "DELETE", core.Concat("/v1/brain/forget/", input.ID), nil) if err != nil { return nil, ForgetOutput{}, err } diff --git a/pkg/brain/messaging.go b/pkg/brain/messaging.go index db7323e..eb8e5f7 100644 --- a/pkg/brain/messaging.go +++ b/pkg/brain/messaging.go @@ -4,15 +4,17 @@ package brain import ( "context" - "fmt" "net/url" - core "dappco.re/go/core" "dappco.re/go/agent/pkg/agentic" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) -// RegisterMessagingTools adds agent messaging tools to the MCP server. +// RegisterMessagingTools adds direct agent messaging tools to an MCP server. +// +// sub := brain.NewDirect() +// sub.RegisterMessagingTools(server) func (s *DirectSubsystem) RegisterMessagingTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "agent_send", @@ -32,22 +34,34 @@ func (s *DirectSubsystem) RegisterMessagingTools(server *mcp.Server) { // Input/Output types +// SendInput sends a direct message to another agent. +// +// brain.SendInput{To: "charon", Subject: "status update", Content: "deploy complete"} type SendInput struct { To string `json:"to"` Content string `json:"content"` Subject string `json:"subject,omitempty"` } +// SendOutput reports the created direct message. +// +// brain.SendOutput{Success: true, ID: 42, To: "charon"} type SendOutput struct { Success bool `json:"success"` ID int `json:"id"` To string `json:"to"` } +// InboxInput selects which agent inbox to read. +// +// brain.InboxInput{Agent: "cladius"} type InboxInput struct { Agent string `json:"agent,omitempty"` } +// MessageItem is one inbox or conversation message. +// +// brain.MessageItem{ID: 7, From: "cladius", To: "charon", Content: "all green"} type MessageItem struct { ID int `json:"id"` From string `json:"from"` @@ -58,15 +72,24 @@ type MessageItem struct { CreatedAt string `json:"created_at"` } +// InboxOutput returns the latest direct messages for an agent. +// +// brain.InboxOutput{Success: true, Messages: []brain.MessageItem{{ID: 1, From: "charon", To: "cladius"}}} type InboxOutput struct { Success bool `json:"success"` Messages []MessageItem `json:"messages"` } +// ConversationInput selects the agent thread to load. +// +// brain.ConversationInput{Agent: "charon"} type ConversationInput struct { Agent string `json:"agent"` } +// ConversationOutput returns a direct message thread with another agent. +// +// brain.ConversationOutput{Success: true, Messages: []brain.MessageItem{{ID: 10, From: "cladius", To: "charon"}}} type ConversationOutput struct { Success bool `json:"success"` Messages []MessageItem `json:"messages"` @@ -138,12 +161,12 @@ func parseMessages(result map[string]any) []MessageItem { mm, _ := m.(map[string]any) messages = append(messages, MessageItem{ ID: toInt(mm["id"]), - From: fmt.Sprintf("%v", mm["from"]), - To: fmt.Sprintf("%v", mm["to"]), - Subject: fmt.Sprintf("%v", mm["subject"]), - Content: fmt.Sprintf("%v", mm["content"]), + From: fieldString(mm, "from"), + To: fieldString(mm, "to"), + Subject: fieldString(mm, "subject"), + Content: fieldString(mm, "content"), Read: mm["read"] == true, - CreatedAt: fmt.Sprintf("%v", mm["created_at"]), + CreatedAt: fieldString(mm, "created_at"), }) } return messages diff --git a/pkg/brain/provider.go b/pkg/brain/provider.go index 529811f..2b64a61 100644 --- a/pkg/brain/provider.go +++ b/pkg/brain/provider.go @@ -5,8 +5,8 @@ package brain import ( "net/http" - "forge.lthn.ai/core/api" - "forge.lthn.ai/core/api/pkg/provider" + "dappco.re/go/core/api" + "dappco.re/go/core/api/pkg/provider" "dappco.re/go/core/ws" "forge.lthn.ai/core/mcp/pkg/mcp/ide" "github.com/gin-gonic/gin" diff --git a/pkg/lib/lib.go b/pkg/lib/lib.go index dc12398..b2b6bcb 100644 --- a/pkg/lib/lib.go +++ b/pkg/lib/lib.go @@ -27,8 +27,9 @@ import ( "io/fs" "os" "path/filepath" - "strings" "text/template" + + core "dappco.re/go/core" ) //go:embed prompt/*.md @@ -160,8 +161,8 @@ func ExtractWorkspace(tmplName, targetDir string, data *WorkspaceData) error { // Process .tmpl files through text/template outputName := name - if strings.HasSuffix(name, ".tmpl") { - outputName = strings.TrimSuffix(name, ".tmpl") + if core.HasSuffix(name, ".tmpl") { + outputName = core.TrimSuffix(name, ".tmpl") tmpl, err := template.New(name).Parse(string(content)) if err != nil { return err @@ -193,9 +194,9 @@ func ListTasks() []string { if err != nil || d.IsDir() { return nil } - rel := strings.TrimPrefix(path, "task/") + rel := core.TrimPrefix(path, "task/") ext := filepath.Ext(rel) - slugs = append(slugs, strings.TrimSuffix(rel, ext)) + slugs = append(slugs, core.TrimSuffix(rel, ext)) return nil }) return slugs @@ -207,9 +208,9 @@ func ListPersonas() []string { if err != nil || d.IsDir() { return nil } - if strings.HasSuffix(path, ".md") { - rel := strings.TrimPrefix(path, "persona/") - rel = strings.TrimSuffix(rel, ".md") + if core.HasSuffix(path, ".md") { + rel := core.TrimPrefix(path, "persona/") + rel = core.TrimSuffix(rel, ".md") paths = append(paths, rel) } return nil @@ -224,14 +225,12 @@ func listDir(fsys embed.FS, dir string) []string { } var slugs []string for _, e := range entries { + name := e.Name() if e.IsDir() { - name := e.Name() slugs = append(slugs, name) continue } - name := e.Name() - ext := filepath.Ext(name) - slugs = append(slugs, strings.TrimSuffix(name, ext)) + slugs = append(slugs, core.TrimSuffix(name, filepath.Ext(name))) } return slugs } diff --git a/pkg/monitor/harvest.go b/pkg/monitor/harvest.go index 8f9617d..8166ba0 100644 --- a/pkg/monitor/harvest.go +++ b/pkg/monitor/harvest.go @@ -12,14 +12,13 @@ package monitor import ( "context" "encoding/json" - "fmt" "os" "os/exec" "path/filepath" - "strings" + "strconv" - core "dappco.re/go/core" "dappco.re/go/agent/pkg/agentic" + core "dappco.re/go/core" ) // harvestResult tracks what happened during harvest. @@ -34,7 +33,7 @@ type harvestResult struct { // branches back to the source repos. Returns a summary message. func (m *Subsystem) harvestCompleted() string { wsRoot := agentic.WorkspaceRoot() - entries, err := filepath.Glob(filepath.Join(wsRoot, "*/status.json")) + entries, err := filepath.Glob(workspaceStatusGlob(wsRoot)) if err != nil { return "" } @@ -56,7 +55,7 @@ func (m *Subsystem) harvestCompleted() string { var parts []string for _, h := range harvested { if h.rejected != "" { - parts = append(parts, fmt.Sprintf("%s: REJECTED (%s)", h.repo, h.rejected)) + parts = append(parts, core.Sprintf("%s: REJECTED (%s)", h.repo, h.rejected)) if m.notifier != nil { m.notifier.ChannelSend(context.Background(), "harvest.rejected", map[string]any{ "repo": h.repo, @@ -65,7 +64,7 @@ func (m *Subsystem) harvestCompleted() string { }) } } else { - parts = append(parts, fmt.Sprintf("%s: ready-for-review %s (%d files)", h.repo, h.branch, h.files)) + parts = append(parts, core.Sprintf("%s: ready-for-review %s (%d files)", h.repo, h.branch, h.files)) if m.notifier != nil { m.notifier.ChannelSend(context.Background(), "harvest.complete", map[string]any{ "repo": h.repo, @@ -75,22 +74,26 @@ func (m *Subsystem) harvestCompleted() string { } } } - return "Harvested: " + strings.Join(parts, ", ") + return core.Concat("Harvested: ", core.Join(", ", parts...)) } // harvestWorkspace checks a single workspace and pushes if ready. func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { - r := fs.Read(filepath.Join(wsDir, "status.json")) + r := fs.Read(workspaceStatusPath(wsDir)) if !r.OK { return nil } + statusData, ok := resultString(r) + if !ok { + return nil + } var st struct { Status string `json:"status"` Repo string `json:"repo"` Branch string `json:"branch"` } - if json.Unmarshal([]byte(r.Value.(string)), &st) != nil { + if json.Unmarshal([]byte(statusData), &st) != nil { return nil } @@ -99,7 +102,7 @@ func (m *Subsystem) harvestWorkspace(wsDir string) *harvestResult { return nil } - srcDir := filepath.Join(wsDir, "src") + srcDir := core.Concat(wsDir, "/src") if _, err := os.Stat(srcDir); err != nil { return nil } @@ -145,7 +148,7 @@ func detectBranch(srcDir string) string { if err != nil { return "" } - return strings.TrimSpace(string(out)) + return core.Trim(string(out)) } // defaultBranch detects the default branch of the repo (main, master, etc.). @@ -154,10 +157,10 @@ func defaultBranch(srcDir string) string { cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short") cmd.Dir = srcDir if out, err := cmd.Output(); err == nil { - ref := strings.TrimSpace(string(out)) + ref := core.Trim(string(out)) // returns "origin/main" — strip prefix - if strings.HasPrefix(ref, "origin/") { - return strings.TrimPrefix(ref, "origin/") + if core.HasPrefix(ref, "origin/") { + return core.TrimPrefix(ref, "origin/") } return ref } @@ -175,24 +178,26 @@ func defaultBranch(srcDir string) string { // countUnpushed returns the number of commits ahead of origin's default branch. func countUnpushed(srcDir, branch string) int { base := defaultBranch(srcDir) - cmd := exec.Command("git", "rev-list", "--count", "origin/"+base+".."+branch) + cmd := exec.Command("git", "rev-list", "--count", core.Concat("origin/", base, "..", branch)) cmd.Dir = srcDir out, err := cmd.Output() if err != nil { - cmd2 := exec.Command("git", "log", "--oneline", base+".."+branch) + cmd2 := exec.Command("git", "log", "--oneline", core.Concat(base, "..", branch)) cmd2.Dir = srcDir out2, err2 := cmd2.Output() if err2 != nil { return 0 } - lines := strings.Split(strings.TrimSpace(string(out2)), "\n") + lines := core.Split(core.Trim(string(out2)), "\n") if len(lines) == 1 && lines[0] == "" { return 0 } return len(lines) } - var count int - fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &count) + count, err := strconv.Atoi(core.Trim(string(out))) + if err != nil { + return 0 + } return count } @@ -202,7 +207,7 @@ func countUnpushed(srcDir, branch string) int { func checkSafety(srcDir string) string { // Check all changed files — added, modified, renamed base := defaultBranch(srcDir) - cmd := exec.Command("git", "diff", "--name-only", base+"...HEAD") + cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) cmd.Dir = srcDir out, err := cmd.Output() if err != nil { @@ -219,20 +224,20 @@ func checkSafety(srcDir string) string { ".db": true, ".sqlite": true, ".sqlite3": true, } - for _, file := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for _, file := range core.Split(core.Trim(string(out)), "\n") { if file == "" { continue } - ext := strings.ToLower(filepath.Ext(file)) + ext := core.Lower(filepath.Ext(file)) if binaryExts[ext] { - return fmt.Sprintf("binary file added: %s", file) + return core.Sprintf("binary file added: %s", file) } // Check file size (reject > 1MB) - fullPath := filepath.Join(srcDir, file) + fullPath := core.Concat(srcDir, "/", file) info, err := os.Stat(fullPath) if err == nil && info.Size() > 1024*1024 { - return fmt.Sprintf("large file: %s (%d bytes)", file, info.Size()) + return core.Sprintf("large file: %s (%d bytes)", file, info.Size()) } } @@ -242,13 +247,13 @@ func checkSafety(srcDir string) string { // countChangedFiles returns the number of files changed vs the default branch. func countChangedFiles(srcDir string) int { base := defaultBranch(srcDir) - cmd := exec.Command("git", "diff", "--name-only", base+"...HEAD") + cmd := exec.Command("git", "diff", "--name-only", core.Concat(base, "...HEAD")) cmd.Dir = srcDir out, err := cmd.Output() if err != nil { return 0 } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lines := core.Split(core.Trim(string(out)), "\n") if len(lines) == 1 && lines[0] == "" { return 0 } @@ -261,19 +266,23 @@ func pushBranch(srcDir, branch string) error { cmd.Dir = srcDir out, err := cmd.CombinedOutput() if err != nil { - return core.E("harvest.pushBranch", strings.TrimSpace(string(out)), err) + return core.E("harvest.pushBranch", core.Trim(string(out)), err) } return nil } // updateStatus updates the workspace status.json. func updateStatus(wsDir, status, question string) { - r := fs.Read(filepath.Join(wsDir, "status.json")) + r := fs.Read(workspaceStatusPath(wsDir)) if !r.OK { return } + statusData, ok := resultString(r) + if !ok { + return + } var st map[string]any - if json.Unmarshal([]byte(r.Value.(string)), &st) != nil { + if json.Unmarshal([]byte(statusData), &st) != nil { return } st["status"] = status @@ -283,5 +292,5 @@ func updateStatus(wsDir, status, question string) { delete(st, "question") // clear stale question from previous state } updated, _ := json.MarshalIndent(st, "", " ") - fs.Write(filepath.Join(wsDir, "status.json"), string(updated)) + fs.Write(workspaceStatusPath(wsDir), string(updated)) } diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 29b03ac..f323ef5 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -12,28 +12,47 @@ package monitor import ( "context" "encoding/json" - "fmt" "net/http" "net/url" "os" "path/filepath" - "strings" "sync" "time" - core "dappco.re/go/core" "dappco.re/go/agent/pkg/agentic" + core "dappco.re/go/core" "github.com/modelcontextprotocol/go-sdk/mcp" ) // fs provides unrestricted filesystem access (root "/" = no sandbox). // -// r := fs.Read(filepath.Join(wsRoot, name, "status.json")) -// if r.OK { json.Unmarshal([]byte(r.Value.(string)), &st) } +// r := fs.Read(core.Concat(wsRoot, "/", name, "/status.json")) +// if text, ok := resultString(r); ok { json.Unmarshal([]byte(text), &st) } var fs = agentic.LocalFs() +func workspaceStatusGlob(wsRoot string) string { + return core.Concat(wsRoot, "/*/status.json") +} + +func workspaceStatusPath(wsDir string) string { + return core.Concat(wsDir, "/status.json") +} + +func brainKeyPath(home string) string { + return filepath.Join(home, ".claude", "brain.key") +} + +func resultString(r core.Result) (string, bool) { + value, ok := r.Value.(string) + if !ok { + return "", false + } + return value, true +} + // ChannelNotifier pushes events to connected MCP sessions. -// Matches the Notifier interface in core/mcp without importing it. +// +// mon.SetNotifier(notifier) type ChannelNotifier interface { ChannelSend(ctx context.Context, channel string, data any) } @@ -51,6 +70,7 @@ type Subsystem struct { wg sync.WaitGroup // Track last seen state to only notify on changes + lastCompletedCount int // completed workspaces seen on the last scan seenCompleted map[string]bool // workspace names we've already notified about completionsSeeded bool // true after first completions check lastInboxMaxID int // highest message ID seen @@ -63,17 +83,23 @@ type Subsystem struct { } // SetNotifier wires up channel event broadcasting. +// +// mon.SetNotifier(notifier) func (m *Subsystem) SetNotifier(n ChannelNotifier) { m.notifier = n } -// Options configures the monitor. +// Options configures the monitor interval. +// +// monitor.New(monitor.Options{Interval: 30 * time.Second}) type Options struct { // Interval between checks (default: 2 minutes) Interval time.Duration } // New creates a monitor subsystem. +// +// mon := monitor.New(monitor.Options{Interval: 30 * time.Second}) func New(opts ...Options) *Subsystem { interval := 2 * time.Minute if len(opts) > 0 && opts[0].Interval > 0 { @@ -99,8 +125,14 @@ func (m *Subsystem) debugChannel(msg string) { } } +// Name returns the subsystem identifier used by MCP registration. +// +// mon.Name() // "monitor" func (m *Subsystem) Name() string { return "monitor" } +// RegisterTools binds the monitor resource to an MCP server. +// +// mon.RegisterTools(server) func (m *Subsystem) RegisterTools(server *mcp.Server) { m.server = server @@ -113,13 +145,14 @@ func (m *Subsystem) RegisterTools(server *mcp.Server) { }, m.agentStatusResource) } -// Start begins the background monitoring loop. -// Called after the MCP server is running and sessions are active. +// Start begins the background monitoring loop after MCP startup. +// +// mon.Start(ctx) func (m *Subsystem) Start(ctx context.Context) { monCtx, cancel := context.WithCancel(ctx) m.cancel = cancel - fmt.Fprintf(os.Stderr, "monitor: started (interval=%s, notifier=%v)\n", m.interval, m.notifier != nil) + core.Print(os.Stderr, "monitor: started (interval=%s, notifier=%v)", m.interval, m.notifier != nil) m.wg.Add(1) go func() { @@ -128,7 +161,9 @@ func (m *Subsystem) Start(ctx context.Context) { }() } -// Shutdown stops the monitoring loop. +// Shutdown stops the monitoring loop and waits for it to exit. +// +// _ = mon.Shutdown(ctx) func (m *Subsystem) Shutdown(_ context.Context) error { if m.cancel != nil { m.cancel() @@ -137,8 +172,9 @@ func (m *Subsystem) Shutdown(_ context.Context) error { return nil } -// Poke triggers an immediate check cycle. Non-blocking — if a poke is already -// pending it's a no-op. Call this from dispatch when an agent completes. +// Poke triggers an immediate check cycle. +// +// mon.Poke() func (m *Subsystem) Poke() { select { case m.poke <- struct{}{}: @@ -203,7 +239,7 @@ func (m *Subsystem) check(ctx context.Context) { return } - combined := strings.Join(messages, "\n") + combined := core.Join("\n", messages...) m.notify(ctx, combined) // Notify resource subscribers that agent status changed @@ -219,13 +255,14 @@ func (m *Subsystem) check(ctx context.Context) { // don't suppress future notifications. func (m *Subsystem) checkCompletions() string { wsRoot := agentic.WorkspaceRoot() - entries, err := filepath.Glob(filepath.Join(wsRoot, "*/status.json")) + entries, err := filepath.Glob(workspaceStatusGlob(wsRoot)) if err != nil { return "" } running := 0 queued := 0 + completed := 0 var newlyCompleted []string m.mu.Lock() @@ -235,12 +272,16 @@ func (m *Subsystem) checkCompletions() string { if !r.OK { continue } + entryData, ok := resultString(r) + if !ok { + continue + } var st struct { Status string `json:"status"` Repo string `json:"repo"` Agent string `json:"agent"` } - if json.Unmarshal([]byte(r.Value.(string)), &st) != nil { + if json.Unmarshal([]byte(entryData), &st) != nil { continue } @@ -248,10 +289,11 @@ func (m *Subsystem) checkCompletions() string { switch st.Status { case "completed": + completed++ if !m.seenCompleted[wsName] { m.seenCompleted[wsName] = true if seeded { - newlyCompleted = append(newlyCompleted, fmt.Sprintf("%s (%s)", st.Repo, st.Agent)) + newlyCompleted = append(newlyCompleted, core.Sprintf("%s (%s)", st.Repo, st.Agent)) } } case "running": @@ -262,11 +304,12 @@ func (m *Subsystem) checkCompletions() string { if !m.seenCompleted[wsName] { m.seenCompleted[wsName] = true if seeded { - newlyCompleted = append(newlyCompleted, fmt.Sprintf("%s (%s) [%s]", st.Repo, st.Agent, st.Status)) + newlyCompleted = append(newlyCompleted, core.Sprintf("%s (%s) [%s]", st.Repo, st.Agent, st.Status)) } } } } + m.lastCompletedCount = completed m.completionsSeeded = true m.mu.Unlock() @@ -284,12 +327,12 @@ func (m *Subsystem) checkCompletions() string { }) } - msg := fmt.Sprintf("%d agent(s) completed", len(newlyCompleted)) + msg := core.Sprintf("%d agent(s) completed", len(newlyCompleted)) if running > 0 { - msg += fmt.Sprintf(", %d still running", running) + msg = core.Concat(msg, core.Sprintf(", %d still running", running)) } if queued > 0 { - msg += fmt.Sprintf(", %d queued", queued) + msg = core.Concat(msg, core.Sprintf(", %d queued", queued)) } return msg } @@ -299,12 +342,16 @@ func (m *Subsystem) checkInbox() string { apiKeyStr := os.Getenv("CORE_BRAIN_KEY") if apiKeyStr == "" { home, _ := os.UserHomeDir() - keyFile := filepath.Join(home, ".claude", "brain.key") + keyFile := brainKeyPath(home) r := fs.Read(keyFile) if !r.OK { return "" } - apiKeyStr = r.Value.(string) + value, ok := resultString(r) + if !ok { + return "" + } + apiKeyStr = value } // Call the API to check inbox @@ -312,11 +359,11 @@ func (m *Subsystem) checkInbox() string { if apiURL == "" { apiURL = "https://api.lthn.sh" } - req, err := http.NewRequest("GET", apiURL+"/v1/messages/inbox?agent="+url.QueryEscape(agentic.AgentName()), nil) + req, err := http.NewRequest("GET", core.Concat(apiURL, "/v1/messages/inbox?agent=", url.QueryEscape(agentic.AgentName())), nil) if err != nil { return "" } - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKeyStr)) + req.Header.Set("Authorization", core.Concat("Bearer ", core.Trim(apiKeyStr))) client := &http.Client{Timeout: 10 * time.Second} httpResp, err := client.Do(req) @@ -406,7 +453,7 @@ func (m *Subsystem) checkInbox() string { }) } - return fmt.Sprintf("%d unread message(s) in inbox", unread) + return core.Sprintf("%d unread message(s) in inbox", unread) } // notify sends a log notification to all connected MCP sessions. @@ -428,7 +475,7 @@ func (m *Subsystem) notify(ctx context.Context, message string) { // agentStatusResource returns current workspace status as a JSON resource. func (m *Subsystem) agentStatusResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { wsRoot := agentic.WorkspaceRoot() - entries, err := filepath.Glob(filepath.Join(wsRoot, "*/status.json")) + entries, err := filepath.Glob(workspaceStatusGlob(wsRoot)) if err != nil { return nil, core.E("monitor.agentStatus", "failed to scan workspaces", err) } @@ -447,13 +494,17 @@ func (m *Subsystem) agentStatusResource(ctx context.Context, req *mcp.ReadResour if !r.OK { continue } + entryData, ok := resultString(r) + if !ok { + continue + } var st struct { Status string `json:"status"` Repo string `json:"repo"` Agent string `json:"agent"` PRURL string `json:"pr_url"` } - if json.Unmarshal([]byte(r.Value.(string)), &st) != nil { + if json.Unmarshal([]byte(entryData), &st) != nil { continue } workspaces = append(workspaces, wsInfo{ @@ -465,7 +516,10 @@ func (m *Subsystem) agentStatusResource(ctx context.Context, req *mcp.ReadResour }) } - result, _ := json.Marshal(workspaces) + result, err := json.Marshal(workspaces) + if err != nil { + return nil, core.E("monitor.agentStatus", "failed to encode workspace status", err) + } return &mcp.ReadResourceResult{ Contents: []*mcp.ResourceContents{ { diff --git a/pkg/monitor/monitor_test.go b/pkg/monitor/monitor_test.go index 9cfc4ab..22e7038 100644 --- a/pkg/monitor/monitor_test.go +++ b/pkg/monitor/monitor_test.go @@ -50,6 +50,7 @@ func writeWorkspaceStatus(t *testing.T, wsRoot, name string, fields map[string]a // --- New --- func TestNew_Good_Defaults(t *testing.T) { + t.Setenv("MONITOR_INTERVAL", "") mon := New() assert.Equal(t, 2*time.Minute, mon.interval) assert.NotNil(t, mon.poke) @@ -61,6 +62,7 @@ func TestNew_Good_CustomInterval(t *testing.T) { } func TestNew_Bad_ZeroInterval(t *testing.T) { + t.Setenv("MONITOR_INTERVAL", "") mon := New(Options{Interval: 0}) assert.Equal(t, 2*time.Minute, mon.interval) } @@ -125,6 +127,13 @@ func TestCheckCompletions_Good_NewCompletions(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) + require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) + + mon := New() + notifier := &mockNotifier{} + mon.SetNotifier(notifier) + assert.Equal(t, "", mon.checkCompletions()) + for i := 0; i < 2; i++ { writeWorkspaceStatus(t, wsRoot, fmt.Sprintf("ws-%d", i), map[string]any{ "status": "completed", @@ -133,10 +142,6 @@ func TestCheckCompletions_Good_NewCompletions(t *testing.T) { }) } - mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) - msg := mon.checkCompletions() assert.Contains(t, msg, "2 agent(s) completed") @@ -151,6 +156,13 @@ func TestCheckCompletions_Good_MixedStatuses(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) + require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) + + mon := New() + notifier := &mockNotifier{} + mon.SetNotifier(notifier) + assert.Equal(t, "", mon.checkCompletions()) + for i, status := range []string{"completed", "running", "queued"} { writeWorkspaceStatus(t, wsRoot, fmt.Sprintf("ws-%d", i), map[string]any{ "status": status, @@ -159,10 +171,6 @@ func TestCheckCompletions_Good_MixedStatuses(t *testing.T) { }) } - mon := New() - notifier := &mockNotifier{} - mon.SetNotifier(notifier) - msg := mon.checkCompletions() assert.Contains(t, msg, "1 agent(s) completed") assert.Contains(t, msg, "1 still running") @@ -211,11 +219,15 @@ func TestCheckCompletions_Good_NoNotifierSet(t *testing.T) { wsRoot := t.TempDir() t.Setenv("CORE_WORKSPACE", wsRoot) + require.NoError(t, os.MkdirAll(filepath.Join(wsRoot, "workspace"), 0755)) + + mon := New() + assert.Equal(t, "", mon.checkCompletions()) + writeWorkspaceStatus(t, wsRoot, "ws-0", map[string]any{ "status": "completed", "repo": "r", "agent": "a", }) - mon := New() msg := mon.checkCompletions() assert.Contains(t, msg, "1 agent(s) completed") } @@ -388,7 +400,8 @@ func TestCheck_Good_CombinesMessages(t *testing.T) { mon.check(context.Background()) mon.mu.Lock() - assert.Equal(t, 1, mon.lastCompletedCount) + assert.True(t, mon.completionsSeeded) + assert.True(t, mon.seenCompleted["ws-0"]) mon.mu.Unlock() } @@ -470,8 +483,8 @@ func TestLoop_Good_PokeTriggersCheck(t *testing.T) { require.Eventually(t, func() bool { mon.mu.Lock() defer mon.mu.Unlock() - return mon.lastCompletedCount == 1 - }, 5*time.Second, 50*time.Millisecond, "expected lastCompletedCount to reach 1") + return mon.seenCompleted["ws-poke"] + }, 5*time.Second, 50*time.Millisecond, "expected ws-poke completion to be recorded") cancel() mon.wg.Wait() diff --git a/pkg/monitor/sync.go b/pkg/monitor/sync.go index 2323cd8..d9e12ee 100644 --- a/pkg/monitor/sync.go +++ b/pkg/monitor/sync.go @@ -4,19 +4,20 @@ package monitor import ( "encoding/json" - "fmt" "net/http" neturl "net/url" "os" "os/exec" "path/filepath" - "strings" "time" "dappco.re/go/agent/pkg/agentic" + core "dappco.re/go/core" ) // CheckinResponse is what the API returns for an agent checkin. +// +// resp := monitor.CheckinResponse{Changed: []monitor.ChangedRepo{{Repo: "core-agent", Branch: "main", SHA: "abc123"}}, Timestamp: 1712345678} type CheckinResponse struct { // Repos that have new commits since the agent's last checkin. Changed []ChangedRepo `json:"changed,omitempty"` @@ -25,6 +26,8 @@ type CheckinResponse struct { } // ChangedRepo is a repo that has new commits. +// +// repo := monitor.ChangedRepo{Repo: "core-agent", Branch: "main", SHA: "abc123"} type ChangedRepo struct { Repo string `json:"repo"` Branch string `json:"branch"` @@ -41,7 +44,7 @@ func (m *Subsystem) syncRepos() string { agentName := agentic.AgentName() - checkinURL := fmt.Sprintf("%s/v1/agent/checkin?agent=%s&since=%d", apiURL, neturl.QueryEscape(agentName), m.lastSyncTimestamp) + checkinURL := core.Sprintf("%s/v1/agent/checkin?agent=%s&since=%d", apiURL, neturl.QueryEscape(agentName), m.lastSyncTimestamp) req, err := http.NewRequest("GET", checkinURL, nil) if err != nil { @@ -52,12 +55,14 @@ func (m *Subsystem) syncRepos() string { brainKey := os.Getenv("CORE_BRAIN_KEY") if brainKey == "" { home, _ := os.UserHomeDir() - if r := fs.Read(filepath.Join(home, ".claude", "brain.key")); r.OK { - brainKey = strings.TrimSpace(r.Value.(string)) + if r := fs.Read(brainKeyPath(home)); r.OK { + if value, ok := resultString(r); ok { + brainKey = core.Trim(value) + } } } if brainKey != "" { - req.Header.Set("Authorization", "Bearer "+brainKey) + req.Header.Set("Authorization", core.Concat("Bearer ", brainKey)) } client := &http.Client{Timeout: 15 * time.Second} @@ -98,7 +103,7 @@ func (m *Subsystem) syncRepos() string { if repoName == "." || repoName == ".." || repoName == "" { continue } - repoDir := filepath.Join(basePath, repoName) + repoDir := core.Concat(basePath, "/", repoName) if _, err := os.Stat(repoDir); err != nil { continue } @@ -110,7 +115,7 @@ func (m *Subsystem) syncRepos() string { if err != nil { continue } - current := strings.TrimSpace(string(currentBranch)) + current := core.Trim(string(currentBranch)) // Determine which branch to pull — use server-reported branch, // fall back to current if server didn't specify @@ -127,7 +132,7 @@ func (m *Subsystem) syncRepos() string { statusCmd := exec.Command("git", "status", "--porcelain") statusCmd.Dir = repoDir status, _ := statusCmd.Output() - if len(strings.TrimSpace(string(status))) > 0 { + if len(core.Trim(string(status))) > 0 { continue // Don't pull if dirty } @@ -153,7 +158,7 @@ func (m *Subsystem) syncRepos() string { return "" } - return fmt.Sprintf("Synced %d repo(s): %s", len(pulled), strings.Join(pulled, ", ")) + return core.Sprintf("Synced %d repo(s): %s", len(pulled), core.Join(", ", pulled...)) } // lastSyncTimestamp is stored on the subsystem — add it via the check cycle. diff --git a/pkg/setup/config.go b/pkg/setup/config.go index fd7d349..6fc7b55 100644 --- a/pkg/setup/config.go +++ b/pkg/setup/config.go @@ -7,7 +7,8 @@ import ( "path/filepath" "strings" - "dappco.re/go/agent/pkg/lib" + core "dappco.re/go/core" + "gopkg.in/yaml.v3" ) // ConfigData holds the data passed to config templates. @@ -35,105 +36,143 @@ type Command struct { Run string } +type configSection struct { + Key string + Values []configValue +} + +type configValue struct { + Key string + Value any +} + // GenerateBuildConfig renders a build.yaml for the detected project type. +// +// content, err := setup.GenerateBuildConfig("/repo", setup.TypeGo) func GenerateBuildConfig(path string, projType ProjectType) (string, error) { name := filepath.Base(path) - data := map[string]any{ - "Comment": name + " build configuration", - "Sections": []map[string]any{ - { - "Key": "project", - "Values": []map[string]any{ - {"Key": "name", "Value": name}, - {"Key": "type", "Value": string(projType)}, - }, + sections := []configSection{ + { + Key: "project", + Values: []configValue{ + {Key: "name", Value: name}, + {Key: "type", Value: string(projType)}, }, }, } switch projType { case TypeGo, TypeWails: - data["Sections"] = append(data["Sections"].([]map[string]any), - map[string]any{ - "Key": "build", - "Values": []map[string]any{ - {"Key": "main", "Value": "./cmd/" + name}, - {"Key": "binary", "Value": name}, - {"Key": "cgo", "Value": "false"}, - }, + sections = append(sections, configSection{ + Key: "build", + Values: []configValue{ + {Key: "main", Value: "./cmd/" + name}, + {Key: "binary", Value: name}, + {Key: "cgo", Value: false}, }, - ) + }) case TypePHP: - data["Sections"] = append(data["Sections"].([]map[string]any), - map[string]any{ - "Key": "build", - "Values": []map[string]any{ - {"Key": "dockerfile", "Value": "Dockerfile"}, - {"Key": "image", "Value": name}, - }, + sections = append(sections, configSection{ + Key: "build", + Values: []configValue{ + {Key: "dockerfile", Value: "Dockerfile"}, + {Key: "image", Value: name}, }, - ) + }) case TypeNode: - data["Sections"] = append(data["Sections"].([]map[string]any), - map[string]any{ - "Key": "build", - "Values": []map[string]any{ - {"Key": "script", "Value": "npm run build"}, - {"Key": "output", "Value": "dist"}, - }, + sections = append(sections, configSection{ + Key: "build", + Values: []configValue{ + {Key: "script", Value: "npm run build"}, + {Key: "output", Value: "dist"}, }, - ) + }) } - return lib.RenderFile("yaml/config", data) + return renderConfig(name+" build configuration", sections) } // GenerateTestConfig renders a test.yaml for the detected project type. +// +// content, err := setup.GenerateTestConfig(setup.TypeGo) func GenerateTestConfig(projType ProjectType) (string, error) { - data := map[string]any{ - "Comment": "Test configuration", - } + var sections []configSection switch projType { case TypeGo, TypeWails: - data["Sections"] = []map[string]any{ + sections = []configSection{ { - "Key": "commands", - "Values": []map[string]any{ - {"Key": "unit", "Value": "go test ./..."}, - {"Key": "coverage", "Value": "go test -coverprofile=coverage.out ./..."}, - {"Key": "race", "Value": "go test -race ./..."}, + Key: "commands", + Values: []configValue{ + {Key: "unit", Value: "go test ./..."}, + {Key: "coverage", Value: "go test -coverprofile=coverage.out ./..."}, + {Key: "race", Value: "go test -race ./..."}, }, }, } case TypePHP: - data["Sections"] = []map[string]any{ + sections = []configSection{ { - "Key": "commands", - "Values": []map[string]any{ - {"Key": "unit", "Value": "vendor/bin/pest --parallel"}, - {"Key": "lint", "Value": "vendor/bin/pint --test"}, + Key: "commands", + Values: []configValue{ + {Key: "unit", Value: "vendor/bin/pest --parallel"}, + {Key: "lint", Value: "vendor/bin/pint --test"}, }, }, } case TypeNode: - data["Sections"] = []map[string]any{ + sections = []configSection{ { - "Key": "commands", - "Values": []map[string]any{ - {"Key": "unit", "Value": "npm test"}, - {"Key": "lint", "Value": "npm run lint"}, + Key: "commands", + Values: []configValue{ + {Key: "unit", Value: "npm test"}, + {Key: "lint", Value: "npm run lint"}, }, }, } } - return lib.RenderFile("yaml/config", data) + return renderConfig("Test configuration", sections) +} + +func renderConfig(comment string, sections []configSection) (string, error) { + var builder strings.Builder + + if comment != "" { + builder.WriteString("# ") + builder.WriteString(comment) + builder.WriteString("\n\n") + } + + for idx, section := range sections { + builder.WriteString(section.Key) + builder.WriteString(":\n") + + for _, value := range section.Values { + scalar, err := yaml.Marshal(value.Value) + if err != nil { + return "", core.E("setup.renderConfig", "marshal "+section.Key+"."+value.Key, err) + } + + builder.WriteString(" ") + builder.WriteString(value.Key) + builder.WriteString(": ") + builder.WriteString(strings.TrimSpace(string(scalar))) + builder.WriteString("\n") + } + + if idx < len(sections)-1 { + builder.WriteString("\n") + } + } + + return builder.String(), nil } // detectGitRemote extracts owner/repo from git remote origin. -func detectGitRemote() string { +func detectGitRemote(path string) string { cmd := exec.Command("git", "remote", "get-url", "origin") + cmd.Dir = path output, err := cmd.Output() if err != nil { return "" diff --git a/pkg/setup/detect.go b/pkg/setup/detect.go index 6aaaf31..31e26af 100644 --- a/pkg/setup/detect.go +++ b/pkg/setup/detect.go @@ -4,8 +4,10 @@ package setup import ( - "os" "path/filepath" + "unsafe" + + core "dappco.re/go/core" ) // ProjectType identifies what kind of project lives at a path. @@ -19,8 +21,22 @@ const ( TypeUnknown ProjectType = "unknown" ) +// fs provides unrestricted filesystem access for setup operations. +var fs = newFs("/") + +// newFs creates a core.Fs with the given root directory. +func newFs(root string) *core.Fs { + type fsRoot struct{ root string } + f := &core.Fs{} + (*fsRoot)(unsafe.Pointer(f)).root = root + return f +} + // Detect identifies the project type from files present at the given path. +// +// projType := setup.Detect("./repo") func Detect(path string) ProjectType { + base := absolutePath(path) checks := []struct { file string projType ProjectType @@ -31,7 +47,7 @@ func Detect(path string) ProjectType { {"package.json", TypeNode}, } for _, c := range checks { - if _, err := os.Stat(filepath.Join(path, c.file)); err == nil { + if fs.IsFile(filepath.Join(base, c.file)) { return c.projType } } @@ -39,7 +55,10 @@ func Detect(path string) ProjectType { } // DetectAll returns all project types found at the path (polyglot repos). +// +// types := setup.DetectAll("./repo") func DetectAll(path string) []ProjectType { + base := absolutePath(path) var types []ProjectType all := []struct { file string @@ -51,9 +70,20 @@ func DetectAll(path string) []ProjectType { {"wails.json", TypeWails}, } for _, c := range all { - if _, err := os.Stat(filepath.Join(path, c.file)); err == nil { + if fs.IsFile(filepath.Join(base, c.file)) { types = append(types, c.projType) } } return types } + +func absolutePath(path string) string { + if path == "" { + path = "." + } + abs, err := filepath.Abs(path) + if err != nil { + return path + } + return abs +} diff --git a/pkg/setup/setup.go b/pkg/setup/setup.go index edc4718..c25520b 100644 --- a/pkg/setup/setup.go +++ b/pkg/setup/setup.go @@ -6,37 +6,44 @@ import ( "fmt" "os" "path/filepath" + "strings" "dappco.re/go/agent/pkg/lib" + core "dappco.re/go/core" ) // Options controls setup behaviour. +// +// err := setup.Run(setup.Options{Path: ".", Force: true}) type Options struct { Path string // Target directory (default: cwd) DryRun bool // Preview only, don't write Force bool // Overwrite existing files - Template string // Dir template to use (agent, php, go, gui) + Template string // Workspace template or compatibility alias (default, review, security, agent, go, php, gui, auto) } // Run performs the workspace setup at the given path. // It detects the project type, generates .core/ configs, // and optionally scaffolds a workspace from a dir template. +// +// err := setup.Run(setup.Options{Path: ".", Template: "auto"}) func Run(opts Options) error { if opts.Path == "" { var err error opts.Path, err = os.Getwd() if err != nil { - return fmt.Errorf("setup: %w", err) + return core.E("setup.Run", "resolve working directory", err) } } + opts.Path = absolutePath(opts.Path) projType := Detect(opts.Path) allTypes := DetectAll(opts.Path) - fmt.Printf("Project: %s\n", filepath.Base(opts.Path)) - fmt.Printf("Type: %s\n", projType) + core.Print(nil, "Project: %s", filepath.Base(opts.Path)) + core.Print(nil, "Type: %s", projType) if len(allTypes) > 1 { - fmt.Printf("Also: %v (polyglot)\n", allTypes) + core.Print(nil, "Also: %v (polyglot)", allTypes) } // Generate .core/ config files @@ -57,17 +64,19 @@ func setupCoreDir(opts Options, projType ProjectType) error { coreDir := filepath.Join(opts.Path, ".core") if opts.DryRun { - fmt.Printf("\nWould create %s/\n", coreDir) + core.Print(nil, "") + core.Print(nil, "Would create %s/", coreDir) } else { - if err := os.MkdirAll(coreDir, 0755); err != nil { - return fmt.Errorf("setup: create .core: %w", err) + if r := fs.EnsureDir(coreDir); !r.OK { + err, _ := r.Value.(error) + return core.E("setup.setupCoreDir", "create .core directory", err) } } // build.yaml buildConfig, err := GenerateBuildConfig(opts.Path, projType) if err != nil { - return fmt.Errorf("setup: build config: %w", err) + return core.E("setup.setupCoreDir", "generate build config", err) } if err := writeConfig(filepath.Join(coreDir, "build.yaml"), buildConfig, opts); err != nil { return err @@ -76,7 +85,7 @@ func setupCoreDir(opts Options, projType ProjectType) error { // test.yaml testConfig, err := GenerateTestConfig(projType) if err != nil { - return fmt.Errorf("setup: test config: %w", err) + return core.E("setup.setupCoreDir", "generate test config", err) } if err := writeConfig(filepath.Join(coreDir, "test.yaml"), testConfig, opts); err != nil { return err @@ -87,64 +96,125 @@ func setupCoreDir(opts Options, projType ProjectType) error { // scaffoldTemplate extracts a dir template into the target path. func scaffoldTemplate(opts Options, projType ProjectType) error { - tmplName := opts.Template - if tmplName == "auto" { - switch projType { - case TypeGo, TypeWails: - tmplName = "go" - case TypePHP: - tmplName = "php" - case TypeNode: - tmplName = "gui" - default: - tmplName = "agent" - } + tmplName, err := resolveTemplateName(opts.Template, projType) + if err != nil { + return err } - fmt.Printf("Template: %s\n", tmplName) + core.Print(nil, "Template: %s", tmplName) - data := map[string]any{ - "Name": filepath.Base(opts.Path), - "Module": detectGitRemote(), - "Namespace": "App", - "ViewNamespace": filepath.Base(opts.Path), - "RouteName": filepath.Base(opts.Path), - "GoVersion": "1.26", - "HasAdmin": true, - "HasApi": true, - "HasConsole": true, + data := &lib.WorkspaceData{ + Repo: filepath.Base(opts.Path), + Branch: "main", + Task: fmt.Sprintf("Initialise %s project tooling.", projType), + Agent: "setup", + Language: string(projType), + Prompt: "This workspace was scaffolded by pkg/setup. Review the repository and continue from the generated context files.", + Flow: formatFlow(projType), + RepoDescription: detectGitRemote(opts.Path), + BuildCmd: defaultBuildCommand(projType), + TestCmd: defaultTestCommand(projType), + } + + if !templateExists(tmplName) { + return core.E("setup.scaffoldTemplate", "template not found: "+tmplName, nil) } if opts.DryRun { - fmt.Printf("Would extract template/%s to %s\n", tmplName, opts.Path) - files := lib.ListDirTemplates() - for _, f := range files { - if f == tmplName { - fmt.Printf(" Template found: %s\n", f) - } - } + core.Print(nil, "Would extract workspace/%s to %s", tmplName, opts.Path) + core.Print(nil, " Template found: %s", tmplName) return nil } - return lib.ExtractDir(tmplName, opts.Path, data) + if err := lib.ExtractWorkspace(tmplName, opts.Path, data); err != nil { + return core.E("setup.scaffoldTemplate", "extract workspace template "+tmplName, err) + } + return nil } func writeConfig(path, content string, opts Options) error { if opts.DryRun { - fmt.Printf(" %s\n", path) + core.Print(nil, " %s", path) return nil } - if !opts.Force { - if _, err := os.Stat(path); err == nil { - fmt.Printf(" skip %s (exists, use --force to overwrite)\n", filepath.Base(path)) - return nil + if !opts.Force && fs.Exists(path) { + core.Print(nil, " skip %s (exists, use --force to overwrite)", filepath.Base(path)) + return nil + } + + if r := fs.WriteMode(path, content, 0644); !r.OK { + err, _ := r.Value.(error) + return core.E("setup.writeConfig", "write "+filepath.Base(path), err) + } + core.Print(nil, " created %s", path) + return nil +} + +func resolveTemplateName(name string, projType ProjectType) (string, error) { + if name == "" { + return "", core.E("setup.resolveTemplateName", "template is required", nil) + } + + if name == "auto" { + switch projType { + case TypeGo, TypeWails, TypePHP, TypeNode, TypeUnknown: + return "default", nil } } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return fmt.Errorf("setup: write %s: %w", filepath.Base(path), err) + switch name { + case "agent", "go", "php", "gui": + return "default", nil + case "verify", "conventions": + return "review", nil + default: + return name, nil } - fmt.Printf(" created %s\n", path) - return nil +} + +func templateExists(name string) bool { + for _, tmpl := range lib.ListWorkspaces() { + if tmpl == name { + return true + } + } + return false +} + +func defaultBuildCommand(projType ProjectType) string { + switch projType { + case TypeGo, TypeWails: + return "go build ./..." + case TypePHP: + return "composer test" + case TypeNode: + return "npm run build" + default: + return "make build" + } +} + +func defaultTestCommand(projType ProjectType) string { + switch projType { + case TypeGo, TypeWails: + return "go test ./..." + case TypePHP: + return "composer test" + case TypeNode: + return "npm test" + default: + return "make test" + } +} + +func formatFlow(projType ProjectType) string { + var builder strings.Builder + builder.WriteString("- Build: `") + builder.WriteString(defaultBuildCommand(projType)) + builder.WriteString("`\n") + builder.WriteString("- Test: `") + builder.WriteString(defaultTestCommand(projType)) + builder.WriteString("`") + return builder.String() } diff --git a/pkg/setup/setup_test.go b/pkg/setup/setup_test.go new file mode 100644 index 0000000..2772348 --- /dev/null +++ b/pkg/setup/setup_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package setup + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetect_Good(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) + + assert.Equal(t, TypeGo, Detect(dir)) + assert.Equal(t, []ProjectType{TypeGo}, DetectAll(dir)) +} + +func TestGenerateBuildConfig_Good(t *testing.T) { + cfg, err := GenerateBuildConfig("/tmp/example", TypeGo) + require.NoError(t, err) + + assert.Contains(t, cfg, "# example build configuration") + assert.Contains(t, cfg, "project:") + assert.Contains(t, cfg, "name: example") + assert.Contains(t, cfg, "type: go") + assert.Contains(t, cfg, "main: ./cmd/example") + assert.Contains(t, cfg, "cgo: false") +} + +func TestRun_Good(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) + + err := Run(Options{Path: dir}) + require.NoError(t, err) + + build := fs.Read(filepath.Join(dir, ".core", "build.yaml")) + require.True(t, build.OK) + assert.Contains(t, build.Value.(string), "type: go") + + test := fs.Read(filepath.Join(dir, ".core", "test.yaml")) + require.True(t, test.OK) + assert.Contains(t, test.Value.(string), "go test ./...") +} + +func TestRun_TemplateAlias_Good(t *testing.T) { + dir := t.TempDir() + require.True(t, fs.WriteMode(filepath.Join(dir, "go.mod"), "module example.com/test\n", 0644).OK) + + err := Run(Options{Path: dir, Template: "agent"}) + require.NoError(t, err) + + prompt := fs.Read(filepath.Join(dir, "PROMPT.md")) + require.True(t, prompt.OK) + assert.Contains(t, prompt.Value.(string), "This workspace was scaffolded by pkg/setup.") +}