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:
Snider 2026-03-22 06:13:41 +00:00
parent 6db0110b6f
commit a0dc9c32e7
41 changed files with 1198 additions and 710 deletions

1
.gitignore vendored
View file

@ -2,4 +2,3 @@
.vscode/
*.log
.core/
var/

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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",

View file

@ -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,

View file

@ -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]
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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))
}
}

View file

@ -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)

View file

@ -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"`

View file

@ -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
}

View file

@ -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

View file

@ -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 {

View file

@ -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"

View file

@ -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) {

View file

@ -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.

View file

@ -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)
}

View file

@ -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
}

View file

@ -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"})
}

View file

@ -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
}

View file

@ -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

View file

@ -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"

View file

@ -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
}

View file

@ -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))
}

View file

@ -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{
{

View file

@ -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()

View file

@ -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.

View file

@ -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 ""

View file

@ -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
}

View file

@ -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
View 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.")
}