refactor: migrate core/agent to Core primitives — reference implementation
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 <virgil@lethean.io>
This commit is contained in:
parent
6db0110b6f
commit
a0dc9c32e7
41 changed files with 1198 additions and 710 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,4 +2,3 @@
|
|||
.vscode/
|
||||
*.log
|
||||
.core/
|
||||
var/
|
||||
|
|
|
|||
153
cmd/main.go
153
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
go.mod
18
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
|
||||
)
|
||||
|
|
|
|||
16
go.sum
16
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=
|
||||
|
|
|
|||
|
|
@ -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 <virgil@lethean.io>\n")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <virgil@lethean.io>",
|
||||
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]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
59
pkg/setup/setup_test.go
Normal file
59
pkg/setup/setup_test.go
Normal file
|
|
@ -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.")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue