Compare commits

...
Sign in to create a new pull request.

9 commits

Author SHA1 Message Date
Snider
d8144fde09 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
Snider
f9c5362151 feat(mcp): export NotifySession for raw JSON-RPC notifications
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-09 11:07:28 +01:00
Snider
8f3afaa42a refactor(mcp): migrate stdlib imports to core/go primitives + upgrade go-sdk v1.5.0
- Replace fmt/errors/strings/path/filepath with core.Sprintf, core.E,
  core.Contains, core.Path etc. across 16 files
- Remove 'errors' import from bridge.go (core.Is/core.As)
- Remove 'fmt' from transport_tcp.go, ide.go (core.Print, inline interface)
- Remove 'strings' from notify.go, transport_http.go, tools_webview.go,
  process_notifications.go (core.Trim, core.HasPrefix, core.Lower etc.)
- Upgrade go-sdk from v1.4.1 to v1.5.0
- Keep encoding/json for json.NewDecoder/MarshalIndent (no core equivalent)
- Keep os/exec in agentic subsystem (needs go-process Action wiring)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-08 22:03:52 +01:00
Snider
f8f137b465 fix(mcp): disable ListChanged to prevent premature stdio notifications
The go-sdk fires notifications/tools/list_changed and
notifications/resources/list_changed with 10ms delay after AddTool/AddResource.
Since all registration happens before server.Run(), these hit stdout
before the client sends initialize, breaking the MCP handshake.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-08 20:50:46 +01:00
Snider
429f1c2b6c Revert "perf(mcp): gate extended built-in tools behind CORE_MCP_FULL"
This reverts commit 9f7dd84d4a.
2026-04-08 20:47:34 +01:00
Snider
9f7dd84d4a perf(mcp): gate extended built-in tools behind CORE_MCP_FULL
Metrics, RAG, and webview tools only register when CORE_MCP_FULL=1.
Process and WS tools always register (used by factory).
Reduces default tool count by 15.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-08 19:17:32 +01:00
Snider
9bd3084da4 fix(mcp): bridge test body + process dep resolution
- Fix TestBridgeToAPI_Good_EndToEnd: POST with empty JSON body instead of nil
- Add local replace for go-process to resolve API drift with core v0.8.0

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-08 16:39:28 +01:00
Snider
20e4a381cf fix: migrate module paths from forge.lthn.ai to dappco.re
Update all import paths and version pins:
- forge.lthn.ai/core/go-* → dappco.re/go/core/*
- forge.lthn.ai/core/api → dappco.re/go/core/api
- forge.lthn.ai/core/cli → dappco.re/go/core/cli
- Updated: api v0.3.0, cli v0.5.2, ai v0.2.2, io v0.4.1, log v0.1.2
- Updated: process v0.5.0, rag v0.1.13, ws v0.4.0, webview v0.2.1
- Updated: i18n v0.2.3, inference v0.3.0, scm v0.6.1

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-07 12:59:22 +01:00
Snider
cd305904e5 fix: migrate module paths from forge.lthn.ai to dappco.re
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:14 +01:00
51 changed files with 557 additions and 491 deletions

View file

@ -27,8 +27,8 @@ import (
"strings" "strings"
"time" "time"
coreio "forge.lthn.ai/core/go-io" coreio "dappco.re/go/core/io"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
) )
var ( var (

View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/cli/pkg/cli"
mcpcmd "dappco.re/go/mcp/cmd/mcpcmd" mcpcmd "dappco.re/go/mcp/cmd/mcpcmd"
) )

View file

@ -13,7 +13,7 @@ import (
"dappco.re/go/mcp/pkg/mcp" "dappco.re/go/mcp/pkg/mcp"
"dappco.re/go/mcp/pkg/mcp/agentic" "dappco.re/go/mcp/pkg/mcp/agentic"
"dappco.re/go/mcp/pkg/mcp/brain" "dappco.re/go/mcp/pkg/mcp/brain"
"forge.lthn.ai/core/cli/pkg/cli" "dappco.re/go/core/cli/pkg/cli"
) )
var workspaceFlag string var workspaceFlag string

27
go.mod
View file

@ -4,26 +4,25 @@ go 1.26.0
require ( require (
dappco.re/go/core v0.8.0-alpha.1 dappco.re/go/core v0.8.0-alpha.1
forge.lthn.ai/core/api v0.1.5 dappco.re/go/core/ai v0.2.2
forge.lthn.ai/core/cli v0.3.7 dappco.re/go/core/api v0.3.0
forge.lthn.ai/core/go-ai v0.1.12 dappco.re/go/core/cli v0.5.2
forge.lthn.ai/core/go-io v0.1.7 dappco.re/go/core/io v0.4.1
forge.lthn.ai/core/go-log v0.0.4 dappco.re/go/core/log v0.1.2
forge.lthn.ai/core/go-process v0.2.9 dappco.re/go/core/process v0.5.0
forge.lthn.ai/core/go-rag v0.1.11 dappco.re/go/core/rag v0.1.13
forge.lthn.ai/core/go-webview v0.1.6 dappco.re/go/core/webview v0.2.1
forge.lthn.ai/core/go-ws v0.2.5 dappco.re/go/core/ws v0.4.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
forge.lthn.ai/core/go v0.3.3 // indirect dappco.re/go/core/i18n v0.2.3 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect dappco.re/go/core/inference v0.3.0 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
github.com/99designs/gqlgen v0.17.88 // indirect github.com/99designs/gqlgen v0.17.88 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
@ -149,3 +148,5 @@ require (
google.golang.org/grpc v1.79.2 // indirect google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
replace dappco.re/go/core/process => ../go-process

48
go.sum
View file

@ -1,29 +1,25 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o= dappco.re/go/core/ai v0.2.2 h1:fkSKm3ezAljYbghlax5qHDm11uq7LUyIedIQO1PtdcY=
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII= dappco.re/go/core/ai v0.2.2/go.mod h1:+MZN/EArn/W2ag91McL034WxdMSO4IPqFcQER5/POGU=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= dappco.re/go/core/api v0.3.0 h1:uWYgDQ+B4e5pXPX3S5lMsqSJamfpui3LWD5hcdwvWew=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= dappco.re/go/core/api v0.3.0/go.mod h1:1ZDNwPHV6YjkUsjtC3nfLk6U4eqWlQ6qj6yT/MB8r6k=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= dappco.re/go/core/cli v0.5.2 h1:mo+PERo3lUytE+r3ArHr8o2nTftXjgPPsU/rn3ETXDM=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= dappco.re/go/core/cli v0.5.2/go.mod h1:D4zfn3ec/hb72AWX/JWDvkW+h2WDKQcxGUrzoss7q2s=
forge.lthn.ai/core/go-ai v0.1.12 h1:OHt0bUABlyhvgxZxyMwueRoh8rS3YKWGFY6++zCAwC8= dappco.re/go/core/i18n v0.2.3 h1:GqFaTR1I0SfSEc4WtsAkgao+jp8X5qcMPqrX0eMAOrY=
forge.lthn.ai/core/go-ai v0.1.12/go.mod h1:5Pc9lszxgkO7Aj2Z3dtq4L9Xk9l/VNN+Baj1t///OCM= dappco.re/go/core/i18n v0.2.3/go.mod h1:LoyX/4fIEJO/wiHY3Q682+4P0Ob7zPemcATfwp0JBUg=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= dappco.re/go/core/inference v0.3.0 h1:ANFnlVO1LEYDipeDeBgqmb8CHvOTUFhMPyfyHGqO0IY=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= dappco.re/go/core/inference v0.3.0/go.mod h1:wbRY0v6iwOoJCpTvcBFarAM08bMgpPcrF6yv3vccYoA=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= dappco.re/go/core/io v0.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA=
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= dappco.re/go/core/log v0.1.2 h1:pQSZxKD8VycdvjNJmatXbPSq2OxcP2xHbF20zgFIiZI=
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= dappco.re/go/core/log v0.1.2/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= dappco.re/go/core/rag v0.1.13 h1:R2Q+Xw5YenT4uFemXLBu+xQYtyUIYGSmMln5/Z+nol4=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= dappco.re/go/core/rag v0.1.13/go.mod h1:wthXtCqYEChjlGIHcJXetlgk49lPDmzG6jFWd1PEIZc=
forge.lthn.ai/core/go-process v0.2.9 h1:Wql+5TUF+lfU2oJ9I+S764MkTqJhBsuyMM0v1zsfZC4= dappco.re/go/core/webview v0.2.1 h1:rdy2sV+MS6RZsav8BiARJxtWhfx7eOAJp3b1Ynp1sYs=
forge.lthn.ai/core/go-process v0.2.9/go.mod h1:NIzZOF5IVYYCjHkcNIGcg1mZH+bzGoie4SlZUDYOKIM= dappco.re/go/core/webview v0.2.1/go.mod h1:Qdo1V/sJJwOnL0hYd3+vzVUJxWYC8eGyILZROya6KoM=
forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8= dappco.re/go/core/ws v0.4.0 h1:yEDV9whXyo+GWzBSjuB3NiLiH2bmBPBWD6rydwHyBn8=
forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8= dappco.re/go/core/ws v0.4.0/go.mod h1:L1rrgW6zU+DztcVBJW2yO5Lm3rGXpyUMOA8OL9zsAok=
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-ws v0.2.5 h1:ZIV7Yrv01R/xpJUogA5vrfP9yB9li1w7EV3eZFMt8h0=
forge.lthn.ai/core/go-ws v0.2.5/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic= github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@ -222,8 +218,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View file

@ -4,17 +4,15 @@ package agentic
import ( import (
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -54,7 +52,7 @@ func (s *PrepSubsystem) registerDispatchTool(svc *coremcp.Service) {
// agentCommand returns the command and args for a given agent type. // agentCommand returns the command and args for a given agent type.
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku". // Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
func agentCommand(agent, prompt string) (string, []string, error) { func agentCommand(agent, prompt string) (string, []string, error) {
parts := strings.SplitN(agent, ":", 2) parts := core.SplitN(agent, ":", 2)
base := parts[0] base := parts[0]
model := "" model := ""
if len(parts) > 1 { if len(parts) > 1 {
@ -78,7 +76,7 @@ func agentCommand(agent, prompt string) (string, []string, error) {
return "claude", args, nil return "claude", args, nil
case "local": case "local":
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh") script := core.Path(home, "Code", "core", "agent", "scripts", "local-agent.sh")
return "bash", []string{script, prompt}, nil return "bash", []string{script, prompt}, nil
default: default:
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil) return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
@ -119,14 +117,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
} }
wsDir := prepOut.WorkspaceDir wsDir := prepOut.WorkspaceDir
srcDir := filepath.Join(wsDir, "src") srcDir := core.Path(wsDir, "src")
// The prompt is just: read PROMPT.md and do the work // 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 parent directory. Work in this directory." prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
if input.DryRun { if input.DryRun {
// Read PROMPT.md for the dry run output // Read PROMPT.md for the dry run output
promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md")) promptRaw, _ := coreio.Local.Read(core.Path(wsDir, "PROMPT.md"))
return nil, DispatchOutput{ return nil, DispatchOutput{
Success: true, Success: true,
Agent: input.Agent, Agent: input.Agent,
@ -181,7 +179,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
return nil, DispatchOutput{}, err return nil, DispatchOutput{}, err
} }
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent)) outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", input.Agent))
outFile, err := os.Create(outputFile) outFile, err := os.Create(outputFile)
if err != nil { if err != nil {
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err) return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)
@ -247,7 +245,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
status := "completed" status := "completed"
channel := coremcp.ChannelAgentComplete channel := coremcp.ChannelAgentComplete
payload := map[string]any{ payload := map[string]any{
"workspace": filepath.Base(wsDir), "workspace": core.PathBase(wsDir),
"repo": input.Repo, "repo": input.Repo,
"org": input.Org, "org": input.Org,
"agent": input.Agent, "agent": input.Agent,
@ -257,11 +255,11 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
// Update status to completed or blocked. // Update status to completed or blocked.
if st, err := readStatus(wsDir); err == nil { if st, err := readStatus(wsDir); err == nil {
st.PID = 0 st.PID = 0
if data, err := coreio.Local.Read(filepath.Join(wsDir, "src", "BLOCKED.md")); err == nil { if data, err := coreio.Local.Read(core.Path(wsDir, "src", "BLOCKED.md")); err == nil {
status = "blocked" status = "blocked"
channel = coremcp.ChannelAgentBlocked channel = coremcp.ChannelAgentBlocked
st.Status = status st.Status = status
st.Question = strings.TrimSpace(data) st.Question = core.Trim(data)
if st.Question != "" { if st.Question != "" {
payload["question"] = st.Question payload["question"] = st.Question
} }

View file

@ -6,12 +6,11 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -101,14 +100,14 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
} }
// Step 2: Build epic body with checklist // Step 2: Build epic body with checklist
var body strings.Builder body := core.NewBuilder()
if input.Body != "" { if input.Body != "" {
body.WriteString(input.Body) body.WriteString(input.Body)
body.WriteString("\n\n") body.WriteString("\n\n")
} }
body.WriteString("## Tasks\n\n") body.WriteString("## Tasks\n\n")
for _, child := range children { 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 // Step 3: Create epic issue
@ -157,8 +156,12 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
payload["labels"] = labelIDs payload["labels"] = labelIDs
} }
data, _ := json.Marshal(payload) r := core.JSONMarshal(payload)
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo) if !r.OK {
return ChildRef{}, coreerr.E("createIssue", "failed to encode issue payload", nil)
}
data := r.Value.([]byte)
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -170,7 +173,7 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 201 { if resp.StatusCode != 201 {
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil) return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil)
} }
var result struct { var result struct {
@ -193,7 +196,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
} }
// Fetch existing labels // 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, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -246,12 +249,16 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
colour = "#6b7280" colour = "#6b7280"
} }
payload, _ := json.Marshal(map[string]string{ r := core.JSONMarshal(map[string]string{
"name": name, "name": name,
"color": colour, "color": colour,
}) })
if !r.OK {
return 0
}
payload := r.Value.([]byte)
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, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)

View file

@ -3,17 +3,12 @@
package agentic package agentic
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
) )
// ingestFindings reads the agent output log and creates issues via the API // ingestFindings reads the agent output log and creates issues via the API
@ -25,10 +20,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
} }
// Read the log file // Read the log file
logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log"))
if err != nil {
return
}
if len(logFiles) == 0 { if len(logFiles) == 0 {
return return
} }
@ -41,7 +33,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
body := contentStr body := contentStr
// Skip quota errors // Skip quota errors
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") { if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") {
return return
} }
@ -56,13 +48,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
// Determine issue type from the template used // Determine issue type from the template used
issueType := "task" issueType := "task"
priority := "normal" priority := "normal"
if strings.Contains(body, "security") || strings.Contains(body, "Security") { if core.Contains(body, "security") || core.Contains(body, "Security") {
issueType = "bug" issueType = "bug"
priority = "high" priority = "high"
} }
// Create a single issue per repo with all findings in the body // 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 // Truncate body to reasonable size for issue description
description := body description := body
@ -86,7 +78,7 @@ func countFileRefs(body string) int {
} }
if j < len(body) && body[j] == '`' { if j < len(body) && body[j] == '`' {
ref := body[i+1 : 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++ count++
} }
} }
@ -102,25 +94,22 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
} }
// Read the agent API key from file // Read the agent API key from file
home, _ := os.UserHomeDir() home := core.Env("HOME")
apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key")) apiKeyData, err := coreio.Local.Read(core.Path(home, ".claude", "agent-api.key"))
if err != nil { if err != nil {
return false return false
} }
apiKey := strings.TrimSpace(apiKeyData) apiKey := core.Trim(apiKeyData)
payload, err := json.Marshal(map[string]string{ payloadStr := core.JSONMarshalString(map[string]string{
"title": title, "title": title,
"description": description, "description": description,
"type": issueType, "type": issueType,
"priority": priority, "priority": priority,
"reporter": "cladius", "reporter": "cladius",
}) })
if err != nil {
return false
}
req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload)) req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", core.NewReader(payloadStr))
if err != nil { if err != nil {
return false return false
} }

View file

@ -6,11 +6,11 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -77,10 +77,10 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ
return nil, DispatchOutput{}, err return nil, DispatchOutput{}, err
} }
if issue.State != "open" { if issue.State != "open" {
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil) return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil)
} }
if issue.Assignee != nil && issue.Assignee.Login != "" { if issue.Assignee != nil && issue.Assignee.Login != "" {
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil) return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil)
} }
if !input.DryRun { if !input.DryRun {
@ -124,7 +124,7 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ
func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int, labels []struct { func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int, labels []struct {
Name string `json:"name"` Name string `json:"name"`
}) error { }) error {
updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
issueLabels := make([]string, 0, len(labels)) issueLabels := make([]string, 0, len(labels))
for _, label := range labels { for _, label := range labels {
if label.Name == "in-progress" { if label.Name == "in-progress" {
@ -135,13 +135,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue
if issueLabels == nil { if issueLabels == nil {
issueLabels = []string{} issueLabels = []string{}
} }
payload, err := json.Marshal(map[string]any{ r := core.JSONMarshal(map[string]any{
"assignees": []string{}, "assignees": []string{},
"labels": issueLabels, "labels": issueLabels,
}) })
if err != nil { if !r.OK {
return coreerr.E("unlockIssue", "failed to encode issue unlock", err) return coreerr.E("unlockIssue", "failed to encode issue unlock", nil)
} }
payload := r.Value.([]byte)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
if err != nil { if err != nil {
@ -156,14 +157,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest { if resp.StatusCode >= http.StatusBadRequest {
return coreerr.E("unlockIssue", fmt.Sprintf("issue unlock returned %d", resp.StatusCode), nil) return coreerr.E("unlockIssue", core.Sprintf("issue unlock returned %d", resp.StatusCode), nil)
} }
return nil return nil
} }
func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) { func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) {
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, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, coreerr.E("fetchIssue", "failed to build request", err) return nil, coreerr.E("fetchIssue", "failed to build request", err)
@ -176,7 +177,7 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, coreerr.E("fetchIssue", fmt.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil) return nil, coreerr.E("fetchIssue", core.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil)
} }
var out forgeIssue var out forgeIssue
@ -187,14 +188,15 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue
} }
func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue int, assignee string) error { func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue int, assignee string) error {
updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
payload, err := json.Marshal(map[string]any{ r := core.JSONMarshal(map[string]any{
"assignees": []string{assignee}, "assignees": []string{assignee},
"labels": []string{"in-progress"}, "labels": []string{"in-progress"},
}) })
if err != nil { if !r.OK {
return coreerr.E("lockIssue", "failed to encode issue update", err) return coreerr.E("lockIssue", "failed to encode issue update", nil)
} }
payload := r.Value.([]byte)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
if err != nil { if err != nil {
@ -209,7 +211,7 @@ func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue i
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest { if resp.StatusCode >= http.StatusBadRequest {
return coreerr.E("lockIssue", fmt.Sprintf("issue update returned %d", resp.StatusCode), nil) return coreerr.E("lockIssue", core.Sprintf("issue update returned %d", resp.StatusCode), nil)
} }
return nil return nil

View file

@ -4,12 +4,11 @@ package agentic
import ( import (
"context" "context"
"fmt"
"os/exec" "os/exec"
"path/filepath"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -64,7 +63,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
skipped := make([]string, 0) skipped := make([]string, 0)
for _, repo := range repos { for _, repo := range repos {
repoDir := filepath.Join(basePath, repo) repoDir := core.Path(basePath, repo)
if !hasRemote(repoDir, "github") { if !hasRemote(repoDir, "github") {
skipped = append(skipped, repo+": no github remote") skipped = append(skipped, repo+": no github remote")
continue continue
@ -88,7 +87,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
} }
if files > maxFiles { 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) synced = append(synced, sync)
continue continue
} }

View file

@ -7,13 +7,12 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"path/filepath"
"strings"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -349,11 +348,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
var plans []Plan var plans []Plan
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
continue continue
} }
id := strings.TrimSuffix(entry.Name(), ".json") id := core.TrimSuffix(entry.Name(), ".json")
plan, err := readPlan(dir, id) plan, err := readPlan(dir, id)
if err != nil { if err != nil {
continue continue
@ -422,41 +421,41 @@ func (s *PrepSubsystem) planCheckpoint(_ context.Context, _ *mcp.CallToolRequest
// --- Helpers --- // --- Helpers ---
func (s *PrepSubsystem) plansDir() string { func (s *PrepSubsystem) plansDir() string {
return filepath.Join(s.codePath, ".core", "plans") return core.Path(s.codePath, ".core", "plans")
} }
func planPath(dir, id string) string { func planPath(dir, id string) string {
return filepath.Join(dir, id+".json") return core.Path(dir, id+".json")
} }
func generatePlanID(title string) string { func generatePlanID(title string) string {
slug := strings.Map(func(r rune) rune { b := core.NewBuilder()
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' { b.Grow(len(title))
return r for _, r := range title {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r + 32)
case r == ' ':
b.WriteByte('-')
} }
if r >= 'A' && r <= 'Z' {
return r + 32
} }
if r == ' ' { slug := b.String()
return '-'
}
return -1
}, title)
// Trim consecutive dashes and cap length // Collapse consecutive dashes and cap length
for strings.Contains(slug, "--") { for core.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-") slug = core.Replace(slug, "--", "-")
} }
slug = strings.Trim(slug, "-") slug = trimDashes(slug)
if len(slug) > 30 { if len(slug) > 30 {
slug = slug[:30] slug = trimDashes(slug[:30])
} }
slug = strings.TrimRight(slug, "-")
// Append short random suffix for uniqueness // Append short random suffix for uniqueness
b := make([]byte, 3) rnd := make([]byte, 3)
rand.Read(b) rand.Read(rnd)
return slug + "-" + hex.EncodeToString(b) return slug + "-" + hex.EncodeToString(rnd)
} }
func readPlan(dir, id string) (*Plan, error) { func readPlan(dir, id string) (*Plan, error) {
@ -466,8 +465,8 @@ func readPlan(dir, id string) (*Plan, error) {
} }
var plan Plan var plan Plan
if err := json.Unmarshal([]byte(data), &plan); err != nil { if r := core.JSONUnmarshal([]byte(data), &plan); !r.OK {
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err) return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil)
} }
return &plan, nil return &plan, nil
} }

View file

@ -6,15 +6,13 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os/exec" "os/exec"
"path/filepath"
"strings"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -66,8 +64,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil) return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
} }
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) wsDir := core.Path(s.workspaceRoot(), input.Workspace)
srcDir := filepath.Join(wsDir, "src") srcDir := core.Path(wsDir, "src")
if _, err := coreio.Local.List(srcDir); err != nil { if _, err := coreio.Local.List(srcDir); err != nil {
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil) return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
@ -87,7 +85,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
if err != nil { if err != nil {
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err) return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err)
} }
st.Branch = strings.TrimSpace(string(out)) st.Branch = core.Trim(string(out))
} }
org := st.Org org := st.Org
@ -105,7 +103,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
title = st.Task title = st.Task
} }
if title == "" { if title == "" {
title = fmt.Sprintf("Agent work on %s", st.Branch) title = core.Sprintf("Agent work on %s", st.Branch)
} }
// Build PR body // Build PR body
@ -143,7 +141,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
// Comment on issue if tracked // Comment on issue if tracked
if st.Issue > 0 { 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) s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment)
} }
@ -159,17 +157,17 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
} }
func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string { func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string {
var b strings.Builder b := core.NewBuilder()
b.WriteString("## Summary\n\n") b.WriteString("## Summary\n\n")
if st.Task != "" { if st.Task != "" {
b.WriteString(st.Task) b.WriteString(st.Task)
b.WriteString("\n\n") b.WriteString("\n\n")
} }
if st.Issue > 0 { 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(core.Sprintf("**Agent:** %s\n", st.Agent))
b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs)) b.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs))
b.WriteString("\n---\n*Created by agentic dispatch*\n") b.WriteString("\n---\n*Created by agentic dispatch*\n")
return b.String() return b.String()
} }
@ -185,7 +183,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
return "", 0, coreerr.E("forgeCreatePR", "failed to marshal PR payload", err) return "", 0, coreerr.E("forgeCreatePR", "failed to marshal PR payload", err)
} }
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, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
if err != nil { if err != nil {
return "", 0, coreerr.E("forgeCreatePR", "failed to build PR request", err) return "", 0, coreerr.E("forgeCreatePR", "failed to build PR request", err)
@ -202,10 +200,10 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
if resp.StatusCode != 201 { if resp.StatusCode != 201 {
var errBody map[string]any var errBody map[string]any
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil { if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err) return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err)
} }
msg, _ := errBody["message"].(string) msg, _ := errBody["message"].(string)
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil) return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil)
} }
var pr struct { var pr struct {
@ -225,7 +223,7 @@ func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, is
return return
} }
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, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload)) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
if err != nil { if err != nil {
return return
@ -337,7 +335,7 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp
} }
func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) { 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) s.forgeURL, org, repo, state)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -348,7 +346,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
} }
var prs []struct { var prs []struct {

View file

@ -8,18 +8,14 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
goio "io"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -46,17 +42,17 @@ var (
// //
// prep := NewPrep() // prep := NewPrep()
func NewPrep() *PrepSubsystem { func NewPrep() *PrepSubsystem {
home, _ := os.UserHomeDir() home := core.Env("HOME")
forgeToken := os.Getenv("FORGE_TOKEN") forgeToken := core.Env("FORGE_TOKEN")
if forgeToken == "" { if forgeToken == "" {
forgeToken = os.Getenv("GITEA_TOKEN") forgeToken = core.Env("GITEA_TOKEN")
} }
brainKey := os.Getenv("CORE_BRAIN_KEY") brainKey := core.Env("CORE_BRAIN_KEY")
if brainKey == "" { if brainKey == "" {
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil { if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil {
brainKey = strings.TrimSpace(data) brainKey = core.Trim(data)
} }
} }
@ -65,8 +61,8 @@ func NewPrep() *PrepSubsystem {
forgeToken: forgeToken, forgeToken: forgeToken,
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
brainKey: brainKey, brainKey: brainKey,
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")), specsPath: envOr("SPECS_PATH", core.Path(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")), codePath: envOr("CODE_PATH", core.Path(home, "Code")),
client: &http.Client{Timeout: 30 * time.Second}, client: &http.Client{Timeout: 30 * time.Second},
} }
} }
@ -84,24 +80,24 @@ func (s *PrepSubsystem) emitChannel(ctx context.Context, channel string, data an
} }
func envOr(key, fallback string) string { func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := core.Env(key); v != "" {
return v return v
} }
return fallback return fallback
} }
func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) { func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) {
if strings.TrimSpace(value) != value { if core.Trim(value) != value {
return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil) return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil)
} }
if value == "" { if value == "" {
return "", nil return "", nil
} }
if strings.Contains(value, "\\") { if core.Contains(value, "\\") {
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil) return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
} }
parts := strings.Split(value, "/") parts := core.Split(value, "/")
if !allowSubdirs && len(parts) != 1 { if !allowSubdirs && len(parts) != 1 {
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil) return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
} }
@ -161,7 +157,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
// workspaceRoot returns the base directory for agent workspaces. // workspaceRoot returns the base directory for agent workspaces.
func (s *PrepSubsystem) workspaceRoot() string { func (s *PrepSubsystem) workspaceRoot() string {
return filepath.Join(s.codePath, ".core", "workspace") return core.Path(s.codePath, ".core", "workspace")
} }
// --- Input/Output types --- // --- Input/Output types ---
@ -227,8 +223,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// Workspace root: .core/workspace/{repo}-{timestamp}/ // Workspace root: .core/workspace/{repo}-{timestamp}/
wsRoot := s.workspaceRoot() wsRoot := s.workspaceRoot()
coreio.Local.EnsureDir(wsRoot) coreio.Local.EnsureDir(wsRoot)
wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix()) wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix())
wsDir := filepath.Join(wsRoot, wsName) wsDir := core.Path(wsRoot, wsName)
// Create workspace structure // Create workspace structure
// kb/ and specs/ will be created inside src/ after clone // kb/ and specs/ will be created inside src/ after clone
@ -236,10 +232,10 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
out := PrepOutput{WorkspaceDir: wsDir} out := PrepOutput{WorkspaceDir: wsDir}
// Source repo path // Source repo path
repoPath := filepath.Join(s.codePath, "core", input.Repo) repoPath := core.Path(s.codePath, "core", input.Repo)
// 1. Clone repo into src/ and create feature branch // 1. Clone repo into src/ and create feature branch
srcDir := filepath.Join(wsDir, "src") srcDir := core.Path(wsDir, "src")
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir) cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
if err := cloneCmd.Run(); err != nil { if err := cloneCmd.Run(); err != nil {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err) return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
@ -251,12 +247,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
taskSlug := branchSlug(input.Task) taskSlug := branchSlug(input.Task)
if input.Issue > 0 { if input.Issue > 0 {
issueSlug := branchSlug(input.Task) issueSlug := branchSlug(input.Task)
branchName = fmt.Sprintf("agent/issue-%d", input.Issue) branchName = core.Sprintf("agent/issue-%d", input.Issue)
if issueSlug != "" { if issueSlug != "" {
branchName += "-" + issueSlug branchName += "-" + issueSlug
} }
} else if taskSlug != "" { } else if taskSlug != "" {
branchName = fmt.Sprintf("agent/%s", taskSlug) branchName = core.Sprintf("agent/%s", taskSlug)
} }
} }
if branchName != "" { if branchName != "" {
@ -269,29 +265,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
} }
// Create context dirs inside src/ // Create context dirs inside src/
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb")) coreio.Local.EnsureDir(core.Path(srcDir, "kb"))
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs")) coreio.Local.EnsureDir(core.Path(srcDir, "specs"))
// Remote stays as local clone origin — agent cannot push to forge. // Remote stays as local clone origin — agent cannot push to forge.
// Reviewer pulls changes from workspace and pushes after verification. // Reviewer pulls changes from workspace and pushes after verification.
// 2. Copy CLAUDE.md and GEMINI.md to workspace // 2. Copy CLAUDE.md and GEMINI.md to workspace
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md") claudeMdPath := core.Path(repoPath, "CLAUDE.md")
if data, err := coreio.Local.Read(claudeMdPath); err == nil { if data, err := coreio.Local.Read(claudeMdPath); err == nil {
_ = writeAtomic(filepath.Join(wsDir, "src", "CLAUDE.md"), data) _ = writeAtomic(core.Path(wsDir, "src", "CLAUDE.md"), data)
out.ClaudeMd = true out.ClaudeMd = true
} }
// Copy GEMINI.md from core/agent (ethics framework for all agents) // Copy GEMINI.md from core/agent (ethics framework for all agents)
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md") agentGeminiMd := core.Path(s.codePath, "core", "agent", "GEMINI.md")
if data, err := coreio.Local.Read(agentGeminiMd); err == nil { if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
_ = writeAtomic(filepath.Join(wsDir, "src", "GEMINI.md"), data) _ = writeAtomic(core.Path(wsDir, "src", "GEMINI.md"), data)
} }
// Copy persona if specified // Copy persona if specified
if persona != "" { if persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md") personaPath := core.Path(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
if data, err := coreio.Local.Read(personaPath); err == nil { if data, err := coreio.Local.Read(personaPath); err == nil {
_ = writeAtomic(filepath.Join(wsDir, "src", "PERSONA.md"), data) _ = writeAtomic(core.Path(wsDir, "src", "PERSONA.md"), data)
} }
} }
@ -299,9 +295,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
if input.Issue > 0 { if input.Issue > 0 {
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir) s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
} else if input.Task != "" { } else if input.Task != "" {
todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n", todo := core.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n",
input.Task, input.Org, input.Repo, input.Task) input.Task, input.Org, input.Repo, input.Task)
_ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), todo) _ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), todo)
} }
// 4. Generate CONTEXT.md from OpenBrain // 4. Generate CONTEXT.md from OpenBrain
@ -333,12 +329,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// branchSlug converts a free-form string into a git-friendly branch suffix. // branchSlug converts a free-form string into a git-friendly branch suffix.
func branchSlug(value string) string { func branchSlug(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = core.Lower(core.Trim(value))
if value == "" { if value == "" {
return "" return ""
} }
var b strings.Builder b := core.NewBuilder()
b.Grow(len(value)) b.Grow(len(value))
lastDash := false lastDash := false
for _, r := range value { for _, r := range value {
@ -359,14 +355,42 @@ func branchSlug(value string) string {
} }
} }
slug := strings.Trim(b.String(), "-") slug := trimDashes(b.String())
if len(slug) > 40 { if len(slug) > 40 {
slug = slug[:40] slug = trimDashes(slug[:40])
slug = strings.Trim(slug, "-")
} }
return slug return slug
} }
// sanitizeFilename replaces non-alphanumeric characters (except - _ .) with dashes.
func sanitizeFilename(title string) string {
b := core.NewBuilder()
b.Grow(len(title))
for _, r := range title {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
return b.String()
}
// trimDashes strips leading and trailing dash characters from a string.
func trimDashes(s string) string {
start := 0
for start < len(s) && s[start] == '-' {
start++
}
end := len(s)
for end > start && s[end-1] == '-' {
end--
}
return s[start:end]
}
// --- Prompt templates --- // --- Prompt templates ---
func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) { func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) {
@ -434,7 +458,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
prompt = "Read TODO.md and complete the task. Work in src/.\n" prompt = "Read TODO.md and complete the task. Work in src/.\n"
} }
_ = writeAtomic(filepath.Join(wsDir, "src", "PROMPT.md"), prompt) _ = writeAtomic(core.Path(wsDir, "src", "PROMPT.md"), prompt)
} }
// --- Plan template rendering --- // --- Plan template rendering ---
@ -443,11 +467,11 @@ Do NOT push. Commit only — a reviewer will verify and push.
// and writes PLAN.md into the workspace src/ directory. // and writes PLAN.md into the workspace src/ directory.
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) { func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
// Look for template in core/agent/prompts/templates/ // Look for template in core/agent/prompts/templates/
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml") templatePath := core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
content, err := coreio.Local.Read(templatePath) content, err := coreio.Local.Read(templatePath)
if err != nil { if err != nil {
// Try .yml extension // Try .yml extension
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml") templatePath = core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
content, err = coreio.Local.Read(templatePath) content, err = coreio.Local.Read(templatePath)
if err != nil { if err != nil {
return // Template not found, skip silently return // Template not found, skip silently
@ -456,8 +480,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
// Substitute variables ({{variable_name}} → value) // Substitute variables ({{variable_name}} → value)
for key, value := range variables { for key, value := range variables {
content = strings.ReplaceAll(content, "{{"+key+"}}", value) content = core.Replace(content, "{{"+key+"}}", value)
content = strings.ReplaceAll(content, "{{ "+key+" }}", value) content = core.Replace(content, "{{ "+key+" }}", value)
} }
// Parse the YAML to render as markdown // Parse the YAML to render as markdown
@ -477,7 +501,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
} }
// Render as PLAN.md // Render as PLAN.md
var plan strings.Builder plan := core.NewBuilder()
plan.WriteString("# Plan: " + tmpl.Name + "\n\n") plan.WriteString("# Plan: " + tmpl.Name + "\n\n")
if task != "" { if task != "" {
plan.WriteString("**Task:** " + task + "\n\n") plan.WriteString("**Task:** " + task + "\n\n")
@ -495,7 +519,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
} }
for i, phase := range tmpl.Phases { 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 != "" { if phase.Description != "" {
plan.WriteString(phase.Description + "\n\n") plan.WriteString(phase.Description + "\n\n")
} }
@ -512,7 +536,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
} }
_ = writeAtomic(filepath.Join(wsDir, "src", "PLAN.md"), plan.String()) _ = writeAtomic(core.Path(wsDir, "src", "PLAN.md"), plan.String())
} }
// --- Helpers (unchanged) --- // --- Helpers (unchanged) ---
@ -522,7 +546,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
return 0 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, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return 0 return 0
@ -553,7 +577,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
subURL = page.Title 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, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil) pageReq, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil)
if err != nil { if err != nil {
continue continue
@ -585,14 +609,9 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
if err != nil { if err != nil {
continue continue
} }
filename := strings.Map(func(r rune) rune { filename := sanitizeFilename(page.Title) + ".md"
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
return r
}
return '-'
}, page.Title) + ".md"
_ = writeAtomic(filepath.Join(wsDir, "src", "kb", filename), string(content)) _ = writeAtomic(core.Path(wsDir, "src", "kb", filename), string(content))
count++ count++
} }
@ -604,9 +623,9 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int {
count := 0 count := 0
for _, file := range specFiles { for _, file := range specFiles {
src := filepath.Join(s.specsPath, file) src := core.Path(s.specsPath, file)
if data, err := coreio.Local.Read(src); err == nil { if data, err := coreio.Local.Read(src); err == nil {
_ = writeAtomic(filepath.Join(wsDir, "src", "specs", file), data) _ = writeAtomic(core.Path(wsDir, "src", "specs", file), data)
count++ count++
} }
} }
@ -629,7 +648,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
return 0 return 0
} }
req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body))) req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(string(body)))
if err != nil { if err != nil {
return 0 return 0
} }
@ -646,18 +665,18 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
return 0 return 0
} }
respData, err := goio.ReadAll(resp.Body) readResult := core.ReadAll(resp.Body)
if err != nil { if !readResult.OK {
return 0 return 0
} }
var result struct { var result struct {
Memories []map[string]any `json:"memories"` Memories []map[string]any `json:"memories"`
} }
if err := json.Unmarshal(respData, &result); err != nil { if ur := core.JSONUnmarshal([]byte(readResult.Value.(string)), &result); !ur.OK {
return 0 return 0
} }
var content strings.Builder content := core.NewBuilder()
content.WriteString("# Context — " + repo + "\n\n") content.WriteString("# Context — " + repo + "\n\n")
content.WriteString("> Relevant knowledge from OpenBrain.\n\n") content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
@ -666,15 +685,15 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
memContent, _ := mem["content"].(string) memContent, _ := mem["content"].(string)
memProject, _ := mem["project"].(string) memProject, _ := mem["project"].(string)
score, _ := mem["score"].(float64) 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))
} }
_ = writeAtomic(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String()) _ = writeAtomic(core.Path(wsDir, "src", "CONTEXT.md"), content.String())
return len(result.Memories) return len(result.Memories)
} }
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
goWorkPath := filepath.Join(s.codePath, "go.work") goWorkPath := core.Path(s.codePath, "go.work")
modulePath := "forge.lthn.ai/core/" + repo modulePath := "forge.lthn.ai/core/" + repo
workData, err := coreio.Local.Read(goWorkPath) workData, err := coreio.Local.Read(goWorkPath)
@ -683,19 +702,19 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
} }
var consumers []string var consumers []string
for _, line := range strings.Split(workData, "\n") { for _, line := range core.Split(workData, "\n") {
line = strings.TrimSpace(line) line = core.Trim(line)
if !strings.HasPrefix(line, "./") { if !core.HasPrefix(line, "./") {
continue continue
} }
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./")) dir := core.Path(s.codePath, core.TrimPrefix(line, "./"))
goMod := filepath.Join(dir, "go.mod") goMod := core.Path(dir, "go.mod")
modData, err := coreio.Local.Read(goMod) modData, err := coreio.Local.Read(goMod)
if err != nil { if err != nil {
continue continue
} }
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)) consumers = append(consumers, core.PathBase(dir))
} }
} }
@ -705,8 +724,8 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
for _, c := range consumers { for _, c := range consumers {
content += "- " + c + "\n" content += "- " + c + "\n"
} }
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) content += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
_ = writeAtomic(filepath.Join(wsDir, "src", "CONSUMERS.md"), content) _ = writeAtomic(core.Path(wsDir, "src", "CONSUMERS.md"), content)
} }
return len(consumers) return len(consumers)
@ -720,10 +739,10 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
return 0 return 0
} }
lines := strings.Split(strings.TrimSpace(string(output)), "\n") lines := core.Split(core.Trim(string(output)), "\n")
if len(lines) > 0 && lines[0] != "" { if len(lines) > 0 && lines[0] != "" {
content := "# Recent Changes\n\n```\n" + string(output) + "```\n" content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
_ = writeAtomic(filepath.Join(wsDir, "src", "RECENT.md"), content) _ = writeAtomic(core.Path(wsDir, "src", "RECENT.md"), content)
} }
return len(lines) return len(lines)
@ -734,7 +753,7 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
return 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, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -753,11 +772,11 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
} }
json.NewDecoder(resp.Body).Decode(&issueData) json.NewDecoder(resp.Body).Decode(&issueData)
content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title) content := core.Sprintf("# TASK: %s\n\n", issueData.Title)
content += fmt.Sprintf("**Status:** ready\n") content += core.Sprintf("**Status:** ready\n")
content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) content += core.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("**Repo:** %s/%s\n\n---\n\n", org, repo)
content += "## Objective\n\n" + issueData.Body + "\n" content += "## Objective\n\n" + issueData.Body + "\n"
_ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), content) _ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), content)
} }

View file

@ -3,18 +3,19 @@
package agentic package agentic
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
coreio "forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// os.Create, os.Open, os.DevNull, os.Environ, os.FindProcess are used for
// process spawning and management — no core equivalents for these OS primitives.
// DispatchConfig controls agent dispatch behaviour. // DispatchConfig controls agent dispatch behaviour.
type DispatchConfig struct { type DispatchConfig struct {
DefaultAgent string `yaml:"default_agent"` DefaultAgent string `yaml:"default_agent"`
@ -43,7 +44,7 @@ type AgentsConfig struct {
// loadAgentsConfig reads config/agents.yaml from the code path. // loadAgentsConfig reads config/agents.yaml from the code path.
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig { func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
paths := []string{ paths := []string{
filepath.Join(s.codePath, ".core", "agents.yaml"), core.Path(s.codePath, ".core", "agents.yaml"),
} }
for _, path := range paths { for _, path := range paths {
@ -79,9 +80,16 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
return 0 return 0
} }
// Parse reset time // Parse reset time (format: "HH:MM")
resetHour, resetMin := 6, 0 resetHour, resetMin := 6, 0
fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin) if parts := core.Split(rate.ResetUTC, ":"); len(parts) == 2 {
if h, ok := parseSimpleInt(parts[0]); ok {
resetHour = h
}
if m, ok := parseSimpleInt(parts[1]); ok {
resetMin = m
}
}
now := time.Now().UTC() now := time.Now().UTC()
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC) resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
@ -115,9 +123,9 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
} }
path := filepath.Join(wsRoot, entry.Name()) path := core.Path(wsRoot, entry.Name())
// Check if this dir has a status.json (it's a workspace) // Check if this dir has a status.json (it's a workspace)
if coreio.Local.IsFile(filepath.Join(path, "status.json")) { if coreio.Local.IsFile(core.Path(path, "status.json")) {
dirs = append(dirs, path) dirs = append(dirs, path)
continue continue
} }
@ -128,8 +136,8 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
} }
for _, sub := range subEntries { for _, sub := range subEntries {
if sub.IsDir() { if sub.IsDir() {
subPath := filepath.Join(path, sub.Name()) subPath := core.Path(path, sub.Name())
if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) { if coreio.Local.IsFile(core.Path(subPath, "status.json")) {
dirs = append(dirs, subPath) dirs = append(dirs, subPath)
} }
} }
@ -146,7 +154,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
if err != nil || st.Status != "running" { if err != nil || st.Status != "running" {
continue continue
} }
stBase := strings.SplitN(st.Agent, ":", 2)[0] stBase := core.SplitN(st.Agent, ":", 2)[0]
if stBase != agent { if stBase != agent {
continue continue
} }
@ -162,7 +170,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
// baseAgent strips the model variant (gemini:flash → gemini). // baseAgent strips the model variant (gemini:flash → gemini).
func baseAgent(agent string) string { 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. // canDispatchAgent checks if we're under the concurrency limit for a specific agent type.
@ -176,6 +184,23 @@ func (s *PrepSubsystem) canDispatchAgent(agent string) bool {
return s.countRunningByAgent(base) < limit return s.countRunningByAgent(base) < limit
} }
// parseSimpleInt parses a small non-negative integer from a string.
// Returns (value, true) on success, (0, false) on failure.
func parseSimpleInt(s string) (int, bool) {
s = core.Trim(s)
if s == "" {
return 0, false
}
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, false
}
n = n*10 + int(r-'0')
}
return n, true
}
// canDispatch is kept for backwards compat. // canDispatch is kept for backwards compat.
func (s *PrepSubsystem) canDispatch() bool { func (s *PrepSubsystem) canDispatch() bool {
return true return true
@ -205,7 +230,7 @@ func (s *PrepSubsystem) drainQueue() {
continue continue
} }
srcDir := filepath.Join(wsDir, "src") srcDir := core.Path(wsDir, "src")
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory." prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
command, args, err := agentCommand(st.Agent, prompt) command, args, err := agentCommand(st.Agent, prompt)
@ -213,7 +238,7 @@ func (s *PrepSubsystem) drainQueue() {
continue continue
} }
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent)) outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", st.Agent))
outFile, err := os.Create(outputFile) outFile, err := os.Create(outputFile)
if err != nil { if err != nil {
continue continue

View file

@ -5,19 +5,18 @@ package agentic
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
) )
func listLocalRepos(basePath string) []string { func listLocalRepos(basePath string) []string {
entries, err := os.ReadDir(basePath) entries, err := coreio.Local.List(basePath)
if err != nil { if err != nil {
return nil return nil
} }
@ -35,7 +34,7 @@ func hasRemote(repoDir, remote string) bool {
cmd := exec.Command("git", "remote", "get-url", remote) cmd := exec.Command("git", "remote", "get-url", remote)
cmd.Dir = repoDir cmd.Dir = repoDir
if out, err := cmd.Output(); err == nil { if out, err := cmd.Output(); err == nil {
return strings.TrimSpace(string(out)) != "" return core.Trim(string(out)) != ""
} }
return false return false
} }
@ -48,7 +47,7 @@ func commitsAhead(repoDir, baseRef, headRef string) int {
return 0 return 0
} }
count, err := parsePositiveInt(strings.TrimSpace(string(out))) count, err := parsePositiveInt(core.Trim(string(out)))
if err != nil { if err != nil {
return 0 return 0
} }
@ -64,8 +63,8 @@ func filesChanged(repoDir, baseRef, headRef string) int {
} }
count := 0 count := 0
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range core.Split(core.Trim(string(out)), "\n") {
if strings.TrimSpace(line) != "" { if core.Trim(line) != "" {
count++ count++
} }
} }
@ -79,11 +78,11 @@ func gitOutput(repoDir string, args ...string) (string, error) {
if err != nil { if err != nil {
return "", coreerr.E("gitOutput", string(out), err) return "", coreerr.E("gitOutput", string(out), err)
} }
return strings.TrimSpace(string(out)), nil return core.Trim(string(out)), nil
} }
func parsePositiveInt(value string) (int, error) { func parsePositiveInt(value string) (int, error) {
value = strings.TrimSpace(value) value = core.Trim(value)
if value == "" { if value == "" {
return 0, coreerr.E("parsePositiveInt", "empty value", nil) return 0, coreerr.E("parsePositiveInt", "empty value", nil)
} }
@ -148,11 +147,11 @@ func createGitHubPR(ctx context.Context, repoDir, repo string, commits, files in
return "", coreerr.E("createGitHubPR", string(out), err) return "", coreerr.E("createGitHubPR", string(out), err)
} }
lines := strings.Split(strings.TrimSpace(string(out)), "\n") lines := core.Split(core.Trim(string(out)), "\n")
if len(lines) == 0 { if len(lines) == 0 {
return "", nil return "", nil
} }
return strings.TrimSpace(lines[len(lines)-1]), nil return core.Trim(lines[len(lines)-1]), nil
} }
func ensureDevBranch(repoDir string) error { func ensureDevBranch(repoDir string) error {
@ -194,7 +193,7 @@ func parseRetryAfter(detail string) time.Duration {
return 5 * time.Minute return 5 * time.Minute
} }
switch strings.ToLower(match[2]) { switch core.Lower(match[2]) {
case "hour", "hours": case "hour", "hours":
return time.Duration(n) * time.Hour return time.Duration(n) * time.Hour
case "second", "seconds": case "second", "seconds":
@ -205,5 +204,5 @@ func parseRetryAfter(detail string) time.Duration {
} }
func repoRootFromCodePath(codePath string) string { func repoRootFromCodePath(codePath string) string {
return filepath.Join(codePath, "core") return core.Path(codePath, "core")
} }

View file

@ -4,16 +4,14 @@ package agentic
import ( import (
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"syscall" "syscall"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -52,8 +50,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil) return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
} }
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace) wsDir := core.Path(s.workspaceRoot(), input.Workspace)
srcDir := filepath.Join(wsDir, "src") srcDir := core.Path(wsDir, "src")
// Verify workspace exists // Verify workspace exists
if _, err := coreio.Local.List(srcDir); err != nil { if _, err := coreio.Local.List(srcDir); err != nil {
@ -78,8 +76,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
// Write ANSWER.md if answer provided // Write ANSWER.md if answer provided
if input.Answer != "" { if input.Answer != "" {
answerPath := filepath.Join(srcDir, "ANSWER.md") answerPath := core.Path(srcDir, "ANSWER.md")
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer) content := core.Sprintf("# Answer\n\n%s\n", input.Answer)
if err := writeAtomic(answerPath, content); err != nil { if err := writeAtomic(answerPath, content); err != nil {
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err) return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
} }
@ -102,7 +100,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
} }
// Spawn agent as detached process (survives parent death) // Spawn agent as detached process (survives parent death)
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s-run%d.log", agent, st.Runs+1)) outputFile := core.Path(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
command, args, err := agentCommand(agent, prompt) command, args, err := agentCommand(agent, prompt)
if err != nil { if err != nil {
@ -154,10 +152,10 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
"branch": st.Branch, "branch": st.Branch,
} }
if data, err := coreio.Local.Read(filepath.Join(srcDir, "BLOCKED.md")); err == nil { if data, err := coreio.Local.Read(core.Path(srcDir, "BLOCKED.md")); err == nil {
status = "blocked" status = "blocked"
channel = coremcp.ChannelAgentBlocked channel = coremcp.ChannelAgentBlocked
st.Question = strings.TrimSpace(data) st.Question = core.Trim(data)
if st.Question != "" { if st.Question != "" {
payload["question"] = st.Question payload["question"] = st.Question
} }

View file

@ -5,16 +5,14 @@ package agentic
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strings"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -93,7 +91,7 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest,
continue continue
} }
repoDir := filepath.Join(basePath, repo) repoDir := core.Path(basePath, repo)
reviewer := input.Reviewer reviewer := input.Reviewer
if reviewer == "" { if reviewer == "" {
reviewer = "coderabbit" reviewer = "coderabbit"
@ -137,7 +135,7 @@ func (s *PrepSubsystem) findReviewCandidates(basePath string) []string {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
} }
repoDir := filepath.Join(basePath, entry.Name()) repoDir := core.Path(basePath, entry.Name())
if !hasRemote(repoDir, "github") { if !hasRemote(repoDir, "github") {
continue continue
} }
@ -154,22 +152,22 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) { if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) {
result.Verdict = "rate_limited" 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 return result
} }
cmd := reviewerCommand(ctx, repoDir, reviewer) cmd := reviewerCommand(ctx, repoDir, reviewer)
cmd.Dir = repoDir cmd.Dir = repoDir
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out)) output := core.Trim(string(out))
if strings.Contains(strings.ToLower(output), "rate limit") { if core.Contains(core.Lower(output), "rate limit") {
result.Verdict = "rate_limited" result.Verdict = "rate_limited"
result.Detail = output result.Detail = output
return result return result
} }
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.Verdict = "error"
if output != "" { if output != "" {
result.Detail = output result.Detail = output
@ -182,7 +180,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
s.storeReviewOutput(repoDir, repo, reviewer, output) s.storeReviewOutput(repoDir, repo, reviewer, output)
result.Findings = countFindingHints(output) result.Findings = countFindingHints(output)
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.Verdict = "clean"
if dryRun { if dryRun {
result.Action = "skipped (dry run)" result.Action = "skipped (dry run)"
@ -198,7 +196,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
mergeCmd.Dir = repoDir mergeCmd.Dir = repoDir
if mergeOut, err := mergeCmd.CombinedOutput(); err == nil { if mergeOut, err := mergeCmd.CombinedOutput(); err == nil {
result.Action = "merged" result.Action = "merged"
result.Detail = strings.TrimSpace(string(mergeOut)) result.Detail = core.Trim(string(mergeOut))
return result return result
} }
} }
@ -219,7 +217,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) { func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) {
home := reviewQueueHomeDir() home := reviewQueueHomeDir()
dataDir := filepath.Join(home, ".core", "training", "reviews") dataDir := core.Path(home, ".core", "training", "reviews")
if err := coreio.Local.EnsureDir(dataDir); err != nil { if err := coreio.Local.EnsureDir(dataDir); err != nil {
return return
} }
@ -235,13 +233,13 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string
return return
} }
name := fmt.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix()) name := core.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix())
_ = writeAtomic(filepath.Join(dataDir, name), string(data)) _ = writeAtomic(core.Path(dataDir, name), string(data))
} }
func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) { func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
home := reviewQueueHomeDir() home := reviewQueueHomeDir()
path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") path := core.Path(home, ".core", "coderabbit-ratelimit.json")
data, err := json.Marshal(info) data, err := json.Marshal(info)
if err != nil { if err != nil {
return return
@ -251,7 +249,7 @@ func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo { func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo {
home := reviewQueueHomeDir() home := reviewQueueHomeDir()
path := filepath.Join(home, ".core", "coderabbit-ratelimit.json") path := core.Path(home, ".core", "coderabbit-ratelimit.json")
data, err := coreio.Local.Read(path) data, err := coreio.Local.Read(path)
if err != nil { if err != nil {
return nil return nil

View file

@ -5,11 +5,10 @@ package agentic
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -81,7 +80,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
seen := make(map[string]bool) seen := make(map[string]bool)
var unique []ScanIssue var unique []ScanIssue
for _, issue := range allIssues { 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] { if !seen[key] {
seen[key] = true seen[key] = true
unique = append(unique, issue) unique = append(unique, issue)
@ -100,7 +99,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
} }
func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) { func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) {
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org) url := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -110,7 +109,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil) return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
} }
var repos []struct { var repos []struct {
@ -126,7 +125,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
} }
func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) { func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues", url := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
s.forgeURL, org, repo, label) s.forgeURL, org, repo, label)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "token "+s.forgeToken) req.Header.Set("Authorization", "token "+s.forgeToken)
@ -137,7 +136,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil) return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
} }
var issues []struct { var issues []struct {
@ -170,7 +169,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
Title: issue.Title, Title: issue.Title,
Labels: labels, Labels: labels,
Assignee: assignee, Assignee: assignee,
URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1), URL: core.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL),
}) })
} }

View file

@ -6,16 +6,18 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath"
"strings"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
// os.Stat and os.FindProcess are used for workspace age detection and PID
// liveness checks — these are OS-level queries with no core equivalent.
// Workspace status file convention: // Workspace status file convention:
// //
// {workspace}/status.json — current state of the workspace // {workspace}/status.json — current state of the workspace
@ -57,23 +59,23 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error {
if err != nil { if err != nil {
return err return err
} }
return writeAtomic(filepath.Join(wsDir, "status.json"), string(data)) return writeAtomic(core.JoinPath(wsDir, "status.json"), string(data))
} }
func (s *PrepSubsystem) saveStatus(wsDir string, status *WorkspaceStatus) { func (s *PrepSubsystem) saveStatus(wsDir string, status *WorkspaceStatus) {
if err := writeStatus(wsDir, status); err != nil { if err := writeStatus(wsDir, status); err != nil {
coreerr.Warn("failed to write workspace status", "workspace", filepath.Base(wsDir), "err", err) coreerr.Warn("failed to write workspace status", "workspace", core.PathBase(wsDir), "err", err)
} }
} }
func readStatus(wsDir string) (*WorkspaceStatus, error) { func readStatus(wsDir string) (*WorkspaceStatus, error) {
data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json")) data, err := coreio.Local.Read(core.JoinPath(wsDir, "status.json"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var s WorkspaceStatus var s WorkspaceStatus
if err := json.Unmarshal([]byte(data), &s); err != nil { if r := core.JSONUnmarshal([]byte(data), &s); !r.OK {
return nil, err return nil, coreerr.E("readStatus", "failed to parse status.json", nil)
} }
return &s, nil return &s, nil
} }
@ -126,7 +128,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
var workspaces []WorkspaceInfo var workspaces []WorkspaceInfo
for _, wsDir := range wsDirs { for _, wsDir := range wsDirs {
name := filepath.Base(wsDir) name := core.PathBase(wsDir)
// Filter by specific workspace if requested // Filter by specific workspace if requested
if input.Workspace != "" && name != input.Workspace { if input.Workspace != "" && name != input.Workspace {
@ -139,7 +141,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
st, err := readStatus(wsDir) st, err := readStatus(wsDir)
if err != nil { if err != nil {
// Legacy workspace (no status.json) — check for log file // Legacy workspace (no status.json) — check for log file
logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log")) logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log"))
if len(logFiles) > 0 { if len(logFiles) > 0 {
info.Status = "completed" info.Status = "completed"
} else { } else {
@ -177,10 +179,10 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
} }
// Process died — check for BLOCKED.md // Process died — check for BLOCKED.md
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md") blockedPath := core.Path(wsDir, "src", "BLOCKED.md")
if data, err := coreio.Local.Read(blockedPath); err == nil { if data, err := coreio.Local.Read(blockedPath); err == nil {
info.Status = "blocked" info.Status = "blocked"
info.Question = strings.TrimSpace(data) info.Question = core.Trim(data)
st.Status = "blocked" st.Status = "blocked"
st.Question = info.Question st.Question = info.Question
status = "blocked" status = "blocked"

View file

@ -4,11 +4,11 @@ package agentic
import ( import (
"context" "context"
"path/filepath"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -153,15 +153,15 @@ func (s *PrepSubsystem) findActiveWorkspaces() []string {
} }
switch st.Status { switch st.Status {
case "running", "queued": case "running", "queued":
active = append(active, filepath.Base(wsDir)) active = append(active, core.PathBase(wsDir))
} }
} }
return active return active
} }
func (s *PrepSubsystem) resolveWorkspaceDir(name string) string { func (s *PrepSubsystem) resolveWorkspaceDir(name string) string {
if filepath.IsAbs(name) { if core.PathIsAbs(name) {
return name return name
} }
return filepath.Join(s.workspaceRoot(), name) return core.JoinPath(s.workspaceRoot(), name)
} }

View file

@ -4,23 +4,26 @@ package agentic
import ( import (
"os" "os"
"path/filepath"
coreio "forge.lthn.ai/core/go-io" core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
) )
// os.CreateTemp, os.Remove, os.Rename are framework-boundary calls for
// atomic file writes — no core equivalent exists for temp file creation.
// writeAtomic writes content to path by staging it in a temporary file and // writeAtomic writes content to path by staging it in a temporary file and
// renaming it into place. // renaming it into place.
// //
// This avoids exposing partially written workspace files to agents that may // This avoids exposing partially written workspace files to agents that may
// read status, prompt, or plan documents while they are being updated. // read status, prompt, or plan documents while they are being updated.
func writeAtomic(path, content string) error { func writeAtomic(path, content string) error {
dir := filepath.Dir(path) dir := core.PathDir(path)
if err := coreio.Local.EnsureDir(dir); err != nil { if err := coreio.Local.EnsureDir(dir); err != nil {
return err return err
} }
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") tmp, err := os.CreateTemp(dir, "."+core.PathBase(path)+".*.tmp")
if err != nil { if err != nil {
return err return err
} }

View file

@ -9,7 +9,7 @@ import (
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
"dappco.re/go/mcp/pkg/mcp/ide" "dappco.re/go/mcp/pkg/mcp/ide"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
) )
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge // errBridgeNotAvailable is returned when a tool requires the Laravel bridge

View file

@ -3,20 +3,15 @@
package brain package brain
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt"
goio "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings"
"time" "time"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -58,15 +53,16 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string,
// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back // Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back
// to ~/.claude/brain.key for the API key. // to ~/.claude/brain.key for the API key.
func NewDirect() *DirectSubsystem { func NewDirect() *DirectSubsystem {
apiURL := os.Getenv("CORE_BRAIN_URL") apiURL := core.Env("CORE_BRAIN_URL")
if apiURL == "" { if apiURL == "" {
apiURL = "https://api.lthn.sh" apiURL = "https://api.lthn.sh"
} }
apiKey := os.Getenv("CORE_BRAIN_KEY") apiKey := core.Env("CORE_BRAIN_KEY")
if apiKey == "" { if apiKey == "" {
if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil { home := core.Env("HOME")
apiKey = strings.TrimSpace(data) if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil {
apiKey = core.Trim(data)
} }
} }
@ -112,16 +108,12 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil) return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
} }
var reqBody goio.Reader var bodyStr string
if body != nil { if body != nil {
data, err := json.Marshal(body) bodyStr = core.JSONMarshalString(body)
if err != nil {
return nil, coreerr.E("brain.apiCall", "marshal request", err)
}
reqBody = bytes.NewReader(data)
} }
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody) req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, core.NewReader(bodyStr))
if err != nil { if err != nil {
return nil, coreerr.E("brain.apiCall", "create request", err) return nil, coreerr.E("brain.apiCall", "create request", err)
} }
@ -135,18 +127,22 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
} }
defer resp.Body.Close() defer resp.Body.Close()
respData, err := goio.ReadAll(resp.Body) r := core.ReadAll(resp.Body)
if err != nil { if !r.OK {
return nil, coreerr.E("brain.apiCall", "read response", err) if readErr, ok := r.Value.(error); ok {
return nil, coreerr.E("brain.apiCall", "read response", readErr)
} }
return nil, coreerr.E("brain.apiCall", "read response failed", nil)
}
respData := r.Value.(string)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil) return nil, coreerr.E("brain.apiCall", "API returned "+respData, nil)
} }
var result map[string]any var result map[string]any
if err := json.Unmarshal(respData, &result); err != nil { if ur := core.JSONUnmarshal([]byte(respData), &result); !ur.OK {
return nil, coreerr.E("brain.apiCall", "parse response", err) return nil, coreerr.E("brain.apiCall", "parse response", nil)
} }
return result, nil return result, nil
@ -200,30 +196,7 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in
return nil, RecallOutput{}, err return nil, RecallOutput{}, err
} }
var memories []Memory memories := memoriesFromResult(result)
if mems, ok := result["memories"].([]any); ok {
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"]),
}
if id, ok := mm["id"].(string); ok {
mem.ID = id
}
if score, ok := mm["score"].(float64); ok {
mem.Confidence = score
}
if source, ok := mm["source"].(string); ok {
mem.Tags = append(mem.Tags, "source:"+source)
}
memories = append(memories, mem)
}
}
}
if s.onChannel != nil { if s.onChannel != nil {
s.onChannel(ctx, coremcp.ChannelBrainRecallDone, map[string]any{ s.onChannel(ctx, coremcp.ChannelBrainRecallDone, map[string]any{
@ -274,37 +247,14 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu
if input.AgentID != "" { if input.AgentID != "" {
values.Set("agent_id", input.AgentID) values.Set("agent_id", input.AgentID)
} }
values.Set("limit", fmt.Sprintf("%d", limit)) values.Set("limit", core.Sprintf("%d", limit))
result, err := s.apiCall(ctx, http.MethodGet, "/v1/brain/list?"+values.Encode(), nil) result, err := s.apiCall(ctx, http.MethodGet, "/v1/brain/list?"+values.Encode(), nil)
if err != nil { if err != nil {
return nil, ListOutput{}, err return nil, ListOutput{}, err
} }
var memories []Memory memories := memoriesFromResult(result)
if mems, ok := result["memories"].([]any); ok {
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"]),
}
if id, ok := mm["id"].(string); ok {
mem.ID = id
}
if score, ok := mm["score"].(float64); ok {
mem.Confidence = score
}
if source, ok := mm["source"].(string); ok {
mem.Tags = append(mem.Tags, "source:"+source)
}
memories = append(memories, mem)
}
}
}
if s.onChannel != nil { if s.onChannel != nil {
s.onChannel(ctx, coremcp.ChannelBrainListDone, map[string]any{ s.onChannel(ctx, coremcp.ChannelBrainListDone, map[string]any{
@ -321,3 +271,49 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu
Memories: memories, Memories: memories,
}, nil }, nil
} }
// memoriesFromResult extracts Memory entries from an API response map.
func memoriesFromResult(result map[string]any) []Memory {
var memories []Memory
mems, ok := result["memories"].([]any)
if !ok {
return memories
}
for _, m := range mems {
mm, ok := m.(map[string]any)
if !ok {
continue
}
mem := Memory{
Content: stringFromMap(mm, "content"),
Type: stringFromMap(mm, "type"),
Project: stringFromMap(mm, "project"),
AgentID: stringFromMap(mm, "agent_id"),
CreatedAt: stringFromMap(mm, "created_at"),
}
if id, ok := mm["id"].(string); ok {
mem.ID = id
}
if score, ok := mm["score"].(float64); ok {
mem.Confidence = score
}
if source, ok := mm["source"].(string); ok {
mem.Tags = append(mem.Tags, "source:"+source)
}
memories = append(memories, mem)
}
return memories
}
// stringFromMap extracts a string value from a map, returning "" if missing or wrong type.
func stringFromMap(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
s, ok := v.(string)
if !ok {
return core.Sprintf("%v", v)
}
return s
}

View file

@ -7,9 +7,9 @@ import (
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
"dappco.re/go/mcp/pkg/mcp/ide" "dappco.re/go/mcp/pkg/mcp/ide"
"forge.lthn.ai/core/api" "dappco.re/go/core/api"
"forge.lthn.ai/core/api/pkg/provider" "dappco.re/go/core/api/pkg/provider"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View file

@ -8,7 +8,7 @@ import (
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
"dappco.re/go/mcp/pkg/mcp/ide" "dappco.re/go/mcp/pkg/mcp/ide"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -3,13 +3,12 @@
package mcp package mcp
import ( import (
"errors"
"net/http" "net/http"
core "dappco.re/go/core" core "dappco.re/go/core"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api" api "dappco.re/go/core/api"
) )
// maxBodySize is the maximum request body size accepted by bridged tool endpoints. // maxBodySize is the maximum request body size accepted by bridged tool endpoints.
@ -48,7 +47,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) {
if !r.OK { if !r.OK {
if err, ok := r.Value.(error); ok { if err, ok := r.Value.(error); ok {
var maxBytesErr *http.MaxBytesError var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") { if core.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") {
c.JSON(http.StatusRequestEntityTooLarge, api.Fail("request_too_large", "Request body exceeds 10 MB limit")) c.JSON(http.StatusRequestEntityTooLarge, api.Fail("request_too_large", "Request body exceeds 10 MB limit"))
return return
} }
@ -63,7 +62,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) {
if err != nil { if err != nil {
// Body present + error = likely bad input (malformed JSON). // Body present + error = likely bad input (malformed JSON).
// No body + error = tool execution failure. // No body + error = tool execution failure.
if errors.Is(err, errInvalidRESTInput) { if core.Is(err, errInvalidRESTInput) {
c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body")) c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body"))
return return
} }

View file

@ -17,7 +17,7 @@ import (
"dappco.re/go/mcp/pkg/mcp/agentic" "dappco.re/go/mcp/pkg/mcp/agentic"
"dappco.re/go/mcp/pkg/mcp/brain" "dappco.re/go/mcp/pkg/mcp/brain"
"dappco.re/go/mcp/pkg/mcp/ide" "dappco.re/go/mcp/pkg/mcp/ide"
api "forge.lthn.ai/core/api" api "dappco.re/go/core/api"
) )
func init() { func init() {
@ -250,7 +250,7 @@ func TestBridgeToAPI_Good_EndToEnd(t *testing.T) {
} }
// Verify a tool endpoint is reachable through the engine. // Verify a tool endpoint is reachable through the engine.
resp2, err := http.Post(srv.URL+"/tools/lang_list", "application/json", nil) resp2, err := http.Post(srv.URL+"/tools/lang_list", "application/json", strings.NewReader("{}"))
if err != nil { if err != nil {
t.Fatalf("lang_list request failed: %v", err) t.Fatalf("lang_list request failed: %v", err)
} }

View file

@ -9,8 +9,8 @@ import (
"sync" "sync"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )

View file

@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )

View file

@ -4,14 +4,13 @@ package ide
import ( import (
"context" "context"
"fmt"
"sync" "sync"
"time" "time"
core "dappco.re/go/core" core "dappco.re/go/core"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
) )
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge // errBridgeNotAvailable is returned when a tool requires the Laravel bridge
@ -556,7 +555,7 @@ func stringFromAny(v any) string {
switch value := v.(type) { switch value := v.(type) {
case string: case string:
return value return value
case fmt.Stringer: case interface{ String() string }:
return value.String() return value.String()
default: default:
return "" return ""

View file

@ -7,7 +7,7 @@ import (
"time" "time"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
coreerr "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -9,7 +9,7 @@ import (
"time" "time"
coremcp "dappco.re/go/mcp/pkg/mcp" coremcp "dappco.re/go/mcp/pkg/mcp"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
) )
// --- Helpers --- // --- Helpers ---

View file

@ -5,22 +5,20 @@
package mcp package mcp
import ( import (
"cmp"
"context" "context"
"errors"
"iter" "iter"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings"
"sync" "sync"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/io"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -74,7 +72,8 @@ func New(opts Options) (*Service, error) {
server := mcp.NewServer(impl, &mcp.ServerOptions{ server := mcp.NewServer(impl, &mcp.ServerOptions{
Capabilities: &mcp.ServerCapabilities{ Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{ListChanged: true}, Resources: &mcp.ResourceCapabilities{ListChanged: false},
Tools: &mcp.ToolCapabilities{ListChanged: false},
Logging: &mcp.LoggingCapabilities{}, Logging: &mcp.LoggingCapabilities{},
Experimental: channelCapability(), Experimental: channelCapability(),
}, },
@ -245,15 +244,15 @@ func (s *Service) resolveWorkspacePath(path string) string {
} }
if s.workspaceRoot == "" { if s.workspaceRoot == "" {
return filepath.Clean(path) return core.CleanPath(path, "/")
} }
clean := filepath.Clean(string(filepath.Separator) + path) clean := core.CleanPath(string(filepath.Separator)+path, "/")
clean = strings.TrimPrefix(clean, string(filepath.Separator)) clean = core.TrimPrefix(clean, string(filepath.Separator))
if clean == "." || clean == "" { if clean == "." || clean == "" {
return s.workspaceRoot return s.workspaceRoot
} }
return filepath.Join(s.workspaceRoot, clean) return core.Path(s.workspaceRoot, clean)
} }
// registerTools adds the built-in tool groups to the MCP server. // registerTools adds the built-in tool groups to the MCP server.
@ -543,8 +542,8 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
if err != nil { if err != nil {
return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err) return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err)
} }
sort.Slice(entries, func(i, j int) bool { slices.SortFunc(entries, func(a, b os.DirEntry) int {
return entries[i].Name() < entries[j].Name() return cmp.Compare(a.Name(), b.Name())
}) })
result := make([]DirectoryEntry, 0, len(entries)) result := make([]DirectoryEntry, 0, len(entries))
for _, e := range entries { for _, e := range entries {
@ -615,7 +614,7 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu
info, err := s.medium.Stat(input.Path) info, err := s.medium.Stat(input.Path)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if core.Is(err, os.ErrNotExist) {
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
} }
return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err) return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err)

View file

@ -7,17 +7,17 @@
package mcp package mcp
import ( import (
"cmp"
"context" "context"
"io" "io"
"iter" "iter"
"os" "os"
"reflect" "reflect"
"slices" "slices"
"sort"
"strings"
"sync" "sync"
"unsafe" "unsafe"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -203,7 +203,7 @@ func (s *Service) ChannelSend(ctx context.Context, channel string, data any) {
if s == nil || s.server == nil { if s == nil || s.server == nil {
return return
} }
if strings.TrimSpace(channel) == "" { if core.Trim(channel) == "" {
return return
} }
ctx = normalizeNotificationContext(ctx) ctx = normalizeNotificationContext(ctx)
@ -218,7 +218,7 @@ func (s *Service) ChannelSendToSession(ctx context.Context, session *mcp.ServerS
if s == nil || s.server == nil || session == nil { if s == nil || s.server == nil || session == nil {
return return
} }
if strings.TrimSpace(channel) == "" { if core.Trim(channel) == "" {
return return
} }
ctx = normalizeNotificationContext(ctx) ctx = normalizeNotificationContext(ctx)
@ -275,6 +275,15 @@ func (s *Service) debugNotify(msg string, args ...any) {
s.logger.Debug(msg, args...) s.logger.Debug(msg, args...)
} }
// NotifySession sends a raw JSON-RPC notification to a specific MCP session.
//
// coremcp.NotifySession(ctx, session, "notifications/claude/channel", map[string]any{
// "content": "build failed", "meta": map[string]string{"severity": "high"},
// })
func NotifySession(ctx context.Context, session *mcp.ServerSession, method string, payload any) error {
return sendSessionNotification(ctx, session, method, payload)
}
func sendSessionNotification(ctx context.Context, session *mcp.ServerSession, method string, payload any) error { func sendSessionNotification(ctx context.Context, session *mcp.ServerSession, method string, payload any) error {
if session == nil { if session == nil {
return nil return nil
@ -353,8 +362,8 @@ func snapshotSessions(server *mcp.Server) []*mcp.ServerSession {
} }
} }
sort.Slice(sessions, func(i, j int) bool { slices.SortFunc(sessions, func(a, b *mcp.ServerSession) int {
return sessions[i].ID() < sessions[j].ID() return cmp.Compare(a.ID(), b.ID())
}) })
return sessions return sessions

View file

@ -4,9 +4,9 @@ package mcp
import ( import (
"context" "context"
"path/filepath"
"strings"
"time" "time"
core "dappco.re/go/core"
) )
type processRuntime struct { type processRuntime struct {
@ -50,19 +50,20 @@ func (s *Service) forgetProcessRuntime(id string) {
} }
func isTestProcess(command string, args []string) bool { func isTestProcess(command string, args []string) bool {
base := strings.ToLower(filepath.Base(command)) base := core.Lower(core.PathBase(command))
if base == "" { if base == "" {
return false return false
} }
switch base { switch base {
case "go": case "go":
return len(args) > 0 && strings.EqualFold(args[0], "test") return len(args) > 0 && core.Lower(args[0]) == "test"
case "cargo": case "cargo":
return len(args) > 0 && strings.EqualFold(args[0], "test") return len(args) > 0 && core.Lower(args[0]) == "test"
case "npm", "pnpm", "yarn", "bun": case "npm", "pnpm", "yarn", "bun":
for _, arg := range args { for _, arg := range args {
if strings.EqualFold(arg, "test") || strings.HasPrefix(strings.ToLower(arg), "test:") { lower := core.Lower(arg)
if lower == "test" || core.HasPrefix(lower, "test:") {
return true return true
} }
} }

View file

@ -7,8 +7,8 @@ import (
"time" "time"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
) )
// Register is the service factory for core.WithService. // Register is the service factory for core.WithService.

View file

@ -9,8 +9,8 @@ import (
"time" "time"
"dappco.re/go/core" "dappco.re/go/core"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
) )
func TestRegister_Good_WiresOptionalServices(t *testing.T) { func TestRegister_Good_WiresOptionalServices(t *testing.T) {

View file

@ -78,7 +78,40 @@ type ToolRecord struct {
// return nil, ReadFileOutput{Path: "src/main.go"}, nil // return nil, ReadFileOutput{Path: "src/main.go"}, nil
// }) // })
func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) { func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) {
mcp.AddTool(server, t, h) // Set inputSchema from struct reflection if not already set.
// Use server.AddTool (non-generic) to avoid auto-generated outputSchema.
// The go-sdk's generic mcp.AddTool generates outputSchema from the Out type,
// but Claude Code's protocol (2025-03-26) doesn't support outputSchema.
// Removing it reduces tools/list from 214KB to ~74KB.
if t.InputSchema == nil {
t.InputSchema = structSchema(new(In))
if t.InputSchema == nil {
t.InputSchema = map[string]any{"type": "object"}
}
}
// Wrap the typed handler into a generic ToolHandler.
wrapped := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var input In
if req != nil && len(req.Params.Arguments) > 0 {
if r := core.JSONUnmarshal(req.Params.Arguments, &input); !r.OK {
if err, ok := r.Value.(error); ok {
return nil, err
}
}
}
result, output, err := h(ctx, req, input)
if err != nil {
return nil, err
}
if result != nil {
return result, nil
}
data := core.JSONMarshalString(output)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: data}},
}, nil
}
server.AddTool(t, wrapped)
restHandler := func(ctx context.Context, body []byte) (any, error) { restHandler := func(ctx context.Context, body []byte) (any, error) {
var input In var input In

View file

@ -7,7 +7,7 @@ import (
"errors" "errors"
"testing" "testing"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
) )
func TestToolRegistry_Good_RecordsTools(t *testing.T) { func TestToolRegistry_Good_RecordsTools(t *testing.T) {

View file

@ -8,8 +8,8 @@ import (
"time" "time"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-ai/ai" "dappco.re/go/core/ai/ai"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -6,8 +6,8 @@ import (
"context" "context"
"time" "time"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -9,7 +9,7 @@ import (
"time" "time"
"dappco.re/go/core" "dappco.re/go/core"
"forge.lthn.ai/core/go-process" "dappco.re/go/core/process"
) )
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests. // newTestProcessService creates a real process.Service backed by a core.Core for CI tests.

View file

@ -6,8 +6,8 @@ import (
"context" "context"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"forge.lthn.ai/core/go-rag" "dappco.re/go/core/rag"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -9,13 +9,12 @@ import (
"image" "image"
"image/jpeg" "image/jpeg"
_ "image/png" _ "image/png"
"strings"
"sync" "sync"
"time" "time"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"forge.lthn.ai/core/go-webview" "dappco.re/go/core/webview"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -554,7 +553,7 @@ func (s *Service) webviewScreenshot(ctx context.Context, req *mcp.CallToolReques
if format == "" { if format == "" {
format = "png" format = "png"
} }
format = strings.ToLower(format) format = core.Lower(format)
data, err := webviewInstance.Screenshot() data, err := webviewInstance.Screenshot()
if err != nil { if err != nil {
@ -649,7 +648,7 @@ func waitForSelector(ctx context.Context, timeout time.Duration, selector string
if err == nil { if err == nil {
return nil return nil
} }
if !strings.Contains(err.Error(), "element not found") { if !core.Contains(err.Error(), "element not found") {
return err return err
} }

View file

@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"forge.lthn.ai/core/go-webview" "dappco.re/go/core/webview"
) )
// skipIfShort skips webview tests in short mode (go test -short). // skipIfShort skips webview tests in short mode (go test -short).

View file

@ -8,8 +8,8 @@ import (
"net/http" "net/http"
core "dappco.re/go/core" core "dappco.re/go/core"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -3,7 +3,7 @@ package mcp
import ( import (
"testing" "testing"
"forge.lthn.ai/core/go-ws" "dappco.re/go/core/ws"
) )
// TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available. // TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available.

View file

@ -7,11 +7,10 @@ import (
"crypto/subtle" "crypto/subtle"
"net" "net"
"net/http" "net/http"
"os"
"strings"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -37,7 +36,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
addr = DefaultHTTPAddr addr = DefaultHTTPAddr
} }
authToken := os.Getenv("MCP_AUTH_TOKEN") authToken := core.Env("MCP_AUTH_TOKEN")
handler := mcp.NewStreamableHTTPHandler( handler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server { func(r *http.Request) *mcp.Server {
@ -85,18 +84,18 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
// If token is empty, authentication is disabled for local development. // If token is empty, authentication is disabled for local development.
func withAuth(token string, next http.Handler) http.Handler { func withAuth(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(token) == "" { if core.Trim(token) == "" {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
auth := r.Header.Get("Authorization") auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") { if !core.HasPrefix(auth, "Bearer ") {
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
return return
} }
provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) provided := core.Trim(core.TrimPrefix(auth, "Bearer "))
if len(provided) == 0 { if len(provided) == 0 {
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized) http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
return return

View file

@ -6,7 +6,7 @@ import (
"context" "context"
"os" "os"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )

View file

@ -5,12 +5,12 @@ package mcp
import ( import (
"bufio" "bufio"
"context" "context"
"fmt"
goio "io" goio "io"
"net" "net"
"os" "os"
"sync" "sync"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -31,7 +31,7 @@ var diagWriter goio.Writer = os.Stderr
func diagPrintf(format string, args ...any) { func diagPrintf(format string, args ...any) {
diagMu.Lock() diagMu.Lock()
defer diagMu.Unlock() defer diagMu.Unlock()
fmt.Fprintf(diagWriter, format, args...) core.Print(diagWriter, format, args...)
} }
// setDiagWriter swaps the diagnostic writer and returns the previous one. // setDiagWriter swaps the diagnostic writer and returns the previous one.

View file

@ -6,8 +6,8 @@ import (
"context" "context"
"net" "net"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/io"
"forge.lthn.ai/core/go-log" "dappco.re/go/core/log"
) )
// ServeUnix starts a Unix domain socket server for the MCP service. // ServeUnix starts a Unix domain socket server for the MCP service.