chore(ax): AX compliance sweep pass 1 — banned imports and naming

Replace all banned imports (fmt, encoding/json, path/filepath, strings,
os.Getenv) with core primitives across agentic, brain, ide, and transport
packages. Rename abbreviated variables (cfg→config/agentsConfig) and add
_Good/_Bad/_Ugly test triads to transport_tcp_test.go and prep_test.go.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Claude 2026-03-31 09:26:24 +01:00
parent 5177dc391b
commit 899a532d76
No known key found for this signature in database
GPG key ID: AF404715446AEB41
19 changed files with 717 additions and 560 deletions

View file

@ -16,17 +16,15 @@ package main
import (
"bytes"
"crypto/tls"
"encoding/json"
"flag"
"fmt"
goio "io"
"net/http"
"os"
"path/filepath"
"path/filepath" // needed for WalkDir (no core equivalent)
"regexp"
"strings"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
@ -57,53 +55,47 @@ var httpClient = &http.Client{
func main() {
flag.Parse()
fmt.Println("OpenBrain Seed — MCP API Client")
fmt.Println(strings.Repeat("=", 55))
os.Stdout.Write([]byte("OpenBrain Seed — MCP API Client\n"))
os.Stdout.Write([]byte(core.Sprintf("%s\n", repeatChar('=', 55))))
if *apiKey == "" && !*dryRun {
fmt.Println("ERROR: -api-key is required (or use -dry-run)")
fmt.Println(" Generate one at: https://lthn.sh/admin/mcp/api-keys")
os.Stdout.Write([]byte("ERROR: -api-key is required (or use -dry-run)\n"))
os.Stdout.Write([]byte(" Generate one at: https://lthn.sh/admin/mcp/api-keys\n"))
os.Exit(1)
}
if *dryRun {
fmt.Println("[DRY RUN] — no data will be stored")
os.Stdout.Write([]byte("[DRY RUN] — no data will be stored\n"))
}
fmt.Printf("API: %s\n", *apiURL)
fmt.Printf("Server: %s | Agent: %s\n", *server, *agent)
os.Stdout.Write([]byte(core.Sprintf("API: %s\n", *apiURL)))
os.Stdout.Write([]byte(core.Sprintf("Server: %s | Agent: %s\n", *server, *agent)))
// Discover memory files
memPath := *memoryPath
if memPath == "" {
home, _ := os.UserHomeDir()
memPath = filepath.Join(home, ".claude", "projects", "*", "memory")
memPath = core.JoinPath(core.Env("HOME"), ".claude", "projects", "*", "memory")
}
memFiles, _ := filepath.Glob(filepath.Join(memPath, "*.md"))
fmt.Printf("\nFound %d memory files\n", len(memFiles))
memFiles := core.PathGlob(core.JoinPath(memPath, "*.md"))
os.Stdout.Write([]byte(core.Sprintf("\nFound %d memory files\n", len(memFiles))))
// Discover plan files
var planFiles []string
if *plans {
pPath := *planPath
if pPath == "" {
home, _ := os.UserHomeDir()
pPath = filepath.Join(home, "Code", "*", "docs", "plans")
pPath = core.JoinPath(core.Env("HOME"), "Code", "*", "docs", "plans")
}
planFiles, _ = filepath.Glob(filepath.Join(pPath, "*.md"))
planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*.md"))...)
// Also check nested dirs (completed/, etc.)
nested, _ := filepath.Glob(filepath.Join(pPath, "*", "*.md"))
planFiles = append(planFiles, nested...)
planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*", "*.md"))...)
// Also check host-uk nested repos
home, _ := os.UserHomeDir()
hostUkPath := filepath.Join(home, "Code", "host-uk", "*", "docs", "plans")
hostUkFiles, _ := filepath.Glob(filepath.Join(hostUkPath, "*.md"))
planFiles = append(planFiles, hostUkFiles...)
hostUkNested, _ := filepath.Glob(filepath.Join(hostUkPath, "*", "*.md"))
planFiles = append(planFiles, hostUkNested...)
hostUkPath := core.JoinPath(core.Env("HOME"), "Code", "host-uk", "*", "docs", "plans")
planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*.md"))...)
planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*", "*.md"))...)
fmt.Printf("Found %d plan files\n", len(planFiles))
os.Stdout.Write([]byte(core.Sprintf("Found %d plan files\n", len(planFiles))))
}
// Discover CLAUDE.md files
@ -111,11 +103,10 @@ func main() {
if *claudeMd {
cPath := *codePath
if cPath == "" {
home, _ := os.UserHomeDir()
cPath = filepath.Join(home, "Code")
cPath = core.JoinPath(core.Env("HOME"), "Code")
}
claudeFiles = discoverClaudeMdFiles(cPath)
fmt.Printf("Found %d CLAUDE.md files\n", len(claudeFiles))
os.Stdout.Write([]byte(core.Sprintf("Found %d CLAUDE.md files\n", len(claudeFiles))))
}
imported := 0
@ -123,11 +114,11 @@ func main() {
errors := 0
// Process memory files
fmt.Println("\n--- Memory Files ---")
os.Stdout.Write([]byte("\n--- Memory Files ---\n"))
for _, f := range memFiles {
project := extractProject(f)
sections := parseMarkdownSections(f)
filename := strings.TrimSuffix(filepath.Base(f), ".md")
filename := core.TrimSuffix(core.PathBase(f), ".md")
if len(sections) == 0 {
coreerr.Warn("brain-seed: skip file (no sections)", "project", project, "file", filename)
@ -137,7 +128,7 @@ func main() {
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
if core.Trim(sec.content) == "" {
skipped++
continue
}
@ -150,8 +141,8 @@ func main() {
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s/%s :: %s (%s) — %d chars\n",
project, filename, sec.heading, memType, len(content))
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s/%s :: %s (%s) — %d chars\n",
project, filename, sec.heading, memType, len(content))))
imported++
continue
}
@ -161,18 +152,18 @@ func main() {
errors++
continue
}
fmt.Printf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType)
os.Stdout.Write([]byte(core.Sprintf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType)))
imported++
}
}
// Process plan files
if *plans && len(planFiles) > 0 {
fmt.Println("\n--- Plan Documents ---")
os.Stdout.Write([]byte("\n--- Plan Documents ---\n"))
for _, f := range planFiles {
project := extractProjectFromPlan(f)
sections := parseMarkdownSections(f)
filename := strings.TrimSuffix(filepath.Base(f), ".md")
filename := core.TrimSuffix(core.PathBase(f), ".md")
if len(sections) == 0 {
skipped++
@ -181,7 +172,7 @@ func main() {
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
if core.Trim(sec.content) == "" {
skipped++
continue
}
@ -190,8 +181,8 @@ func main() {
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s :: %s / %s (plan) — %d chars\n",
project, filename, sec.heading, len(content))
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: %s / %s (plan) — %d chars\n",
project, filename, sec.heading, len(content))))
imported++
continue
}
@ -201,7 +192,7 @@ func main() {
errors++
continue
}
fmt.Printf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading)
os.Stdout.Write([]byte(core.Sprintf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading)))
imported++
}
}
@ -209,7 +200,7 @@ func main() {
// Process CLAUDE.md files
if *claudeMd && len(claudeFiles) > 0 {
fmt.Println("\n--- CLAUDE.md Files ---")
os.Stdout.Write([]byte("\n--- CLAUDE.md Files ---\n"))
for _, f := range claudeFiles {
project := extractProjectFromClaudeMd(f)
sections := parseMarkdownSections(f)
@ -221,7 +212,7 @@ func main() {
for _, sec := range sections {
content := sec.heading + "\n\n" + sec.content
if strings.TrimSpace(sec.content) == "" {
if core.Trim(sec.content) == "" {
skipped++
continue
}
@ -230,8 +221,8 @@ func main() {
content = truncate(content, *maxChars)
if *dryRun {
fmt.Printf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n",
project, sec.heading, len(content))
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n",
project, sec.heading, len(content))))
imported++
continue
}
@ -241,18 +232,18 @@ func main() {
errors++
continue
}
fmt.Printf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading)
os.Stdout.Write([]byte(core.Sprintf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading)))
imported++
}
}
}
fmt.Printf("\n%s\n", strings.Repeat("=", 55))
os.Stdout.Write([]byte(core.Sprintf("\n%s\n", repeatChar('=', 55))))
prefix := ""
if *dryRun {
prefix = "[DRY RUN] "
}
fmt.Printf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors)
os.Stdout.Write([]byte(core.Sprintf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors)))
}
// callBrainRemember sends a memory to the MCP API via brain_remember tool.
@ -273,10 +264,7 @@ func callBrainRemember(content, memType string, tags []string, project string, c
"arguments": args,
}
body, err := json.Marshal(payload)
if err != nil {
return coreerr.E("callBrainRemember", "marshal", err)
}
body := []byte(core.JSONMarshalString(payload))
req, err := http.NewRequest("POST", *apiURL+"/tools/call", bytes.NewReader(body))
if err != nil {
@ -301,8 +289,9 @@ func callBrainRemember(content, memType string, tags []string, project string, c
Success bool `json:"success"`
Error string `json:"error"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return coreerr.E("callBrainRemember", "decode", err)
r := core.JSONUnmarshalString(string(respBody), &result)
if !r.OK {
return coreerr.E("callBrainRemember", "decode", nil)
}
if !result.Success {
return coreerr.E("callBrainRemember", "API: "+result.Error, nil)
@ -312,19 +301,26 @@ func callBrainRemember(content, memType string, tags []string, project string, c
}
// truncate caps content to maxLen chars, appending an ellipsis if truncated.
//
// truncate("hello world", 5) // "hello…"
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
// Find last space before limit to avoid splitting mid-word
cut := maxLen
if idx := strings.LastIndex(s[:maxLen], " "); idx > maxLen-200 {
cut = idx
for i := maxLen - 1; i > maxLen-200 && i >= 0; i-- {
if s[i] == ' ' {
cut = i
break
}
}
return s[:cut] + "…"
}
// discoverClaudeMdFiles finds CLAUDE.md files across a code directory.
//
// files := discoverClaudeMdFiles(core.JoinPath(core.Env("HOME"), "Code"))
func discoverClaudeMdFiles(codePath string) []string {
var files []string
@ -338,9 +334,15 @@ func discoverClaudeMdFiles(codePath string) []string {
if name == "node_modules" || name == "vendor" || name == ".claude" {
return filepath.SkipDir
}
// Limit depth
// Limit depth by counting separators manually
rel, _ := filepath.Rel(codePath, path)
if strings.Count(rel, string(os.PathSeparator)) > 3 {
depth := 0
for _, ch := range rel {
if ch == os.PathSeparator {
depth++
}
}
if depth > 3 {
return filepath.SkipDir
}
return nil
@ -363,6 +365,8 @@ type section struct {
var headingRe = regexp.MustCompile(`^#{1,3}\s+(.+)$`)
// parseMarkdownSections splits a markdown file by headings.
//
// sections := parseMarkdownSections("/path/to/MEMORY.md")
func parseMarkdownSections(path string) []section {
data, err := coreio.Local.Read(path)
if err != nil || len(data) == 0 {
@ -370,19 +374,19 @@ func parseMarkdownSections(path string) []section {
}
var sections []section
lines := strings.Split(data, "\n")
lines := core.Split(data, "\n")
var curHeading string
var curContent []string
for _, line := range lines {
if m := headingRe.FindStringSubmatch(line); m != nil {
if curHeading != "" && len(curContent) > 0 {
text := strings.TrimSpace(strings.Join(curContent, "\n"))
text := core.Trim(joinLines(curContent))
if text != "" {
sections = append(sections, section{curHeading, text})
}
}
curHeading = strings.TrimSpace(m[1])
curHeading = core.Trim(m[1])
curContent = nil
} else {
curContent = append(curContent, line)
@ -391,17 +395,17 @@ func parseMarkdownSections(path string) []section {
// Flush last section
if curHeading != "" && len(curContent) > 0 {
text := strings.TrimSpace(strings.Join(curContent, "\n"))
text := core.Trim(joinLines(curContent))
if text != "" {
sections = append(sections, section{curHeading, text})
}
}
// If no headings found, treat entire file as one section
if len(sections) == 0 && strings.TrimSpace(data) != "" {
if len(sections) == 0 && core.Trim(data) != "" {
sections = append(sections, section{
heading: strings.TrimSuffix(filepath.Base(path), ".md"),
content: strings.TrimSpace(data),
heading: core.TrimSuffix(core.PathBase(path), ".md"),
content: core.Trim(data),
})
}
@ -459,7 +463,7 @@ func inferType(heading, content, source string) string {
return "convention"
}
lower := strings.ToLower(heading + " " + content)
lower := toLower(heading + " " + content)
patterns := map[string][]string{
"architecture": {"architecture", "stack", "infrastructure", "layer", "service mesh"},
"convention": {"convention", "standard", "naming", "pattern", "rule", "coding"},
@ -468,10 +472,10 @@ func inferType(heading, content, source string) string {
"plan": {"plan", "todo", "roadmap", "milestone", "phase", "task"},
"research": {"research", "finding", "discovery", "analysis", "rfc"},
}
for t, keywords := range patterns {
for memoryType, keywords := range patterns {
for _, kw := range keywords {
if strings.Contains(lower, kw) {
return t
if core.Contains(lower, kw) {
return memoryType
}
}
}
@ -485,7 +489,7 @@ func buildTags(filename, source, project string) []string {
tags = append(tags, "project:"+project)
}
if filename != "MEMORY" && filename != "CLAUDE" {
tags = append(tags, strings.ReplaceAll(strings.ReplaceAll(filename, "-", " "), "_", " "))
tags = append(tags, core.Replace(core.Replace(filename, "-", " "), "_", " "))
}
return tags
}
@ -503,3 +507,43 @@ func confidenceForSource(source string) float64 {
return 0.5
}
}
// repeatChar returns a string of n repetitions of ch.
//
// repeatChar('=', 3) // "==="
func repeatChar(ch byte, n int) string {
b := core.NewBuilder()
for i := 0; i < n; i++ {
b.WriteByte(ch)
}
return b.String()
}
// joinLines joins a slice of lines with newline separators.
//
// joinLines([]string{"a", "b"}) // "a\nb"
func joinLines(lines []string) string {
b := core.NewBuilder()
for i, line := range lines {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(line)
}
return b.String()
}
// toLower converts a string to lowercase ASCII.
//
// toLower("Hello World") // "hello world"
func toLower(s string) string {
b := core.NewBuilder()
for _, ch := range s {
if ch >= 'A' && ch <= 'Z' {
b.WriteRune(ch + 32)
} else {
b.WriteRune(ch)
}
}
return b.String()
}

View file

@ -4,14 +4,12 @@ package agentic
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -52,31 +50,31 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) {
// agentCommand returns the command and args for a given agent type.
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
func agentCommand(agent, prompt string) (string, []string, error) {
parts := strings.SplitN(agent, ":", 2)
base := parts[0]
model := ""
parts := core.SplitN(agent, ":", 2)
agentBase := parts[0]
agentModel := ""
if len(parts) > 1 {
model = parts[1]
agentModel = parts[1]
}
switch base {
switch agentBase {
case "gemini":
args := []string{"-p", prompt, "--yolo", "--sandbox"}
if model != "" {
args = append(args, "-m", "gemini-2.5-"+model)
if agentModel != "" {
args = append(args, "-m", "gemini-2.5-"+agentModel)
}
return "gemini", args, nil
case "codex":
return "codex", []string{"--approval-mode", "full-auto", "-q", prompt}, nil
case "claude":
args := []string{"-p", prompt, "--dangerously-skip-permissions"}
if model != "" {
args = append(args, "--model", model)
if agentModel != "" {
args = append(args, "--model", agentModel)
}
return "claude", args, nil
case "local":
home, _ := os.UserHomeDir()
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
home := core.Env("HOME")
script := core.JoinPath(home, "Code", "core", "agent", "scripts", "local-agent.sh")
return "bash", []string{script, prompt}, nil
default:
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
@ -117,14 +115,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
}
wsDir := prepOut.WorkspaceDir
srcDir := filepath.Join(wsDir, "src")
srcDir := core.JoinPath(wsDir, "src")
// The prompt is just: read PROMPT.md and do the work
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
if input.DryRun {
// Read PROMPT.md for the dry run output
promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
promptRaw, _ := coreio.Local.Read(core.JoinPath(wsDir, "PROMPT.md"))
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
@ -175,7 +173,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
return nil, DispatchOutput{}, err
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent))
outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", input.Agent))
outFile, err := os.Create(outputFile)
if err != nil {
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)

View file

@ -5,11 +5,9 @@ package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -99,19 +97,19 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
}
// Step 2: Build epic body with checklist
var body strings.Builder
epicBody := core.NewBuilder()
if input.Body != "" {
body.WriteString(input.Body)
body.WriteString("\n\n")
epicBody.WriteString(input.Body)
epicBody.WriteString("\n\n")
}
body.WriteString("## Tasks\n\n")
epicBody.WriteString("## Tasks\n\n")
for _, child := range children {
body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
epicBody.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
}
// Step 3: Create epic issue
epicLabels := append(labelIDs, s.resolveLabelIDs(ctx, input.Org, input.Repo, []string{"epic"})...)
epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, body.String(), epicLabels)
epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, epicBody.String(), epicLabels)
if err != nil {
return nil, EpicOutput{}, coreerr.E("createEpic", "failed to create epic", err)
}
@ -155,9 +153,9 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
payload["labels"] = labelIDs
}
data, _ := json.Marshal(payload)
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
data := []byte(core.JSONMarshalString(payload))
issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", issueURL, bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken)
@ -165,17 +163,17 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
if err != nil {
return ChildRef{}, coreerr.E("createIssue", "request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != 201 {
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil)
resp.Body.Close()
return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil)
}
var result struct {
Number int `json:"number"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(resp.Body).Decode(&result)
core.JSONUnmarshalString(readBody(resp.Body), &result)
return ChildRef{
Number: result.Number,
@ -191,16 +189,16 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
}
// Fetch existing labels
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
labelsURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "GET", labelsURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resp.Body.Close()
return nil
}
@ -208,7 +206,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
ID int64 `json:"id"`
Name string `json:"name"`
}
json.NewDecoder(resp.Body).Decode(&existing)
core.JSONUnmarshalString(readBody(resp.Body), &existing)
nameToID := make(map[string]int64)
for _, l := range existing {
@ -244,13 +242,13 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
colour = "#6b7280"
}
payload, _ := json.Marshal(map[string]string{
payload := []byte(core.JSONMarshalString(map[string]string{
"name": name,
"color": colour,
})
}))
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
createLabelURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", createLabelURL, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken)
@ -258,15 +256,15 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
if err != nil {
return 0
}
defer resp.Body.Close()
if resp.StatusCode != 201 {
resp.Body.Close()
return 0
}
var result struct {
ID int64 `json:"id"`
}
json.NewDecoder(resp.Body).Decode(&result)
core.JSONUnmarshalString(readBody(resp.Body), &result)
return result.ID
}

View file

@ -4,13 +4,9 @@ package agentic
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
)
@ -23,10 +19,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
}
// Read the log file
logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
if err != nil {
return
}
logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log"))
if len(logFiles) == 0 {
return
}
@ -39,7 +32,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
body := contentStr
// Skip quota errors
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") {
return
}
@ -52,13 +45,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
// Determine issue type from the template used
issueType := "task"
priority := "normal"
if strings.Contains(body, "security") || strings.Contains(body, "Security") {
if core.Contains(body, "security") || core.Contains(body, "Security") {
issueType = "bug"
priority = "high"
}
// Create a single issue per repo with all findings in the body
title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings)
title := core.Sprintf("Scan findings for %s (%d items)", st.Repo, findings)
// Truncate body to reasonable size for issue description
description := body
@ -81,7 +74,7 @@ func countFileRefs(body string) int {
}
if j < len(body) && body[j] == '`' {
ref := body[i+1 : j]
if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") {
if core.Contains(ref, ".go:") || core.Contains(ref, ".php:") {
count++
}
}
@ -97,20 +90,20 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
}
// Read the agent API key from file
home, _ := os.UserHomeDir()
apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key"))
home := core.Env("HOME")
apiKeyData, err := coreio.Local.Read(core.JoinPath(home, ".claude", "agent-api.key"))
if err != nil {
return
}
apiKey := strings.TrimSpace(apiKeyData)
apiKey := core.Trim(apiKeyData)
payload, _ := json.Marshal(map[string]string{
payload := []byte(core.JSONMarshalString(map[string]string{
"title": title,
"description": description,
"type": issueType,
"priority": priority,
"reporter": "cladius",
})
}))
req, _ := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")

View file

@ -6,11 +6,9 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"path/filepath"
"strings"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -281,11 +279,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
var plans []Plan
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
continue
}
id := strings.TrimSuffix(entry.Name(), ".json")
id := core.TrimSuffix(entry.Name(), ".json")
plan, err := readPlan(dir, id)
if err != nil {
continue
@ -312,36 +310,43 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
// --- Helpers ---
func (s *PrepSubsystem) plansDir() string {
return filepath.Join(s.codePath, ".core", "plans")
return core.JoinPath(s.codePath, ".core", "plans")
}
func planPath(dir, id string) string {
return filepath.Join(dir, id+".json")
return core.JoinPath(dir, id+".json")
}
func generatePlanID(title string) string {
slug := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
return r
// Build slug: lowercase, letters/digits/-
builder := core.NewBuilder()
for _, r := range title {
switch {
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r + 32)
case r == ' ':
builder.WriteRune('-')
}
if r >= 'A' && r <= 'Z' {
return r + 32
}
if r == ' ' {
return '-'
}
return -1
}, title)
}
slug := builder.String()
// Trim consecutive dashes and cap length
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
for core.Contains(slug, "--") {
slug = core.Replace(slug, "--", "-")
}
slug = core.Trim(slug)
// Trim leading/trailing dashes manually (core.Trim does whitespace only)
for len(slug) > 0 && slug[0] == '-' {
slug = slug[1:]
}
slug = strings.Trim(slug, "-")
if len(slug) > 30 {
slug = slug[:30]
}
slug = strings.TrimRight(slug, "-")
for len(slug) > 0 && slug[len(slug)-1] == '-' {
slug = slug[:len(slug)-1]
}
// Append short random suffix for uniqueness
b := make([]byte, 3)
@ -356,8 +361,9 @@ func readPlan(dir, id string) (*Plan, error) {
}
var plan Plan
if err := json.Unmarshal([]byte(data), &plan); err != nil {
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err)
result := core.JSONUnmarshalString(data, &plan)
if !result.OK {
return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil)
}
return &plan, nil
}
@ -368,12 +374,9 @@ func writePlan(dir string, plan *Plan) (string, error) {
}
path := planPath(dir, plan.ID)
data, err := json.MarshalIndent(plan, "", " ")
if err != nil {
return "", err
}
encoded := core.JSONMarshalString(plan)
return path, coreio.Local.Write(path, string(data))
return path, coreio.Local.Write(path, encoded)
}
func validPlanStatus(status string) bool {

View file

@ -5,13 +5,10 @@ package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"path/filepath"
"strings"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -54,8 +51,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
}
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
srcDir := filepath.Join(wsDir, "src")
wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace)
srcDir := core.JoinPath(wsDir, "src")
if _, err := coreio.Local.List(srcDir); err != nil {
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
@ -75,7 +72,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
if err != nil {
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
@ -93,7 +90,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
title = st.Task
}
if title == "" {
title = fmt.Sprintf("Agent work on %s", st.Branch)
title = core.Sprintf("Agent work on %s", st.Branch)
}
// Build PR body
@ -131,7 +128,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
// Comment on issue if tracked
if st.Issue > 0 {
comment := fmt.Sprintf("Pull request created: %s", prURL)
comment := core.Sprintf("Pull request created: %s", prURL)
s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment)
}
@ -147,31 +144,31 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
}
func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string {
var b strings.Builder
b.WriteString("## Summary\n\n")
builder := core.NewBuilder()
builder.WriteString("## Summary\n\n")
if st.Task != "" {
b.WriteString(st.Task)
b.WriteString("\n\n")
builder.WriteString(st.Task)
builder.WriteString("\n\n")
}
if st.Issue > 0 {
b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue))
builder.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue))
}
b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent))
b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs))
b.WriteString("\n---\n*Created by agentic dispatch*\n")
return b.String()
builder.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent))
builder.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs))
builder.WriteString("\n---\n*Created by agentic dispatch*\n")
return builder.String()
}
func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base, title, body string) (string, int, error) {
payload, _ := json.Marshal(map[string]any{
payload := []byte(core.JSONMarshalString(map[string]any{
"title": title,
"body": body,
"head": head,
"base": base,
})
}))
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
pullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "POST", pullsURL, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken)
@ -183,25 +180,25 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
if resp.StatusCode != 201 {
var errBody map[string]any
json.NewDecoder(resp.Body).Decode(&errBody)
core.JSONUnmarshalString(readBody(resp.Body), &errBody)
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 {
Number int `json:"number"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(resp.Body).Decode(&pr)
core.JSONUnmarshalString(readBody(resp.Body), &pr)
return pr.HTMLURL, pr.Number, nil
}
func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, issue int, comment string) {
payload, _ := json.Marshal(map[string]string{"body": comment})
payload := []byte(core.JSONMarshalString(map[string]string{"body": comment}))
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
commentURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
req, _ := http.NewRequestWithContext(ctx, "POST", commentURL, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+s.forgeToken)
@ -302,18 +299,18 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp
}
func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10",
repoPullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10",
s.forgeURL, org, repo, state)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req, _ := http.NewRequestWithContext(ctx, "GET", repoPullsURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
if err != nil {
return nil, coreerr.E("listRepoPRs", "failed to list PRs for "+repo, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
resp.Body.Close()
return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
}
var prs []struct {
@ -335,7 +332,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
Name string `json:"name"`
} `json:"labels"`
}
json.NewDecoder(resp.Body).Decode(&prs)
core.JSONUnmarshalString(readBody(resp.Body), &prs)
var result []PRInfo
for _, pr := range prs {

View file

@ -7,16 +7,12 @@ package agentic
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
goio "io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -35,18 +31,21 @@ type PrepSubsystem struct {
}
// NewPrep creates an agentic subsystem.
//
// sub := agentic.NewPrep()
// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}})
func NewPrep() *PrepSubsystem {
home, _ := os.UserHomeDir()
home := core.Env("HOME")
forgeToken := os.Getenv("FORGE_TOKEN")
forgeToken := core.Env("FORGE_TOKEN")
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 data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
brainKey = strings.TrimSpace(data)
if data, err := coreio.Local.Read(core.JoinPath(home, ".claude", "brain.key")); err == nil {
brainKey = core.Trim(data)
}
}
@ -55,31 +54,31 @@ func NewPrep() *PrepSubsystem {
forgeToken: forgeToken,
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
brainKey: brainKey,
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
specsPath: envOr("SPECS_PATH", core.JoinPath(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")),
client: &http.Client{Timeout: 30 * time.Second},
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
if v := core.Env(key); v != "" {
return v
}
return fallback
}
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)
}
if value == "" {
return "", nil
}
if strings.Contains(value, "\\") {
if core.Contains(value, "\\") {
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
}
parts := strings.Split(value, "/")
parts := core.Split(value, "/")
if !allowSubdirs && len(parts) != 1 {
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
}
@ -134,7 +133,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
// workspaceRoot returns the base directory for agent workspaces.
func (s *PrepSubsystem) workspaceRoot() string {
return filepath.Join(s.codePath, ".core", "workspace")
return core.JoinPath(s.codePath, ".core", "workspace")
}
// --- Input/Output types ---
@ -197,8 +196,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
// Workspace root: .core/workspace/{repo}-{timestamp}/
wsRoot := s.workspaceRoot()
wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix())
wsDir := filepath.Join(wsRoot, wsName)
wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix())
wsDir := core.JoinPath(wsRoot, wsName)
// Create workspace structure
// kb/ and specs/ will be created inside src/ after clone
@ -206,31 +205,41 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
out := PrepOutput{WorkspaceDir: wsDir}
// Source repo path
repoPath := filepath.Join(s.codePath, "core", input.Repo)
repoPath := core.JoinPath(s.codePath, "core", input.Repo)
// 1. Clone repo into src/ and create feature branch
srcDir := filepath.Join(wsDir, "src")
srcDir := core.JoinPath(wsDir, "src")
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
if err := cloneCmd.Run(); err != nil {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
}
// Create feature branch
taskSlug := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
return r
taskBuilder := core.NewBuilder()
for _, r := range input.Task {
switch {
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-':
taskBuilder.WriteRune(r)
case r >= 'A' && r <= 'Z':
taskBuilder.WriteRune(r + 32)
default:
taskBuilder.WriteRune('-')
}
if r >= 'A' && r <= 'Z' {
return r + 32 // lowercase
}
return '-'
}, input.Task)
}
taskSlug := taskBuilder.String()
if len(taskSlug) > 40 {
taskSlug = taskSlug[:40]
}
taskSlug = strings.Trim(taskSlug, "-")
taskSlug = core.Trim(taskSlug)
// Trim leading/trailing dashes
for len(taskSlug) > 0 && taskSlug[0] == '-' {
taskSlug = taskSlug[1:]
}
for len(taskSlug) > 0 && taskSlug[len(taskSlug)-1] == '-' {
taskSlug = taskSlug[:len(taskSlug)-1]
}
if taskSlug != "" {
branchName := fmt.Sprintf("agent/%s", taskSlug)
branchName := core.Sprintf("agent/%s", taskSlug)
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
branchCmd.Dir = srcDir
if err := branchCmd.Run(); err != nil {
@ -239,29 +248,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
}
// Create context dirs inside src/
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs"))
coreio.Local.EnsureDir(core.JoinPath(srcDir, "kb"))
coreio.Local.EnsureDir(core.JoinPath(srcDir, "specs"))
// Remote stays as local clone origin — agent cannot push to forge.
// Reviewer pulls changes from workspace and pushes after verification.
// 2. Copy CLAUDE.md and GEMINI.md to workspace
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
claudeMdPath := core.JoinPath(repoPath, "CLAUDE.md")
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
coreio.Local.Write(core.JoinPath(wsDir, "src", "CLAUDE.md"), data)
out.ClaudeMd = true
}
// Copy GEMINI.md from core/agent (ethics framework for all agents)
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
agentGeminiMd := core.JoinPath(s.codePath, "core", "agent", "GEMINI.md")
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
coreio.Local.Write(core.JoinPath(wsDir, "src", "GEMINI.md"), data)
}
// Copy persona if specified
if persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
personaPath := core.JoinPath(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
if data, err := coreio.Local.Read(personaPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
coreio.Local.Write(core.JoinPath(wsDir, "src", "PERSONA.md"), data)
}
}
@ -269,9 +278,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
if input.Issue > 0 {
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
} 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)
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todo)
}
// 4. Generate CONTEXT.md from OpenBrain
@ -368,7 +377,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
prompt = "Read TODO.md and complete the task. Work in src/.\n"
}
coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
coreio.Local.Write(core.JoinPath(wsDir, "src", "PROMPT.md"), prompt)
}
// --- Plan template rendering ---
@ -377,11 +386,11 @@ Do NOT push. Commit only — a reviewer will verify and push.
// and writes PLAN.md into the workspace src/ directory.
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
// Look for template in core/agent/prompts/templates/
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
templatePath := core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
content, err := coreio.Local.Read(templatePath)
if err != nil {
// Try .yml extension
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
templatePath = core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
content, err = coreio.Local.Read(templatePath)
if err != nil {
return // Template not found, skip silently
@ -390,8 +399,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
// Substitute variables ({{variable_name}} → value)
for key, value := range variables {
content = strings.ReplaceAll(content, "{{"+key+"}}", value)
content = strings.ReplaceAll(content, "{{ "+key+" }}", value)
content = core.Replace(content, "{{"+key+"}}", value)
content = core.Replace(content, "{{ "+key+" }}", value)
}
// Parse the YAML to render as markdown
@ -411,42 +420,42 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
}
// Render as PLAN.md
var plan strings.Builder
plan.WriteString("# Plan: " + tmpl.Name + "\n\n")
planBuilder := core.NewBuilder()
planBuilder.WriteString("# Plan: " + tmpl.Name + "\n\n")
if task != "" {
plan.WriteString("**Task:** " + task + "\n\n")
planBuilder.WriteString("**Task:** " + task + "\n\n")
}
if tmpl.Description != "" {
plan.WriteString(tmpl.Description + "\n\n")
planBuilder.WriteString(tmpl.Description + "\n\n")
}
if len(tmpl.Guidelines) > 0 {
plan.WriteString("## Guidelines\n\n")
for _, g := range tmpl.Guidelines {
plan.WriteString("- " + g + "\n")
planBuilder.WriteString("## Guidelines\n\n")
for _, guideline := range tmpl.Guidelines {
planBuilder.WriteString("- " + guideline + "\n")
}
plan.WriteString("\n")
planBuilder.WriteString("\n")
}
for i, phase := range tmpl.Phases {
plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name))
for phaseIndex, phase := range tmpl.Phases {
planBuilder.WriteString(core.Sprintf("## Phase %d: %s\n\n", phaseIndex+1, phase.Name))
if phase.Description != "" {
plan.WriteString(phase.Description + "\n\n")
planBuilder.WriteString(phase.Description + "\n\n")
}
for _, task := range phase.Tasks {
switch t := task.(type) {
for _, phaseTask := range phase.Tasks {
switch taskValue := phaseTask.(type) {
case string:
plan.WriteString("- [ ] " + t + "\n")
planBuilder.WriteString("- [ ] " + taskValue + "\n")
case map[string]any:
if name, ok := t["name"].(string); ok {
plan.WriteString("- [ ] " + name + "\n")
if name, ok := taskValue["name"].(string); ok {
planBuilder.WriteString("- [ ] " + name + "\n")
}
}
}
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
planBuilder.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
}
coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
coreio.Local.Write(core.JoinPath(wsDir, "src", "PLAN.md"), planBuilder.String())
}
// --- Helpers (unchanged) ---
@ -456,8 +465,8 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
return 0
}
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
wikiURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
req, _ := http.NewRequestWithContext(ctx, "GET", wikiURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
@ -473,7 +482,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
Title string `json:"title"`
SubURL string `json:"sub_url"`
}
json.NewDecoder(resp.Body).Decode(&pages)
core.JSONUnmarshalString(readBody(resp.Body), &pages)
count := 0
for _, page := range pages {
@ -482,7 +491,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
subURL = page.Title
}
pageURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL)
pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL)
pageReq, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil)
pageReq.Header.Set("Authorization", "token "+s.forgeToken)
@ -498,7 +507,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
var pageData struct {
ContentBase64 string `json:"content_base64"`
}
json.NewDecoder(pageResp.Body).Decode(&pageData)
core.JSONUnmarshalString(readBody(pageResp.Body), &pageData)
pageResp.Body.Close()
if pageData.ContentBase64 == "" {
@ -506,14 +515,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
}
content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64)
filename := strings.Map(func(r rune) rune {
fileBuilder := core.NewBuilder()
for _, r := range page.Title {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
return r
fileBuilder.WriteRune(r)
} else {
fileBuilder.WriteRune('-')
}
return '-'
}, page.Title) + ".md"
}
filename := fileBuilder.String() + ".md"
coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content))
coreio.Local.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content))
count++
}
@ -522,17 +534,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
func (s *PrepSubsystem) copySpecs(wsDir string) int {
specFiles := []string{"AGENT_CONTEXT.md", "TASK_PROTOCOL.md"}
count := 0
specCount := 0
for _, file := range specFiles {
src := filepath.Join(s.specsPath, file)
if data, err := coreio.Local.Read(src); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data)
count++
sourcePath := core.JoinPath(s.specsPath, file)
if data, err := coreio.Local.Read(sourcePath); err == nil {
coreio.Local.Write(core.JoinPath(wsDir, "src", "specs", file), data)
specCount++
}
}
return count
return specCount
}
func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) int {
@ -540,14 +552,14 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
return 0
}
body, _ := json.Marshal(map[string]any{
body := core.JSONMarshalString(map[string]any{
"query": "architecture conventions key interfaces for " + repo,
"top_k": 10,
"project": repo,
"agent_id": "cladius",
})
req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body)))
req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+s.brainKey)
@ -565,26 +577,26 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
var result struct {
Memories []map[string]any `json:"memories"`
}
json.Unmarshal(respData, &result)
core.JSONUnmarshalString(string(respData), &result)
var content strings.Builder
content.WriteString("# Context — " + repo + "\n\n")
content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
contextBuilder := core.NewBuilder()
contextBuilder.WriteString("# Context — " + repo + "\n\n")
contextBuilder.WriteString("> Relevant knowledge from OpenBrain.\n\n")
for i, mem := range result.Memories {
for memIndex, mem := range result.Memories {
memType, _ := mem["type"].(string)
memContent, _ := mem["content"].(string)
memProject, _ := mem["project"].(string)
score, _ := mem["score"].(float64)
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
memScore, _ := mem["score"].(float64)
contextBuilder.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", memIndex+1, memProject, memType, memScore, memContent))
}
coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
coreio.Local.Write(core.JoinPath(wsDir, "src", "CONTEXT.md"), contextBuilder.String())
return len(result.Memories)
}
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
goWorkPath := filepath.Join(s.codePath, "go.work")
goWorkPath := core.JoinPath(s.codePath, "go.work")
modulePath := "forge.lthn.ai/core/" + repo
workData, err := coreio.Local.Read(goWorkPath)
@ -593,47 +605,47 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
}
var consumers []string
for _, line := range strings.Split(workData, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "./") {
for _, line := range core.Split(workData, "\n") {
line = core.Trim(line)
if !core.HasPrefix(line, "./") {
continue
}
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
goMod := filepath.Join(dir, "go.mod")
dir := core.JoinPath(s.codePath, core.TrimPrefix(line, "./"))
goMod := core.JoinPath(dir, "go.mod")
modData, err := coreio.Local.Read(goMod)
if err != nil {
continue
}
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
consumers = append(consumers, filepath.Base(dir))
if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) {
consumers = append(consumers, core.PathBase(dir))
}
}
if len(consumers) > 0 {
content := "# Consumers of " + repo + "\n\n"
content += "These modules import `" + modulePath + "`:\n\n"
for _, c := range consumers {
content += "- " + c + "\n"
consumersContent := "# Consumers of " + repo + "\n\n"
consumersContent += "These modules import `" + modulePath + "`:\n\n"
for _, consumer := range consumers {
consumersContent += "- " + consumer + "\n"
}
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
consumersContent += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
coreio.Local.Write(core.JoinPath(wsDir, "src", "CONSUMERS.md"), consumersContent)
}
return len(consumers)
}
func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
cmd := exec.Command("git", "log", "--oneline", "-20")
cmd.Dir = repoPath
output, err := cmd.Output()
gitCmd := exec.Command("git", "log", "--oneline", "-20")
gitCmd.Dir = repoPath
output, err := gitCmd.Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
lines := core.Split(core.Trim(string(output)), "\n")
if len(lines) > 0 && lines[0] != "" {
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content)
coreio.Local.Write(core.JoinPath(wsDir, "src", "RECENT.md"), content)
}
return len(lines)
@ -644,8 +656,8 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
return
}
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
req, _ := http.NewRequestWithContext(ctx, "GET", issueURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
@ -661,13 +673,23 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
Title string `json:"title"`
Body string `json:"body"`
}
json.NewDecoder(resp.Body).Decode(&issueData)
core.JSONUnmarshalString(readBody(resp.Body), &issueData)
content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title)
content += fmt.Sprintf("**Status:** ready\n")
content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
content += "## Objective\n\n" + issueData.Body + "\n"
todoContent := core.Sprintf("# TASK: %s\n\n", issueData.Title)
todoContent += "**Status:** ready\n"
todoContent += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
todoContent += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
todoContent += "## Objective\n\n" + issueData.Body + "\n"
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todoContent)
}
// readBody reads an HTTP response body to a string and closes it.
//
// body := readBody(resp.Body)
// core.JSONUnmarshalString(body, &result)
func readBody(body goio.ReadCloser) string {
data, _ := goio.ReadAll(body)
body.Close()
return string(data)
}

View file

@ -94,3 +94,36 @@ func TestPrepWorkspace_Bad_BadPlanTemplateTraversal(t *testing.T) {
t.Fatalf("expected plan template error, got %q", err)
}
}
func TestSanitizeRepoPathSegment_Ugly(t *testing.T) {
// Empty value is allowed (returns "", nil) — callers validate presence separately
value, err := sanitizeRepoPathSegment("", "repo", false)
if err != nil {
t.Errorf("expected nil error for empty value, got %v", err)
}
if value != "" {
t.Errorf("expected empty string, got %q", value)
}
// Null bytes are rejected as invalid characters
_, err = sanitizeRepoPathSegment("repo\x00name", "repo", false)
if err == nil {
t.Error("expected error for null byte in value, got nil")
}
// Leading whitespace is rejected
_, err = sanitizeRepoPathSegment(" repo", "repo", false)
if err == nil {
t.Error("expected error for leading whitespace, got nil")
}
}
func TestPrepWorkspace_Ugly(t *testing.T) {
// Empty codePath still validates inputs before hitting the filesystem
s := &PrepSubsystem{codePath: ""}
_, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{Repo: ""})
if err == nil {
t.Error("expected error for empty repo with empty codePath, got nil")
}
}

View file

@ -3,14 +3,12 @@
package agentic
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
"gopkg.in/yaml.v3"
)
@ -41,21 +39,24 @@ type AgentsConfig struct {
}
// loadAgentsConfig reads config/agents.yaml from the code path.
//
// agentsConfig := s.loadAgentsConfig()
// limit := agentsConfig.Concurrency["claude"] // 1
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
paths := []string{
filepath.Join(s.codePath, ".core", "agents.yaml"),
core.JoinPath(s.codePath, ".core", "agents.yaml"),
}
for _, path := range paths {
data, err := coreio.Local.Read(path)
for _, configPath := range paths {
data, err := coreio.Local.Read(configPath)
if err != nil {
continue
}
var cfg AgentsConfig
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
var configuration AgentsConfig
if err := yaml.Unmarshal([]byte(data), &configuration); err != nil {
continue
}
return &cfg
return &configuration
}
return &AgentsConfig{
@ -73,15 +74,15 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
// delayForAgent calculates how long to wait before spawning the next task
// for a given agent type, based on rate config and time of day.
func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
cfg := s.loadAgentsConfig()
rate, ok := cfg.Rates[agent]
agentsConfig := s.loadAgentsConfig()
rate, ok := agentsConfig.Rates[agent]
if !ok || rate.SustainedDelay == 0 {
return 0
}
// Parse reset time
// Parse reset time (e.g. "06:00")
resetHour, resetMin := 6, 0
fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin)
parseResetTime(rate.ResetUTC, &resetHour, &resetMin)
now := time.Now().UTC()
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
@ -103,6 +104,9 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
// listWorkspaceDirs returns all workspace directories, including those
// nested one level deep (e.g. workspace/core/go-io-123/).
//
// dirs := s.listWorkspaceDirs()
// // dirs == ["/home/user/.core/workspace/go-io-123", ...]
func (s *PrepSubsystem) listWorkspaceDirs() []string {
wsRoot := s.workspaceRoot()
entries, err := coreio.Local.List(wsRoot)
@ -115,21 +119,21 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
if !entry.IsDir() {
continue
}
path := filepath.Join(wsRoot, entry.Name())
entryPath := core.JoinPath(wsRoot, entry.Name())
// Check if this dir has a status.json (it's a workspace)
if coreio.Local.IsFile(filepath.Join(path, "status.json")) {
dirs = append(dirs, path)
if coreio.Local.IsFile(core.JoinPath(entryPath, "status.json")) {
dirs = append(dirs, entryPath)
continue
}
// Otherwise check one level deeper (org subdirectory)
subEntries, err := coreio.Local.List(path)
subEntries, err := coreio.Local.List(entryPath)
if err != nil {
continue
}
for _, sub := range subEntries {
if sub.IsDir() {
subPath := filepath.Join(path, sub.Name())
if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) {
subPath := core.JoinPath(entryPath, sub.Name())
if coreio.Local.IsFile(core.JoinPath(subPath, "status.json")) {
dirs = append(dirs, subPath)
}
}
@ -146,7 +150,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
if err != nil || st.Status != "running" {
continue
}
stBase := strings.SplitN(st.Agent, ":", 2)[0]
stBase := core.SplitN(st.Agent, ":", 2)[0]
if stBase != agent {
continue
}
@ -161,15 +165,18 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
}
// baseAgent strips the model variant (gemini:flash → gemini).
//
// baseAgent("gemini:flash") == "gemini"
// baseAgent("claude") == "claude"
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.
func (s *PrepSubsystem) canDispatchAgent(agent string) bool {
cfg := s.loadAgentsConfig()
agentsConfig := s.loadAgentsConfig()
base := baseAgent(agent)
limit, ok := cfg.Concurrency[base]
limit, ok := agentsConfig.Concurrency[base]
if !ok || limit <= 0 {
return true
}
@ -205,7 +212,7 @@ func (s *PrepSubsystem) drainQueue() {
continue
}
srcDir := filepath.Join(wsDir, "src")
srcDir := core.JoinPath(wsDir, "src")
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
command, args, err := agentCommand(st.Agent, prompt)
@ -213,7 +220,7 @@ func (s *PrepSubsystem) drainQueue() {
continue
}
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent))
outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", st.Agent))
outFile, err := os.Create(outputFile)
if err != nil {
continue
@ -264,3 +271,28 @@ func (s *PrepSubsystem) drainQueue() {
return
}
}
// parseResetTime parses "HH:MM" into hour and minute integers.
// On invalid input the defaults are unchanged.
//
// parseResetTime("06:30", &h, &m) // h=6, m=30
func parseResetTime(value string, hour, minute *int) {
parts := core.SplitN(value, ":", 2)
if len(parts) != 2 {
return
}
type hm struct {
H int `json:"h"`
M int `json:"m"`
}
var target hm
result := core.JSONUnmarshalString(
core.Sprintf(`{"h":%s,"m":%s}`, core.Trim(parts[0]), core.Trim(parts[1])),
&target,
)
if result.OK {
*hour = target.H
*minute = target.M
}
}

View file

@ -4,12 +4,11 @@ package agentic
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -45,8 +44,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
}
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
srcDir := filepath.Join(wsDir, "src")
wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace)
srcDir := core.JoinPath(wsDir, "src")
// Verify workspace exists
if _, err := coreio.Local.List(srcDir); err != nil {
@ -71,8 +70,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
// Write ANSWER.md if answer provided
if input.Answer != "" {
answerPath := filepath.Join(srcDir, "ANSWER.md")
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer)
answerPath := core.JoinPath(srcDir, "ANSWER.md")
content := core.Sprintf("# Answer\n\n%s\n", input.Answer)
if err := coreio.Local.Write(answerPath, content); err != nil {
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
}
@ -95,7 +94,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
}
// 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.JoinPath(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
command, args, err := agentCommand(agent, prompt)
if err != nil {

View file

@ -4,11 +4,9 @@ package agentic
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
core "dappco.re/go/core"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -81,7 +79,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
seen := make(map[string]bool)
var unique []ScanIssue
for _, issue := range allIssues {
key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number)
key := core.Sprintf("%s#%d", issue.Repo, issue.Number)
if !seen[key] {
seen[key] = true
unique = append(unique, issue)
@ -100,8 +98,8 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
}
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)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
orgReposURL := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
req, _ := http.NewRequestWithContext(ctx, "GET", orgReposURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
@ -110,13 +108,14 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
resp.Body.Close()
return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
}
var repos []struct {
Name string `json:"name"`
}
json.NewDecoder(resp.Body).Decode(&repos)
core.JSONUnmarshalString(readBody(resp.Body), &repos)
var names []string
for _, r := range repos {
@ -126,9 +125,9 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
}
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",
repoIssuesURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
s.forgeURL, org, repo, label)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req, _ := http.NewRequestWithContext(ctx, "GET", repoIssuesURL, nil)
req.Header.Set("Authorization", "token "+s.forgeToken)
resp, err := s.client.Do(req)
@ -137,7 +136,8 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
resp.Body.Close()
return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
}
var issues []struct {
@ -151,7 +151,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
} `json:"assignee"`
HTMLURL string `json:"html_url"`
}
json.NewDecoder(resp.Body).Decode(&issues)
core.JSONUnmarshalString(readBody(resp.Body), &issues)
var result []ScanIssue
for _, issue := range issues {
@ -170,7 +170,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
Title: issue.Title,
Labels: labels,
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

@ -4,12 +4,10 @@ package agentic
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -44,25 +42,29 @@ type WorkspaceStatus struct {
PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created)
}
// writeStatus serialises workspace status to status.json.
//
// writeStatus(wsDir, &WorkspaceStatus{Status: "running", Agent: "claude"})
func writeStatus(wsDir string, status *WorkspaceStatus) error {
status.UpdatedAt = time.Now()
data, err := json.MarshalIndent(status, "", " ")
if err != nil {
return err
}
return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data))
return coreio.Local.Write(core.JoinPath(wsDir, "status.json"), core.JSONMarshalString(status))
}
// readStatus deserialises workspace status from status.json.
//
// st, err := readStatus(wsDir)
// // st.Status == "running", st.Agent == "claude"
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 {
return nil, err
}
var s WorkspaceStatus
if err := json.Unmarshal([]byte(data), &s); err != nil {
return nil, err
var workspaceStatus WorkspaceStatus
result := core.JSONUnmarshalString(data, &workspaceStatus)
if !result.OK {
return nil, coreerr.E("readStatus", "failed to parse status.json", nil)
}
return &s, nil
return &workspaceStatus, nil
}
// --- agentic_status tool ---
@ -103,60 +105,60 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
var workspaces []WorkspaceInfo
for _, wsDir := range wsDirs {
name := filepath.Base(wsDir)
workspaceName := core.PathBase(wsDir)
// Filter by specific workspace if requested
if input.Workspace != "" && name != input.Workspace {
if input.Workspace != "" && workspaceName != input.Workspace {
continue
}
info := WorkspaceInfo{Name: name}
info := WorkspaceInfo{Name: workspaceName}
// Try reading status.json
st, err := readStatus(wsDir)
statusRecord, err := readStatus(wsDir)
if err != nil {
// Legacy workspace (no status.json) — check for log file
logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log"))
if len(logFiles) > 0 {
info.Status = "completed"
} else {
info.Status = "unknown"
}
if fi, err := os.Stat(wsDir); err == nil {
info.Age = time.Since(fi.ModTime()).Truncate(time.Minute).String()
if fileInfo, statErr := os.Stat(wsDir); statErr == nil {
info.Age = time.Since(fileInfo.ModTime()).Truncate(time.Minute).String()
}
workspaces = append(workspaces, info)
continue
}
info.Status = st.Status
info.Agent = st.Agent
info.Repo = st.Repo
info.Task = st.Task
info.Runs = st.Runs
info.Age = time.Since(st.StartedAt).Truncate(time.Minute).String()
info.Status = statusRecord.Status
info.Agent = statusRecord.Agent
info.Repo = statusRecord.Repo
info.Task = statusRecord.Task
info.Runs = statusRecord.Runs
info.Age = time.Since(statusRecord.StartedAt).Truncate(time.Minute).String()
// If status is "running", check if PID is still alive
if st.Status == "running" && st.PID > 0 {
proc, err := os.FindProcess(st.PID)
if statusRecord.Status == "running" && statusRecord.PID > 0 {
proc, err := os.FindProcess(statusRecord.PID)
if err != nil || proc.Signal(nil) != nil {
// Process died — check for BLOCKED.md
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md")
blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md")
if data, err := coreio.Local.Read(blockedPath); err == nil {
info.Status = "blocked"
info.Question = strings.TrimSpace(data)
st.Status = "blocked"
st.Question = info.Question
info.Question = core.Trim(data)
statusRecord.Status = "blocked"
statusRecord.Question = info.Question
} else {
info.Status = "completed"
st.Status = "completed"
statusRecord.Status = "completed"
}
writeStatus(wsDir, st)
writeStatus(wsDir, statusRecord)
}
}
if st.Status == "blocked" {
info.Question = st.Question
if statusRecord.Status == "blocked" {
info.Question = statusRecord.Question
}
workspaces = append(workspaces, info)

View file

@ -5,14 +5,11 @@ package brain
import (
"bytes"
"context"
"encoding/json"
"fmt"
goio "io"
"net/http"
"os"
"strings"
"time"
core "dappco.re/go/core"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
@ -44,16 +41,20 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string,
// NewDirect creates a brain subsystem that calls the OpenBrain API directly.
// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back
// to ~/.claude/brain.key for the API key.
//
// sub := brain.NewDirect()
// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}})
func NewDirect() *DirectSubsystem {
apiURL := os.Getenv("CORE_BRAIN_URL")
apiURL := core.Env("CORE_BRAIN_URL")
if apiURL == "" {
apiURL = "https://api.lthn.sh"
}
apiKey := os.Getenv("CORE_BRAIN_KEY")
apiKey := core.Env("CORE_BRAIN_KEY")
if apiKey == "" {
if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
apiKey = strings.TrimSpace(data)
keyPath := core.JoinPath(core.Env("HOME"), ".claude", "brain.key")
if data, err := coreio.Local.Read(keyPath); err == nil {
apiKey = core.Trim(data)
}
}
@ -95,11 +96,7 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
var reqBody goio.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, coreerr.E("brain.apiCall", "marshal request", err)
}
reqBody = bytes.NewReader(data)
reqBody = bytes.NewReader([]byte(core.JSONMarshalString(body)))
}
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
@ -126,8 +123,9 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
}
var result map[string]any
if err := json.Unmarshal(respData, &result); err != nil {
return nil, coreerr.E("brain.apiCall", "parse response", err)
r := core.JSONUnmarshalString(string(respData), &result)
if !r.OK {
return nil, coreerr.E("brain.apiCall", "parse response", nil)
}
return result, nil
@ -186,11 +184,11 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in
for _, m := range mems {
if mm, ok := m.(map[string]any); ok {
mem := Memory{
Content: fmt.Sprintf("%v", mm["content"]),
Type: fmt.Sprintf("%v", mm["type"]),
Project: fmt.Sprintf("%v", mm["project"]),
AgentID: fmt.Sprintf("%v", mm["agent_id"]),
CreatedAt: fmt.Sprintf("%v", mm["created_at"]),
Content: core.Sprintf("%v", mm["content"]),
Type: core.Sprintf("%v", mm["type"]),
Project: core.Sprintf("%v", mm["project"]),
AgentID: core.Sprintf("%v", mm["agent_id"]),
CreatedAt: core.Sprintf("%v", mm["created_at"]),
}
if id, ok := mm["id"].(string); ok {
mem.ID = id

View file

@ -24,8 +24,8 @@ type BridgeMessage struct {
// Bridge maintains a WebSocket connection to the Laravel core-agentic
// backend and forwards responses to a local ws.Hub.
type Bridge struct {
cfg Config
hub *ws.Hub
config Config
hub *ws.Hub
conn *websocket.Conn
mu sync.Mutex
@ -35,8 +35,10 @@ type Bridge struct {
// NewBridge creates a bridge that will connect to the Laravel backend and
// forward incoming messages to the provided ws.Hub channels.
func NewBridge(hub *ws.Hub, cfg Config) *Bridge {
return &Bridge{cfg: cfg, hub: hub}
//
// bridge := ide.NewBridge(hub, ide.DefaultConfig())
func NewBridge(hub *ws.Hub, configuration Config) *Bridge {
return &Bridge{config: configuration, hub: hub}
}
// Start begins the connection loop in a background goroutine.
@ -81,7 +83,7 @@ func (b *Bridge) Send(msg BridgeMessage) error {
// connectLoop reconnects to Laravel with exponential backoff.
func (b *Bridge) connectLoop(ctx context.Context) {
delay := b.cfg.ReconnectInterval
delay := b.config.ReconnectInterval
for {
select {
case <-ctx.Done():
@ -96,12 +98,12 @@ func (b *Bridge) connectLoop(ctx context.Context) {
return
case <-time.After(delay):
}
delay = min(delay*2, b.cfg.MaxReconnectInterval)
delay = min(delay*2, b.config.MaxReconnectInterval)
continue
}
// Reset backoff on successful connection
delay = b.cfg.ReconnectInterval
delay = b.config.ReconnectInterval
b.readLoop(ctx)
}
}
@ -112,12 +114,12 @@ func (b *Bridge) dial(ctx context.Context) error {
}
var header http.Header
if b.cfg.Token != "" {
if b.config.Token != "" {
header = http.Header{}
header.Set("Authorization", "Bearer "+b.cfg.Token)
header.Set("Authorization", "Bearer "+b.config.Token)
}
conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, header)
conn, _, err := dialer.DialContext(ctx, b.config.LaravelWSURL, header)
if err != nil {
return err
}
@ -127,7 +129,7 @@ func (b *Bridge) dial(ctx context.Context) error {
b.connected = true
b.mu.Unlock()
coreerr.Info("ide bridge: connected", "url", b.cfg.LaravelWSURL)
coreerr.Info("ide bridge: connected", "url", b.config.LaravelWSURL)
return nil
}

View file

@ -14,23 +14,25 @@ var errBridgeNotAvailable = coreerr.E("ide", "bridge not available", nil)
// Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE.
type Subsystem struct {
cfg Config
config Config
bridge *Bridge
hub *ws.Hub
}
// New creates an IDE subsystem. The ws.Hub is used for real-time forwarding;
// pass nil if headless (tools still work but real-time streaming is disabled).
//
// sub := ide.New(hub, ide.WithToken("sk-abc"))
func New(hub *ws.Hub, opts ...Option) *Subsystem {
cfg := DefaultConfig()
configuration := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
opt(&configuration)
}
var bridge *Bridge
if hub != nil {
bridge = NewBridge(hub, cfg)
bridge = NewBridge(hub, configuration)
}
return &Subsystem{cfg: cfg, bridge: bridge, hub: hub}
return &Subsystem{config: configuration, bridge: bridge, hub: hub}
}
// Name implements mcp.Subsystem.

View file

@ -716,17 +716,17 @@ func TestSubsystem_Good_WithOptions(t *testing.T) {
WithToken("secret-123"),
)
if sub.cfg.LaravelWSURL != "ws://custom:1234/ws" {
t.Errorf("expected custom URL, got %q", sub.cfg.LaravelWSURL)
if sub.config.LaravelWSURL != "ws://custom:1234/ws" {
t.Errorf("expected custom URL, got %q", sub.config.LaravelWSURL)
}
if sub.cfg.WorkspaceRoot != "/tmp/test" {
t.Errorf("expected workspace '/tmp/test', got %q", sub.cfg.WorkspaceRoot)
if sub.config.WorkspaceRoot != "/tmp/test" {
t.Errorf("expected workspace '/tmp/test', got %q", sub.config.WorkspaceRoot)
}
if sub.cfg.ReconnectInterval != 5*time.Second {
t.Errorf("expected 5s reconnect interval, got %v", sub.cfg.ReconnectInterval)
if sub.config.ReconnectInterval != 5*time.Second {
t.Errorf("expected 5s reconnect interval, got %v", sub.config.ReconnectInterval)
}
if sub.cfg.Token != "secret-123" {
t.Errorf("expected token 'secret-123', got %q", sub.cfg.Token)
if sub.config.Token != "secret-123" {
t.Errorf("expected token 'secret-123', got %q", sub.config.Token)
}
}

View file

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

View file

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

View file

@ -10,147 +10,137 @@ import (
"time"
)
func TestNewTCPTransport_Defaults(t *testing.T) {
// Test that empty string gets replaced with default address constant
// Note: We can't actually bind to 9100 as it may be in use,
// so we verify the address is set correctly before Listen is called
func TestTransportTcp_NewTCPTransport_Good(t *testing.T) {
// Default constant is correctly set
if DefaultTCPAddr != "127.0.0.1:9100" {
t.Errorf("Expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr)
t.Errorf("expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr)
}
// Test with a dynamic port to verify transport creation works
// Create transport with dynamic port
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create transport with dynamic port: %v", err)
t.Fatalf("failed to create transport with dynamic port: %v", err)
}
defer tr.listener.Close()
// Verify we got a valid address
if tr.addr != "127.0.0.1:0" {
t.Errorf("Expected address to be set, got %s", tr.addr)
t.Errorf("expected address to be set, got %s", tr.addr)
}
}
func TestNewTCPTransport_Warning(t *testing.T) {
// Capture warning output via setDiagWriter (mutex-protected, no race).
func TestTransportTcp_NewTCPTransport_Bad(t *testing.T) {
// Binding to an already-in-use port returns an error
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("first bind failed unexpectedly: %v", err)
}
defer tr.listener.Close()
addr := tr.listener.Addr().String()
_, err = NewTCPTransport(addr)
if err == nil {
t.Error("expected error when binding to already-in-use port, got nil")
}
}
func TestTransportTcp_NewTCPTransport_Ugly(t *testing.T) {
// Empty host defaults to 127.0.0.1 — never binds to 0.0.0.0
var buf bytes.Buffer
old := setDiagWriter(&buf)
defer setDiagWriter(old)
// Trigger warning — use port 0 (OS assigns free port)
tr, err := NewTCPTransport("0.0.0.0:0")
tr, err := NewTCPTransport(":0")
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
t.Fatalf("failed to create transport with empty host: %v", err)
}
defer tr.listener.Close()
output := buf.String()
if !strings.Contains(output, "WARNING") {
t.Error("Expected warning for binding to 0.0.0.0, but didn't find it in stderr")
// Should NOT have emitted a warning for 0.0.0.0
if strings.Contains(buf.String(), "WARNING") {
t.Error("unexpected warning for :0 (should default to 127.0.0.1, not 0.0.0.0)")
}
// The bound address must be on 127.0.0.1, not 0.0.0.0
host, _, _ := net.SplitHostPort(tr.listener.Addr().String())
if host != "127.0.0.1" {
t.Errorf("expected 127.0.0.1, got %s", host)
}
}
func TestServeTCP_Connection(t *testing.T) {
func TestTransportTcp_ServeTCP_Good(t *testing.T) {
s, err := New(Options{})
if err != nil {
t.Fatalf("Failed to create service: %v", err)
t.Fatalf("failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Use a random port for testing to avoid collisions
addr := "127.0.0.1:0"
// Create transport first to get the actual address if we use :0
tr, err := NewTCPTransport(addr)
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
}
actualAddr := tr.listener.Addr().String()
tr.listener.Close() // Close it so ServeTCP can re-open it or use the same address
// Start server in background
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, actualAddr)
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// Connect to the server
conn, err := net.Dial("tcp", actualAddr)
if err != nil {
t.Fatalf("Failed to connect to server: %v", err)
}
defer conn.Close()
// Verify we can write to it
_, err = conn.Write([]byte("{}\n"))
if err != nil {
t.Errorf("Failed to write to connection: %v", err)
}
// Shutdown server
cancel()
err = <-errCh
if err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}
func TestRun_TCPTrigger(t *testing.T) {
s, err := New(Options{})
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set MCP_ADDR to empty to trigger default TCP
os.Setenv("MCP_ADDR", "")
defer os.Unsetenv("MCP_ADDR")
// We use a random port for testing, but Run will try to use 127.0.0.1:9100 by default if we don't override.
// Since 9100 might be in use, we'll set MCP_ADDR to use :0 (random port)
os.Setenv("MCP_ADDR", "127.0.0.1:0")
errCh := make(chan error, 1)
go func() {
errCh <- s.Run(ctx)
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
// Since we can't easily get the actual port used by Run (it's internal),
// we just verify it didn't immediately fail.
select {
case err := <-errCh:
t.Fatalf("Run failed immediately: %v", err)
default:
// still running, which is good
}
cancel()
_ = <-errCh
}
func TestServeTCP_MultipleConnections(t *testing.T) {
s, err := New(Options{})
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addr := "127.0.0.1:0"
tr, err := NewTCPTransport(addr)
if err != nil {
t.Fatalf("Failed to create transport: %v", err)
t.Fatalf("failed to create transport: %v", err)
}
actualAddr := tr.listener.Addr().String()
tr.listener.Close()
errCh := make(chan error, 1)
go func() {
errCh <- s.ServeTCP(ctx, actualAddr)
}()
time.Sleep(100 * time.Millisecond)
conn, err := net.Dial("tcp", actualAddr)
if err != nil {
t.Fatalf("failed to connect to server: %v", err)
}
defer conn.Close()
_, err = conn.Write([]byte("{}\n"))
if err != nil {
t.Errorf("failed to write to connection: %v", err)
}
cancel()
if err = <-errCh; err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}
func TestTransportTcp_ServeTCP_Bad(t *testing.T) {
// ServeTCP with an already-in-use address returns an error
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("failed to create transport: %v", err)
}
defer tr.listener.Close()
addr := tr.listener.Addr().String()
s, err := New(Options{})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
ctx := context.Background()
err = s.ServeTCP(ctx, addr)
if err == nil {
t.Error("expected error when binding to already-in-use port, got nil")
}
}
func TestTransportTcp_ServeTCP_Ugly(t *testing.T) {
// Multiple simultaneous clients can connect and write without error
s, err := New(Options{})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tr, err := NewTCPTransport("127.0.0.1:0")
if err != nil {
t.Fatalf("failed to create transport: %v", err)
}
actualAddr := tr.listener.Addr().String()
tr.listener.Close()
@ -162,23 +152,68 @@ func TestServeTCP_MultipleConnections(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Connect multiple clients
const numClients = 3
for i := range numClients {
conn, err := net.Dial("tcp", actualAddr)
if err != nil {
t.Fatalf("Client %d failed to connect: %v", i, err)
t.Fatalf("client %d failed to connect: %v", i, err)
}
defer conn.Close()
_, err = conn.Write([]byte("{}\n"))
if err != nil {
t.Errorf("Client %d failed to write: %v", i, err)
t.Errorf("client %d failed to write: %v", i, err)
}
}
cancel()
err = <-errCh
if err != nil {
if err = <-errCh; err != nil {
t.Errorf("ServeTCP returned error: %v", err)
}
}
func TestTransportTcp_Run_Good(t *testing.T) {
s, err := New(Options{})
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
os.Setenv("MCP_ADDR", "127.0.0.1:0")
defer os.Unsetenv("MCP_ADDR")
errCh := make(chan error, 1)
go func() {
errCh <- s.Run(ctx)
}()
time.Sleep(100 * time.Millisecond)
select {
case err := <-errCh:
t.Fatalf("Run failed immediately: %v", err)
default:
// still running, which is good
}
cancel()
_ = <-errCh
}
func TestTransportTcp_Warning_Ugly(t *testing.T) {
// Binding to 0.0.0.0 emits a security warning
var buf bytes.Buffer
old := setDiagWriter(&buf)
defer setDiagWriter(old)
tr, err := NewTCPTransport("0.0.0.0:0")
if err != nil {
t.Fatalf("failed to create transport: %v", err)
}
defer tr.listener.Close()
if !strings.Contains(buf.String(), "WARNING") {
t.Error("expected security warning for 0.0.0.0 binding, got none")
}
}