refactor: replace fmt.Errorf/os.* with go-io/go-log conventions

Replace all fmt.Errorf and errors.New in production code with
coreerr.E("caller.Method", "message", err) from go-log. Replace
all os.ReadFile/os.WriteFile/os.MkdirAll/os.Remove with coreio.Local
equivalents from go-io. Test files are intentionally untouched.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-16 21:48:31 +00:00
parent 56397d7377
commit 5eb26f90fc
31 changed files with 258 additions and 231 deletions

View file

@ -1,7 +1,6 @@
package agent
import (
"errors"
"fmt"
"os"
"os/exec"
@ -11,6 +10,7 @@ import (
"forge.lthn.ai/core/cli/pkg/cli"
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/agentci"
"forge.lthn.ai/core/config"
)
@ -80,18 +80,18 @@ func agentAddCmd() *cli.Command {
keys, err := scanCmd.Output()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("failed to scan host keys: %w", err)
return coreerr.E("agent.add", "failed to scan host keys", err)
}
home, _ := os.UserHomeDir()
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
f, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open known_hosts: %w", err)
return coreerr.E("agent.add", "failed to open known_hosts", err)
}
if _, err := f.Write(keys); err != nil {
f.Close()
return fmt.Errorf("failed to write known_hosts: %w", err)
return coreerr.E("agent.add", "failed to write known_hosts", err)
}
f.Close()
fmt.Println(successStyle.Render("OK"))
@ -102,7 +102,7 @@ func agentAddCmd() *cli.Command {
out, err := testCmd.CombinedOutput()
if err != nil {
fmt.Println(errorStyle.Render("FAILED"))
return fmt.Errorf("SSH failed: %s", strings.TrimSpace(string(out)))
return coreerr.E("agent.add", "SSH failed: "+strings.TrimSpace(string(out)), nil)
}
fmt.Println(successStyle.Render("OK"))
@ -204,7 +204,7 @@ func agentStatusCmd() *cli.Command {
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
return coreerr.E("agent.status", "agent not found: "+name, nil)
}
script := `
@ -256,7 +256,7 @@ func agentLogsCmd() *cli.Command {
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found", name)
return coreerr.E("agent.status", "agent not found: "+name, nil)
}
remoteCmd := fmt.Sprintf("tail -n %d ~/ai-work/logs/runner.log", lines)
@ -294,13 +294,13 @@ func agentSetupCmd() *cli.Command {
}
ac, ok := agents[name]
if !ok {
return fmt.Errorf("agent %q not found — use 'core ai agent add' first", name)
return coreerr.E("agent.setup", "agent not found: "+name+" — use 'core ai agent add' first", nil)
}
// Find the setup script relative to the binary or in known locations.
scriptPath := findSetupScript()
if scriptPath == "" {
return errors.New("agent-setup.sh not found — expected in scripts/ directory")
return coreerr.E("agent.setup", "agent-setup.sh not found — expected in scripts/ directory", nil)
}
fmt.Printf("Setting up %s on %s...\n", name, ac.Host)
@ -308,7 +308,7 @@ func agentSetupCmd() *cli.Command {
setupCmd.Stdout = os.Stdout
setupCmd.Stderr = os.Stderr
if err := setupCmd.Run(); err != nil {
return fmt.Errorf("setup failed: %w", err)
return coreerr.E("agent.setup", "setup failed", err)
}
fmt.Println(successStyle.Render("Setup complete!"))
@ -358,7 +358,7 @@ func agentFleetCmd() *cli.Command {
registry, err := agentic.NewSQLiteRegistry(dbPath)
if err != nil {
return fmt.Errorf("failed to open registry: %w", err)
return coreerr.E("agent.fleet", "failed to open registry", err)
}
defer registry.Close()

View file

@ -17,6 +17,7 @@ import (
"time"
"forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-log"
agentic "forge.lthn.ai/core/agent/pkg/lifecycle"
@ -159,7 +160,7 @@ func dispatchWatchCmd() *cli.Command {
defer cancel()
if err := client.Ping(ctx); err != nil {
return fmt.Errorf("API ping failed (url=%s): %w", apiURL, err)
return log.E("dispatch.watch", "API ping failed (url="+apiURL+")", err)
}
log.Info("Connected to agentic API", "url", apiURL, "agent", agentID)
@ -348,7 +349,7 @@ func executePhaseWork(ctx context.Context, client *agentic.Client, plan *agentic
// Prepare the repository.
jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber))
repoDir := filepath.Join(jobDir, t.RepoName)
if err := os.MkdirAll(jobDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(jobDir); err != nil {
log.Error("Failed to create job dir", "error", err)
_ = client.EndSession(ctx, sessionID, string(agentic.SessionFailed), fmt.Sprintf("mkdir failed: %v", err))
return false
@ -507,8 +508,8 @@ func dispatchStatusCmd() *cli.Command {
paths := getPaths(workDir)
lockStatus := "IDLE"
if data, err := os.ReadFile(paths.lock); err == nil {
pidStr := strings.TrimSpace(string(data))
if data, err := coreio.Local.Read(paths.lock); err == nil {
pidStr := strings.TrimSpace(data)
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
lockStatus = fmt.Sprintf("RUNNING (PID %d)", pid)
@ -587,21 +588,21 @@ func processTicket(paths runnerPaths, ticketPath string) (bool, error) {
activePath := filepath.Join(paths.active, fileName)
if err := os.Rename(ticketPath, activePath); err != nil {
return false, fmt.Errorf("failed to move ticket to active: %w", err)
return false, log.E("processTicket", "failed to move ticket to active", err)
}
data, err := os.ReadFile(activePath)
data, err := coreio.Local.Read(activePath)
if err != nil {
return false, fmt.Errorf("failed to read ticket: %w", err)
return false, log.E("processTicket", "failed to read ticket", err)
}
var t dispatchTicket
if err := json.Unmarshal(data, &t); err != nil {
return false, fmt.Errorf("failed to unmarshal ticket: %w", err)
if err := json.Unmarshal([]byte(data), &t); err != nil {
return false, log.E("processTicket", "failed to unmarshal ticket", err)
}
jobDir := filepath.Join(paths.jobs, fmt.Sprintf("%s-%s-%d", t.RepoOwner, t.RepoName, t.IssueNumber))
repoDir := filepath.Join(jobDir, t.RepoName)
if err := os.MkdirAll(jobDir, 0755); err != nil {
if err := coreio.Local.EnsureDir(jobDir); err != nil {
return false, err
}
@ -673,14 +674,14 @@ func prepareRepo(t dispatchTicket, repoDir string) error {
continue
}
}
return fmt.Errorf("git command %v failed: %s", args, string(out))
return log.E("prepareRepo", "git command failed: "+string(out), err)
}
}
} else {
log.Info("Cloning repo", "url", t.RepoOwner+"/"+t.RepoName)
cmd := exec.Command("git", "clone", "-b", t.TargetBranch, cloneURL, repoDir)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git clone failed: %s", string(out))
return log.E("prepareRepo", "git clone failed: "+string(out), err)
}
}
return nil
@ -818,29 +819,29 @@ func moveToDone(paths runnerPaths, activePath, fileName string) {
func ensureDispatchDirs(p runnerPaths) error {
dirs := []string{p.queue, p.active, p.done, p.logs, p.jobs}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return fmt.Errorf("mkdir %s failed: %w", d, err)
if err := coreio.Local.EnsureDir(d); err != nil {
return log.E("ensureDispatchDirs", "mkdir "+d+" failed", err)
}
}
return nil
}
func acquireLock(lockPath string) error {
if data, err := os.ReadFile(lockPath); err == nil {
pidStr := strings.TrimSpace(string(data))
if data, err := coreio.Local.Read(lockPath); err == nil {
pidStr := strings.TrimSpace(data)
pid, _ := strconv.Atoi(pidStr)
if isProcessAlive(pid) {
return fmt.Errorf("locked by PID %d", pid)
return log.E("acquireLock", fmt.Sprintf("locked by PID %d", pid), nil)
}
log.Info("Removing stale lock", "pid", pid)
_ = os.Remove(lockPath)
_ = coreio.Local.Delete(lockPath)
}
return os.WriteFile(lockPath, []byte(fmt.Sprintf("%d", os.Getpid())), 0644)
return coreio.Local.Write(lockPath, fmt.Sprintf("%d", os.Getpid()))
}
func releaseLock(lockPath string) {
_ = os.Remove(lockPath)
_ = coreio.Local.Delete(lockPath)
}
func isProcessAlive(pid int) bool {

View file

@ -23,7 +23,6 @@ package workspace
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
@ -31,6 +30,7 @@ import (
"forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
var (
@ -92,7 +92,7 @@ func agentContextPath(wsPath, provider, name string) string {
func parseAgentID(id string) (provider, name string, err error) {
parts := strings.SplitN(id, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", errors.New("agent ID must be provider/agent-name (e.g. claude-opus/qa)")
return "", "", coreerr.E("parseAgentID", "agent ID must be provider/agent-name (e.g. claude-opus/qa)", nil)
}
return parts[0], parts[1], nil
}
@ -134,10 +134,10 @@ func runAgentInit(cmd *cli.Command, args []string) error {
// Create directory structure
if err := coreio.Local.EnsureDir(agentDir); err != nil {
return fmt.Errorf("failed to create agent directory: %w", err)
return coreerr.E("agentInit", "failed to create agent directory", err)
}
if err := coreio.Local.EnsureDir(filepath.Join(agentDir, "artifacts")); err != nil {
return fmt.Errorf("failed to create artifacts directory: %w", err)
return coreerr.E("agentInit", "failed to create artifacts directory", err)
}
// Create initial memory.md
@ -153,7 +153,7 @@ func runAgentInit(cmd *cli.Command, args []string) error {
`, provider, name, taskIssue, taskEpic, taskEpic, taskIssue, time.Now().Format(time.RFC3339))
if err := coreio.Local.Write(filepath.Join(agentDir, "memory.md"), memoryContent); err != nil {
return fmt.Errorf("failed to create memory.md: %w", err)
return coreerr.E("agentInit", "failed to create memory.md", err)
}
// Write manifest
@ -184,7 +184,7 @@ func runAgentList(cmd *cli.Command, args []string) error {
providers, err := coreio.Local.List(agentsDir)
if err != nil {
return fmt.Errorf("failed to list agents: %w", err)
return coreerr.E("agentList", "failed to list agents", err)
}
found := false

View file

@ -10,7 +10,6 @@ package workspace
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
@ -19,6 +18,7 @@ import (
"forge.lthn.ai/core/cli/pkg/cli"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/repos"
)
@ -114,7 +114,7 @@ func runTaskCreate(cmd *cli.Command, args []string) error {
if len(repoNames) == 0 {
repoNames, err = registryRepoNames(root)
if err != nil {
return fmt.Errorf("failed to load registry: %w", err)
return coreerr.E("taskCreate", "failed to load registry", err)
}
}
@ -133,7 +133,7 @@ func runTaskCreate(cmd *cli.Command, args []string) error {
}
if err := coreio.Local.EnsureDir(wsPath); err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
return coreerr.E("taskCreate", "failed to create workspace directory", err)
}
cli.Print("Creating task workspace: %s\n", cli.ValueStyle.Render(fmt.Sprintf("p%d/i%d", taskEpic, taskIssue)))
@ -190,14 +190,14 @@ func runTaskRemove(cmd *cli.Command, args []string) error {
cli.Print(" %s %s\n", cli.ErrorStyle.Render("·"), r)
}
cli.Print("\nUse --force to override or resolve the issues first.\n")
return errors.New("workspace has unresolved changes")
return coreerr.E("taskRemove", "workspace has unresolved changes", nil)
}
}
// Remove worktrees first (so git knows they're gone)
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
return coreerr.E("taskRemove", "failed to list workspace", err)
}
config, _ := LoadConfig(root)
@ -224,7 +224,7 @@ func runTaskRemove(cmd *cli.Command, args []string) error {
// Remove the workspace directory
if err := coreio.Local.DeleteAll(wsPath); err != nil {
return fmt.Errorf("failed to remove workspace directory: %w", err)
return coreerr.E("taskRemove", "failed to remove workspace directory", err)
}
// Clean up empty parent (p{epic}/) if it's now empty
@ -251,7 +251,7 @@ func runTaskList(cmd *cli.Command, args []string) error {
epics, err := coreio.Local.List(wsRoot)
if err != nil {
return fmt.Errorf("failed to list workspaces: %w", err)
return coreerr.E("taskList", "failed to list workspaces", err)
}
found := false
@ -316,7 +316,7 @@ func runTaskStatus(cmd *cli.Command, args []string) error {
entries, err := coreio.Local.List(wsPath)
if err != nil {
return fmt.Errorf("failed to list workspace: %w", err)
return coreerr.E("taskStatus", "failed to list workspace", err)
}
for _, entry := range entries {
@ -369,11 +369,11 @@ func createWorktree(ctx context.Context, repoPath, worktreePath, branch string)
cmd.Dir = repoPath
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
return coreerr.E("createWorktree", strings.TrimSpace(string(output)), nil)
}
return nil
}
return fmt.Errorf("%s", errStr)
return coreerr.E("createWorktree", errStr, nil)
}
return nil
}

View file

@ -1,12 +1,11 @@
package workspace
import (
"errors"
"fmt"
"os"
"path/filepath"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"gopkg.in/yaml.v3"
)
@ -46,16 +45,16 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
// No workspace.yaml found anywhere - return nil to indicate no config
return nil, nil
}
return nil, fmt.Errorf("failed to read workspace config: %w", err)
return nil, coreerr.E("LoadConfig", "failed to read workspace config", err)
}
config := DefaultConfig()
if err := yaml.Unmarshal([]byte(data), config); err != nil {
return nil, fmt.Errorf("failed to parse workspace config: %w", err)
return nil, coreerr.E("LoadConfig", "failed to parse workspace config", err)
}
if config.Version != 1 {
return nil, fmt.Errorf("unsupported workspace config version: %d", config.Version)
return nil, coreerr.E("LoadConfig", "unsupported workspace config version", nil)
}
return config, nil
@ -65,17 +64,17 @@ func LoadConfig(dir string) (*WorkspaceConfig, error) {
func SaveConfig(dir string, config *WorkspaceConfig) error {
coreDir := filepath.Join(dir, ".core")
if err := coreio.Local.EnsureDir(coreDir); err != nil {
return fmt.Errorf("failed to create .core directory: %w", err)
return coreerr.E("SaveConfig", "failed to create .core directory", err)
}
path := filepath.Join(coreDir, "workspace.yaml")
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal workspace config: %w", err)
return coreerr.E("SaveConfig", "failed to marshal workspace config", err)
}
if err := coreio.Local.Write(path, string(data)); err != nil {
return fmt.Errorf("failed to write workspace config: %w", err)
return coreerr.E("SaveConfig", "failed to write workspace config", err)
}
return nil
@ -100,5 +99,5 @@ func FindWorkspaceRoot() (string, error) {
dir = parent
}
return "", errors.New("not in a workspace")
return "", coreerr.E("FindWorkspaceRoot", "not in a workspace", nil)
}

View file

@ -10,6 +10,8 @@ import (
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-process"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -76,7 +78,7 @@ func agentCommand(agent, prompt string) (string, []string, error) {
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
return "bash", []string{script, prompt}, nil
default:
return "", nil, fmt.Errorf("unknown agent: %s", agent)
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
}
}
@ -99,7 +101,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st
Detach: true,
})
if err != nil {
return 0, "", fmt.Errorf("failed to spawn %s: %w", agent, err)
return 0, "", coreerr.E("dispatch.spawnAgent", "failed to spawn "+agent, err)
}
pid := proc.Info().PID
@ -109,7 +111,7 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st
// Write captured output to log file
if output := proc.Output(); output != "" {
os.WriteFile(outputFile, []byte(output), 0644)
coreio.Local.Write(outputFile, output)
}
// Update status to completed
@ -131,10 +133,10 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st
func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
if input.Repo == "" {
return nil, DispatchOutput{}, fmt.Errorf("repo is required")
return nil, DispatchOutput{}, coreerr.E("dispatch", "repo is required", nil)
}
if input.Task == "" {
return nil, DispatchOutput{}, fmt.Errorf("task is required")
return nil, DispatchOutput{}, coreerr.E("dispatch", "task is required", nil)
}
if input.Org == "" {
input.Org = "core"
@ -159,7 +161,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
}
_, prepOut, err := s.prepWorkspace(ctx, req, prepInput)
if err != nil {
return nil, DispatchOutput{}, fmt.Errorf("prep workspace failed: %w", err)
return nil, DispatchOutput{}, coreerr.E("dispatch", "prep workspace failed", err)
}
wsDir := prepOut.WorkspaceDir
@ -170,13 +172,13 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
if input.DryRun {
// Read PROMPT.md for the dry run output
promptContent, _ := os.ReadFile(filepath.Join(wsDir, "PROMPT.md"))
promptContent, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
return nil, DispatchOutput{
Success: true,
Agent: input.Agent,
Repo: input.Repo,
WorkspaceDir: wsDir,
Prompt: string(promptContent),
Prompt: promptContent,
}, nil
}

View file

@ -10,6 +10,7 @@ import (
"net/http"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -53,13 +54,13 @@ func (s *PrepSubsystem) registerEpicTool(server *mcp.Server) {
func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest, input EpicInput) (*mcp.CallToolResult, EpicOutput, error) {
if input.Title == "" {
return nil, EpicOutput{}, fmt.Errorf("title is required")
return nil, EpicOutput{}, coreerr.E("createEpic", "title is required", nil)
}
if len(input.Tasks) == 0 {
return nil, EpicOutput{}, fmt.Errorf("at least one task is required")
return nil, EpicOutput{}, coreerr.E("createEpic", "at least one task is required", nil)
}
if s.forgeToken == "" {
return nil, EpicOutput{}, fmt.Errorf("no Forge token configured")
return nil, EpicOutput{}, coreerr.E("createEpic", "no Forge token configured", nil)
}
if input.Org == "" {
input.Org = "core"
@ -112,7 +113,7 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
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)
if err != nil {
return nil, EpicOutput{}, fmt.Errorf("failed to create epic: %w", err)
return nil, EpicOutput{}, coreerr.E("createEpic", "failed to create epic", err)
}
out := EpicOutput{
@ -162,12 +163,12 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
resp, err := s.client.Do(req)
if err != nil {
return ChildRef{}, fmt.Errorf("create issue request failed: %w", err)
return ChildRef{}, coreerr.E("createIssue", "create issue request failed", err)
}
defer resp.Body.Close()
if resp.StatusCode != 201 {
return ChildRef{}, fmt.Errorf("create issue returned %d", resp.StatusCode)
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("create issue returned %d", resp.StatusCode), nil)
}
var result struct {

View file

@ -10,6 +10,8 @@ import (
"os"
"path/filepath"
"strings"
coreio "forge.lthn.ai/core/go-io"
)
// ingestFindings reads the agent output log and creates issues via the API
@ -26,12 +28,12 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
return
}
content, err := os.ReadFile(logFiles[0])
if err != nil || len(content) < 100 {
contentStr, err := coreio.Local.Read(logFiles[0])
if err != nil || len(contentStr) < 100 {
return
}
body := string(content)
body := contentStr
// Skip quota errors
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
@ -93,11 +95,11 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
// Read the agent API key from file
home, _ := os.UserHomeDir()
apiKeyData, err := os.ReadFile(filepath.Join(home, ".claude", "agent-api.key"))
apiKeyStr, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key"))
if err != nil {
return
}
apiKey := strings.TrimSpace(string(apiKeyData))
apiKey := strings.TrimSpace(apiKeyStr)
payload, _ := json.Marshal(map[string]string{
"title": title,

View file

@ -7,12 +7,13 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -145,10 +146,10 @@ func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) {
func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, PlanCreateOutput, error) {
if input.Title == "" {
return nil, PlanCreateOutput{}, fmt.Errorf("title is required")
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "title is required", nil)
}
if input.Objective == "" {
return nil, PlanCreateOutput{}, fmt.Errorf("objective is required")
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "objective is required", nil)
}
id := generatePlanID(input.Title)
@ -177,7 +178,7 @@ func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, in
path, err := writePlan(s.plansDir(), &plan)
if err != nil {
return nil, PlanCreateOutput{}, fmt.Errorf("failed to write plan: %w", err)
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "failed to write plan", err)
}
return nil, PlanCreateOutput{
@ -189,7 +190,7 @@ func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, in
func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanReadOutput, error) {
if input.ID == "" {
return nil, PlanReadOutput{}, fmt.Errorf("id is required")
return nil, PlanReadOutput{}, coreerr.E("planRead", "id is required", nil)
}
plan, err := readPlan(s.plansDir(), input.ID)
@ -205,7 +206,7 @@ func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, inpu
func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, input PlanUpdateInput) (*mcp.CallToolResult, PlanUpdateOutput, error) {
if input.ID == "" {
return nil, PlanUpdateOutput{}, fmt.Errorf("id is required")
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "id is required", nil)
}
plan, err := readPlan(s.plansDir(), input.ID)
@ -216,7 +217,7 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
// Apply partial updates
if input.Status != "" {
if !validPlanStatus(input.Status) {
return nil, PlanUpdateOutput{}, fmt.Errorf("invalid status: %s (valid: draft, ready, in_progress, needs_verification, verified, approved)", input.Status)
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "invalid status: "+input.Status+" (valid: draft, ready, in_progress, needs_verification, verified, approved)", nil)
}
plan.Status = input.Status
}
@ -239,7 +240,7 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
plan.UpdatedAt = time.Now()
if _, err := writePlan(s.plansDir(), plan); err != nil {
return nil, PlanUpdateOutput{}, fmt.Errorf("failed to write plan: %w", err)
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "failed to write plan", err)
}
return nil, PlanUpdateOutput{
@ -250,16 +251,16 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanDeleteOutput, error) {
if input.ID == "" {
return nil, PlanDeleteOutput{}, fmt.Errorf("id is required")
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "id is required", nil)
}
path := planPath(s.plansDir(), input.ID)
if _, err := os.Stat(path); err != nil {
return nil, PlanDeleteOutput{}, fmt.Errorf("plan not found: %s", input.ID)
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "plan not found: "+input.ID, nil)
}
if err := os.Remove(path); err != nil {
return nil, PlanDeleteOutput{}, fmt.Errorf("failed to delete plan: %w", err)
if err := coreio.Local.Delete(path); err != nil {
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "failed to delete plan", err)
}
return nil, PlanDeleteOutput{
@ -270,13 +271,13 @@ func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, in
func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, input PlanListInput) (*mcp.CallToolResult, PlanListOutput, error) {
dir := s.plansDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, PlanListOutput{}, fmt.Errorf("failed to access plans directory: %w", err)
if err := coreio.Local.EnsureDir(dir); err != nil {
return nil, PlanListOutput{}, coreerr.E("planList", "failed to access plans directory", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, PlanListOutput{}, fmt.Errorf("failed to read plans directory: %w", err)
return nil, PlanListOutput{}, coreerr.E("planList", "failed to read plans directory", err)
}
var plans []Plan
@ -351,21 +352,21 @@ func generatePlanID(title string) string {
}
func readPlan(dir, id string) (*Plan, error) {
data, err := os.ReadFile(planPath(dir, id))
data, err := coreio.Local.Read(planPath(dir, id))
if err != nil {
return nil, fmt.Errorf("plan not found: %s", id)
return nil, coreerr.E("readPlan", "plan not found: "+id, nil)
}
var plan Plan
if err := json.Unmarshal(data, &plan); err != nil {
return nil, fmt.Errorf("failed to parse plan %s: %w", id, err)
if err := json.Unmarshal([]byte(data), &plan); err != nil {
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err)
}
return &plan, nil
}
func writePlan(dir string, plan *Plan) (string, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("failed to create plans directory: %w", err)
if err := coreio.Local.EnsureDir(dir); err != nil {
return "", coreerr.E("writePlan", "failed to create plans directory", err)
}
path := planPath(dir, plan.ID)
@ -374,7 +375,7 @@ func writePlan(dir string, plan *Plan) (string, error) {
return "", err
}
return path, os.WriteFile(path, data, 0644)
return path, coreio.Local.Write(path, string(data))
}
func validPlanStatus(status string) bool {

View file

@ -13,6 +13,7 @@ import (
"path/filepath"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -47,10 +48,10 @@ func (s *PrepSubsystem) registerCreatePRTool(server *mcp.Server) {
func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, input CreatePRInput) (*mcp.CallToolResult, CreatePROutput, error) {
if input.Workspace == "" {
return nil, CreatePROutput{}, fmt.Errorf("workspace is required")
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace is required", nil)
}
if s.forgeToken == "" {
return nil, CreatePROutput{}, fmt.Errorf("no Forge token configured")
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
}
home, _ := os.UserHomeDir()
@ -58,13 +59,13 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
srcDir := filepath.Join(wsDir, "src")
if _, err := os.Stat(srcDir); err != nil {
return nil, CreatePROutput{}, fmt.Errorf("workspace not found: %s", input.Workspace)
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
}
// Read workspace status for repo, branch, issue context
st, err := readStatus(wsDir)
if err != nil {
return nil, CreatePROutput{}, fmt.Errorf("no status.json: %w", err)
return nil, CreatePROutput{}, coreerr.E("createPR", "no status.json", err)
}
if st.Branch == "" {
@ -73,7 +74,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
branchCmd.Dir = srcDir
out, err := branchCmd.Output()
if err != nil {
return nil, CreatePROutput{}, fmt.Errorf("failed to detect branch: %w", err)
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err)
}
st.Branch = strings.TrimSpace(string(out))
}
@ -116,13 +117,13 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
pushCmd.Dir = srcDir
pushOut, err := pushCmd.CombinedOutput()
if err != nil {
return nil, CreatePROutput{}, fmt.Errorf("git push failed: %s: %w", string(pushOut), err)
return nil, CreatePROutput{}, coreerr.E("createPR", "git push failed: "+string(pushOut), err)
}
// Create PR via Forge API
prURL, prNum, err := s.forgeCreatePR(ctx, org, st.Repo, st.Branch, base, title, body)
if err != nil {
return nil, CreatePROutput{}, fmt.Errorf("failed to create PR: %w", err)
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to create PR", err)
}
// Update status with PR URL
@ -177,7 +178,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
resp, err := s.client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("request failed: %w", err)
return "", 0, coreerr.E("forgeCreatePR", "request failed", err)
}
defer resp.Body.Close()
@ -185,7 +186,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
var errBody map[string]any
json.NewDecoder(resp.Body).Decode(&errBody)
msg, _ := errBody["message"].(string)
return "", 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil)
}
var pr struct {
@ -252,7 +253,7 @@ func (s *PrepSubsystem) registerListPRsTool(server *mcp.Server) {
func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) {
if s.forgeToken == "" {
return nil, ListPRsOutput{}, fmt.Errorf("no Forge token configured")
return nil, ListPRsOutput{}, coreerr.E("listPRs", "no Forge token configured", nil)
}
if input.Org == "" {
@ -309,7 +310,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
resp, err := s.client.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to list PRs for %s: %v", repo, err)
return nil, coreerr.E("listRepoPRs", "failed to list PRs for "+repo, err)
}
defer resp.Body.Close()

View file

@ -17,6 +17,8 @@ import (
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3"
)
@ -43,8 +45,8 @@ func NewPrep() *PrepSubsystem {
brainKey := os.Getenv("CORE_BRAIN_KEY")
if brainKey == "" {
if data, err := os.ReadFile(filepath.Join(home, ".claude", "brain.key")); err == nil {
brainKey = strings.TrimSpace(string(data))
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
brainKey = strings.TrimSpace(data)
}
}
@ -122,7 +124,7 @@ type PrepOutput struct {
func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) {
if input.Repo == "" {
return nil, PrepOutput{}, fmt.Errorf("repo is required")
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "repo is required", nil)
}
if input.Org == "" {
input.Org = "core"
@ -171,29 +173,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
branchCmd.Run()
// Create context dirs inside src/
os.MkdirAll(filepath.Join(srcDir, "kb"), 0755)
os.MkdirAll(filepath.Join(srcDir, "specs"), 0755)
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
coreio.Local.EnsureDir(filepath.Join(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")
if data, err := os.ReadFile(claudeMdPath); err == nil {
os.WriteFile(filepath.Join(wsDir, "src", "CLAUDE.md"), data, 0644)
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
coreio.Local.Write(filepath.Join(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")
if data, err := os.ReadFile(agentGeminiMd); err == nil {
os.WriteFile(filepath.Join(wsDir, "src", "GEMINI.md"), data, 0644)
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
}
// Copy persona if specified
if input.Persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", input.Persona+".md")
if data, err := os.ReadFile(personaPath); err == nil {
os.WriteFile(filepath.Join(wsDir, "src", "PERSONA.md"), data, 0644)
if data, err := coreio.Local.Read(personaPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
}
}
@ -203,7 +205,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
} else if input.Task != "" {
todo := fmt.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)
os.WriteFile(filepath.Join(wsDir, "src", "TODO.md"), []byte(todo), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
}
// 4. Generate CONTEXT.md from OpenBrain
@ -300,7 +302,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
prompt = "Read TODO.md and complete the task. Work in src/.\n"
}
os.WriteFile(filepath.Join(wsDir, "src", "PROMPT.md"), []byte(prompt), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
}
// --- Plan template rendering ---
@ -310,17 +312,17 @@ Do NOT push. Commit only — a reviewer will verify and push.
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")
data, err := os.ReadFile(templatePath)
data, err := coreio.Local.Read(templatePath)
if err != nil {
// Try .yml extension
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
data, err = os.ReadFile(templatePath)
data, err = coreio.Local.Read(templatePath)
if err != nil {
return // Template not found, skip silently
}
}
content := string(data)
content := data
// Substitute variables ({{variable_name}} → value)
for key, value := range variables {
@ -380,7 +382,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
}
os.WriteFile(filepath.Join(wsDir, "src", "PLAN.md"), []byte(plan.String()), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
}
// --- Helpers (unchanged) ---
@ -440,7 +442,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
return '-'
}, page.Title) + ".md"
os.WriteFile(filepath.Join(wsDir, "src", "kb", filename), content, 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content))
count++
}
@ -453,8 +455,8 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int {
for _, file := range specFiles {
src := filepath.Join(s.specsPath, file)
if data, err := os.ReadFile(src); err == nil {
os.WriteFile(filepath.Join(wsDir, "src", "specs", file), data, 0644)
if data, err := coreio.Local.Read(src); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data)
count++
}
}
@ -503,7 +505,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
}
os.WriteFile(filepath.Join(wsDir, "src", "CONTEXT.md"), []byte(content.String()), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
return len(result.Memories)
}
@ -511,24 +513,24 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
goWorkPath := filepath.Join(s.codePath, "go.work")
modulePath := "forge.lthn.ai/core/" + repo
workData, err := os.ReadFile(goWorkPath)
workData, err := coreio.Local.Read(goWorkPath)
if err != nil {
return 0
}
var consumers []string
for _, line := range strings.Split(string(workData), "\n") {
for _, line := range strings.Split(workData, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "./") {
continue
}
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
goMod := filepath.Join(dir, "go.mod")
modData, err := os.ReadFile(goMod)
modData, err := coreio.Local.Read(goMod)
if err != nil {
continue
}
if strings.Contains(string(modData), modulePath) && !strings.HasPrefix(string(modData), "module "+modulePath) {
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
consumers = append(consumers, filepath.Base(dir))
}
}
@ -540,7 +542,7 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
content += "- " + c + "\n"
}
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
os.WriteFile(filepath.Join(wsDir, "src", "CONSUMERS.md"), []byte(content), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
}
return len(consumers)
@ -557,7 +559,7 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 0 && lines[0] != "" {
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
os.WriteFile(filepath.Join(wsDir, "src", "RECENT.md"), []byte(content), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content)
}
return len(lines)
@ -590,5 +592,5 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
content += "## Objective\n\n" + issueData.Body + "\n"
os.WriteFile(filepath.Join(wsDir, "src", "TODO.md"), []byte(content), 0644)
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
}

View file

@ -10,6 +10,7 @@ import (
"syscall"
"time"
coreio "forge.lthn.ai/core/go-io"
"gopkg.in/yaml.v3"
)
@ -47,12 +48,12 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
}
for _, path := range paths {
data, err := os.ReadFile(path)
data, err := coreio.Local.Read(path)
if err != nil {
continue
}
var cfg AgentsConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
continue
}
return &cfg

View file

@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -38,7 +40,7 @@ func (s *PrepSubsystem) registerResumeTool(server *mcp.Server) {
func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, input ResumeInput) (*mcp.CallToolResult, ResumeOutput, error) {
if input.Workspace == "" {
return nil, ResumeOutput{}, fmt.Errorf("workspace is required")
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
}
home, _ := os.UserHomeDir()
@ -47,17 +49,17 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
// Verify workspace exists
if _, err := os.Stat(srcDir); err != nil {
return nil, ResumeOutput{}, fmt.Errorf("workspace not found: %s", input.Workspace)
return nil, ResumeOutput{}, coreerr.E("resume", "workspace not found: "+input.Workspace, nil)
}
// Read current status
st, err := readStatus(wsDir)
if err != nil {
return nil, ResumeOutput{}, fmt.Errorf("no status.json in workspace: %w", err)
return nil, ResumeOutput{}, coreerr.E("resume", "no status.json in workspace", err)
}
if st.Status != "blocked" && st.Status != "failed" && st.Status != "completed" {
return nil, ResumeOutput{}, fmt.Errorf("workspace is %s, not resumable (must be blocked, failed, or completed)", st.Status)
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is "+st.Status+", not resumable (must be blocked, failed, or completed)", nil)
}
// Determine agent
@ -70,8 +72,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
if input.Answer != "" {
answerPath := filepath.Join(srcDir, "ANSWER.md")
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer)
if err := os.WriteFile(answerPath, []byte(content), 0644); err != nil {
return nil, ResumeOutput{}, fmt.Errorf("failed to write ANSWER.md: %w", err)
if err := coreio.Local.Write(answerPath, content); err != nil {
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
}
}

View file

@ -9,6 +9,7 @@ import (
"net/http"
"strings"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -38,7 +39,7 @@ type ScanIssue struct {
func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input ScanInput) (*mcp.CallToolResult, ScanOutput, error) {
if s.forgeToken == "" {
return nil, ScanOutput{}, fmt.Errorf("no Forge token configured")
return nil, ScanOutput{}, coreerr.E("scan", "no Forge token configured", nil)
}
if input.Org == "" {
@ -105,7 +106,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
resp, err := s.client.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to list repos: %v", err)
return nil, coreerr.E("scan.listOrgRepos", "failed to list repos", err)
}
defer resp.Body.Close()
@ -129,7 +130,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
resp, err := s.client.Do(req)
if err != nil || resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to list issues for %s: %v", repo, err)
return nil, coreerr.E("scan.listRepoIssues", "failed to list issues for "+repo, err)
}
defer resp.Body.Close()

View file

@ -5,12 +5,13 @@ package agentic
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -49,16 +50,16 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error {
if err != nil {
return err
}
return os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0644)
return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data))
}
func readStatus(wsDir string) (*WorkspaceStatus, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "status.json"))
data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json"))
if err != nil {
return nil, err
}
var s WorkspaceStatus
if err := json.Unmarshal(data, &s); err != nil {
if err := json.Unmarshal([]byte(data), &s); err != nil {
return nil, err
}
return &s, nil
@ -99,7 +100,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
entries, err := os.ReadDir(wsRoot)
if err != nil {
return nil, StatusOutput{}, fmt.Errorf("no workspaces found: %w", err)
return nil, StatusOutput{}, coreerr.E("status", "no workspaces found", err)
}
var workspaces []WorkspaceInfo
@ -150,9 +151,9 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
if err != nil || proc.Signal(nil) != nil {
// Process died — check for BLOCKED.md
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md")
if data, err := os.ReadFile(blockedPath); err == nil {
if data, err := coreio.Local.Read(blockedPath); err == nil {
info.Status = "blocked"
info.Question = strings.TrimSpace(string(data))
info.Question = strings.TrimSpace(data)
st.Status = "blocked"
st.Question = info.Question
} else {

View file

@ -6,15 +6,15 @@ package brain
import (
"context"
"errors"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
// but it has not been initialised (headless mode).
var errBridgeNotAvailable = errors.New("brain: bridge not available")
var errBridgeNotAvailable = coreerr.E("brain", "bridge not available", nil)
// Subsystem implements mcp.Subsystem for OpenBrain knowledge store operations.
// It proxies brain_* tool calls to the Laravel backend via the shared IDE bridge.

View file

@ -10,9 +10,12 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -49,8 +52,9 @@ func NewDirect() *DirectSubsystem {
apiKey := os.Getenv("CORE_BRAIN_KEY")
if apiKey == "" {
if data, err := os.ReadFile(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
apiKey = strings.TrimSpace(string(data))
home, _ := os.UserHomeDir()
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
apiKey = strings.TrimSpace(data)
}
}
@ -90,21 +94,21 @@ func (s *DirectSubsystem) Shutdown(_ context.Context) error { return nil }
func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) {
if s.apiKey == "" {
return nil, fmt.Errorf("brain: no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)")
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
}
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("brain: marshal request: %w", err)
return nil, coreerr.E("brain.apiCall", "marshal request", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("brain: create request: %w", err)
return nil, coreerr.E("brain.apiCall", "create request", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
@ -112,22 +116,22 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("brain: API call failed: %w", err)
return nil, coreerr.E("brain.apiCall", "API call failed", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("brain: read response: %w", err)
return nil, coreerr.E("brain.apiCall", "read response", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("brain: API returned %d: %s", resp.StatusCode, string(respData))
return nil, coreerr.E("brain.apiCall", fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(respData)), nil)
}
var result map[string]any
if err := json.Unmarshal(respData, &result); err != nil {
return nil, fmt.Errorf("brain: parse response: %w", err)
return nil, coreerr.E("brain.apiCall", "parse response", err)
}
return result, nil

View file

@ -6,6 +6,7 @@ import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -73,7 +74,7 @@ type ConversationOutput struct {
func (s *DirectSubsystem) sendMessage(ctx context.Context, _ *mcp.CallToolRequest, input SendInput) (*mcp.CallToolResult, SendOutput, error) {
if input.To == "" || input.Content == "" {
return nil, SendOutput{}, fmt.Errorf("to and content are required")
return nil, SendOutput{}, coreerr.E("brain.sendMessage", "to and content are required", nil)
}
result, err := s.apiCall(ctx, "POST", "/v1/messages/send", map[string]any{
@ -114,7 +115,7 @@ func (s *DirectSubsystem) inbox(ctx context.Context, _ *mcp.CallToolRequest, inp
func (s *DirectSubsystem) conversation(ctx context.Context, _ *mcp.CallToolRequest, input ConversationInput) (*mcp.CallToolResult, ConversationOutput, error) {
if input.Agent == "" {
return nil, ConversationOutput{}, fmt.Errorf("agent is required")
return nil, ConversationOutput{}, coreerr.E("brain.conversation", "agent is required", nil)
}
result, err := s.apiCall(ctx, "GET", "/v1/messages/conversation/"+input.Agent+"?me="+agentName(), nil)

View file

@ -4,9 +4,9 @@ package brain
import (
"context"
"fmt"
"time"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -140,7 +140,7 @@ func (s *Subsystem) brainRemember(_ context.Context, _ *mcp.CallToolRequest, inp
},
})
if err != nil {
return nil, RememberOutput{}, fmt.Errorf("failed to send brain_remember: %w", err)
return nil, RememberOutput{}, coreerr.E("brain.remember", "failed to send brain_remember", err)
}
return nil, RememberOutput{
@ -163,7 +163,7 @@ func (s *Subsystem) brainRecall(_ context.Context, _ *mcp.CallToolRequest, input
},
})
if err != nil {
return nil, RecallOutput{}, fmt.Errorf("failed to send brain_recall: %w", err)
return nil, RecallOutput{}, coreerr.E("brain.recall", "failed to send brain_recall", err)
}
return nil, RecallOutput{
@ -185,7 +185,7 @@ func (s *Subsystem) brainForget(_ context.Context, _ *mcp.CallToolRequest, input
},
})
if err != nil {
return nil, ForgetOutput{}, fmt.Errorf("failed to send brain_forget: %w", err)
return nil, ForgetOutput{}, coreerr.E("brain.forget", "failed to send brain_forget", err)
}
return nil, ForgetOutput{
@ -210,7 +210,7 @@ func (s *Subsystem) brainList(_ context.Context, _ *mcp.CallToolRequest, input L
},
})
if err != nil {
return nil, ListOutput{}, fmt.Errorf("failed to send brain_list: %w", err)
return nil, ListOutput{}, coreerr.E("brain.list", "failed to send brain_list", err)
}
return nil, ListOutput{

View file

@ -5,6 +5,7 @@ import (
"fmt"
"time"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/forge"
"forge.lthn.ai/core/agent/pkg/jobrunner"
)
@ -47,11 +48,11 @@ func (h *CompletionHandler) Execute(ctx context.Context, signal *jobrunner.Pipel
if signal.Success {
completeLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentComplete, ColorAgentComplete)
if err != nil {
return nil, fmt.Errorf("ensure label %s: %w", LabelAgentComplete, err)
return nil, coreerr.E("completion.Execute", "ensure label "+LabelAgentComplete, err)
}
if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{completeLabel.ID}); err != nil {
return nil, fmt.Errorf("add completed label: %w", err)
return nil, coreerr.E("completion.Execute", "add completed label", err)
}
if signal.Message != "" {
@ -60,11 +61,11 @@ func (h *CompletionHandler) Execute(ctx context.Context, signal *jobrunner.Pipel
} else {
failedLabel, err := h.forge.EnsureLabel(signal.RepoOwner, signal.RepoName, LabelAgentFailed, ColorAgentFailed)
if err != nil {
return nil, fmt.Errorf("ensure label %s: %w", LabelAgentFailed, err)
return nil, coreerr.E("completion.Execute", "ensure label "+LabelAgentFailed, err)
}
if err := h.forge.AddIssueLabels(signal.RepoOwner, signal.RepoName, int64(signal.ChildNumber), []int64{failedLabel.ID}); err != nil {
return nil, fmt.Errorf("add failed label: %w", err)
return nil, coreerr.E("completion.Execute", "add failed label", err)
}
msg := "Agent reported failure."

View file

@ -11,7 +11,7 @@ import (
agentci "forge.lthn.ai/core/agent/pkg/orchestrator"
"forge.lthn.ai/core/go-scm/forge"
"forge.lthn.ai/core/agent/pkg/jobrunner"
"forge.lthn.ai/core/go-log"
coreerr "forge.lthn.ai/core/go-log"
)
const (
@ -83,23 +83,23 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
agentName, agent, ok := h.spinner.FindByForgejoUser(signal.Assignee)
if !ok {
return nil, fmt.Errorf("handlers.Dispatch.Execute: unknown agent: %s", signal.Assignee)
return nil, coreerr.E("handlers.Dispatch.Execute", "unknown agent: "+signal.Assignee, nil)
}
// Sanitize inputs to prevent path traversal.
safeOwner, err := agentci.SanitizePath(signal.RepoOwner)
if err != nil {
return nil, fmt.Errorf("invalid repo owner: %w", err)
return nil, coreerr.E("handlers.Dispatch.Execute", "invalid repo owner", err)
}
safeRepo, err := agentci.SanitizePath(signal.RepoName)
if err != nil {
return nil, fmt.Errorf("invalid repo name: %w", err)
return nil, coreerr.E("handlers.Dispatch.Execute", "invalid repo name", err)
}
// Ensure in-progress label exists on repo.
inProgressLabel, err := h.forge.EnsureLabel(safeOwner, safeRepo, LabelInProgress, ColorInProgress)
if err != nil {
return nil, fmt.Errorf("ensure label %s: %w", LabelInProgress, err)
return nil, coreerr.E("handlers.Dispatch.Execute", "ensure label "+LabelInProgress, err)
}
// Check if already in progress to prevent double-dispatch.
@ -107,7 +107,7 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
if err == nil {
for _, l := range issue.Labels {
if l.Name == LabelInProgress || l.Name == LabelAgentComplete {
log.Info("issue already processed, skipping", "issue", signal.ChildNumber)
coreerr.Info("issue already processed, skipping", "issue", signal.ChildNumber)
return &jobrunner.ActionResult{
Action: "dispatch",
Success: true,
@ -120,11 +120,11 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
// Assign agent and add in-progress label.
if err := h.forge.AssignIssue(safeOwner, safeRepo, int64(signal.ChildNumber), []string{signal.Assignee}); err != nil {
log.Warn("failed to assign agent, continuing", "err", err)
coreerr.Warn("failed to assign agent, continuing", "err", err)
}
if err := h.forge.AddIssueLabels(safeOwner, safeRepo, int64(signal.ChildNumber), []int64{inProgressLabel.ID}); err != nil {
return nil, fmt.Errorf("add in-progress label: %w", err)
return nil, coreerr.E("handlers.Dispatch.Execute", "add in-progress label", err)
}
// Remove agent-ready label if present.
@ -164,13 +164,13 @@ func (h *DispatchHandler) Execute(ctx context.Context, signal *jobrunner.Pipelin
ticketJSON, err := json.MarshalIndent(ticket, "", " ")
if err != nil {
h.failDispatch(signal, "Failed to marshal ticket JSON")
return nil, fmt.Errorf("marshal ticket: %w", err)
return nil, coreerr.E("handlers.Dispatch.Execute", "marshal ticket", err)
}
// Check if ticket already exists on agent (dedup).
ticketName := fmt.Sprintf("ticket-%s-%s-%d.json", safeOwner, safeRepo, signal.ChildNumber)
if h.ticketExists(ctx, agent, ticketName) {
log.Info("ticket already queued, skipping", "ticket", ticketName, "agent", signal.Assignee)
coreerr.Info("ticket already queued, skipping", "ticket", ticketName, "agent", signal.Assignee)
return &jobrunner.ActionResult{
Action: "dispatch",
RepoOwner: safeOwner,
@ -263,7 +263,7 @@ func (h *DispatchHandler) secureTransfer(ctx context.Context, agent agentci.Agen
output, err := cmd.CombinedOutput()
if err != nil {
return log.E("dispatch.transfer", fmt.Sprintf("ssh to %s failed: %s", agent.Host, string(output)), err)
return coreerr.E("dispatch.transfer", fmt.Sprintf("ssh to %s failed: %s", agent.Host, string(output)), err)
}
return nil
}

View file

@ -7,6 +7,7 @@ import (
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/forge"
"forge.lthn.ai/core/agent/pkg/jobrunner"
)
@ -39,7 +40,7 @@ func (h *DismissReviewsHandler) Execute(ctx context.Context, signal *jobrunner.P
reviews, err := h.forge.ListPRReviews(signal.RepoOwner, signal.RepoName, int64(signal.PRNumber))
if err != nil {
return nil, fmt.Errorf("dismiss_reviews: list reviews: %w", err)
return nil, coreerr.E("dismiss_reviews.Execute", "list reviews", err)
}
var dismissErrors []string

View file

@ -8,6 +8,7 @@ import (
forgejosdk "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/forge"
"forge.lthn.ai/core/agent/pkg/jobrunner"
)
@ -41,7 +42,7 @@ func (h *TickParentHandler) Execute(ctx context.Context, signal *jobrunner.Pipel
// Fetch the epic issue body.
epic, err := h.forge.GetIssue(signal.RepoOwner, signal.RepoName, int64(signal.EpicNumber))
if err != nil {
return nil, fmt.Errorf("tick_parent: fetch epic: %w", err)
return nil, coreerr.E("tick_parent.Execute", "fetch epic", err)
}
oldBody := epic.Body

View file

@ -3,14 +3,15 @@ package jobrunner
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"iter"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
// validPathComponent matches safe repo owner/name characters (alphanumeric, hyphen, underscore, dot).
@ -55,7 +56,7 @@ type Journal struct {
// NewJournal creates a new Journal rooted at baseDir.
func NewJournal(baseDir string) (*Journal, error) {
if baseDir == "" {
return nil, errors.New("journal.NewJournal: base directory is required")
return nil, coreerr.E("journal.NewJournal", "base directory is required", nil)
}
return &Journal{baseDir: baseDir}, nil
}
@ -66,12 +67,12 @@ func NewJournal(baseDir string) (*Journal, error) {
func sanitizePathComponent(name string) (string, error) {
// Reject empty or whitespace-only values.
if name == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("journal.sanitizePathComponent: invalid path component: %q", name)
return "", coreerr.E("journal.sanitizePathComponent", "invalid path component: "+name, nil)
}
// Reject inputs containing path separators (directory traversal attempt).
if strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("journal.sanitizePathComponent: path component contains directory separator: %q", name)
return "", coreerr.E("journal.sanitizePathComponent", "path component contains directory separator: "+name, nil)
}
// Use filepath.Clean to normalize (e.g., collapse redundant dots).
@ -79,12 +80,12 @@ func sanitizePathComponent(name string) (string, error) {
// Reject traversal components.
if clean == "." || clean == ".." {
return "", fmt.Errorf("journal.sanitizePathComponent: invalid path component: %q", name)
return "", coreerr.E("journal.sanitizePathComponent", "invalid path component: "+name, nil)
}
// Validate against the safe character set.
if !validPathComponent.MatchString(clean) {
return "", fmt.Errorf("journal.sanitizePathComponent: path component contains invalid characters: %q", name)
return "", coreerr.E("journal.sanitizePathComponent", "path component contains invalid characters: "+name, nil)
}
return clean, nil
@ -122,10 +123,10 @@ func (j *Journal) ReadEntries(path string) iter.Seq2[JournalEntry, error] {
// Append writes a journal entry for the given signal and result.
func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error {
if signal == nil {
return errors.New("journal.Append: signal is required")
return coreerr.E("journal.Append", "signal is required", nil)
}
if result == nil {
return errors.New("journal.Append: result is required")
return coreerr.E("journal.Append", "result is required", nil)
}
entry := JournalEntry{
@ -153,18 +154,18 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error {
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("journal.Append: marshal entry: %w", err)
return coreerr.E("journal.Append", "marshal entry", err)
}
data = append(data, '\n')
// Sanitize path components to prevent path traversal (CVE: issue #46).
owner, err := sanitizePathComponent(signal.RepoOwner)
if err != nil {
return fmt.Errorf("journal.Append: invalid repo owner: %w", err)
return coreerr.E("journal.Append", "invalid repo owner", err)
}
repo, err := sanitizePathComponent(signal.RepoName)
if err != nil {
return fmt.Errorf("journal.Append: invalid repo name: %w", err)
return coreerr.E("journal.Append", "invalid repo name", err)
}
date := result.Timestamp.UTC().Format("2006-01-02")
@ -173,27 +174,27 @@ func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error {
// Resolve to absolute path and verify it stays within baseDir.
absBase, err := filepath.Abs(j.baseDir)
if err != nil {
return fmt.Errorf("journal.Append: resolve base directory: %w", err)
return coreerr.E("journal.Append", "resolve base directory", err)
}
absDir, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("journal.Append: resolve journal directory: %w", err)
return coreerr.E("journal.Append", "resolve journal directory", err)
}
if !strings.HasPrefix(absDir, absBase+string(filepath.Separator)) {
return fmt.Errorf("journal.Append: path %q escapes base directory %q", absDir, absBase)
return coreerr.E("journal.Append", "path escapes base directory: "+absDir, nil)
}
j.mu.Lock()
defer j.mu.Unlock()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("journal.Append: create directory: %w", err)
if err := coreio.Local.EnsureDir(dir); err != nil {
return coreerr.E("journal.Append", "create directory", err)
}
path := filepath.Join(dir, date+".jsonl")
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("journal.Append: open file: %w", err)
return coreerr.E("journal.Append", "open file", err)
}
defer func() { _ = f.Close() }()

View file

@ -288,7 +288,7 @@ func runCommandCtx(ctx context.Context, dir string, command string, args ...stri
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return "", fmt.Errorf("%w: %s", err, stderr.String())
return "", log.E("runCommandCtx", stderr.String(), err)
}
return "", err
}

View file

@ -2,12 +2,13 @@ package lifecycle
import (
"cmp"
"errors"
"slices"
coreerr "forge.lthn.ai/core/go-log"
)
// ErrNoEligibleAgent is returned when no agent matches the task requirements.
var ErrNoEligibleAgent = errors.New("no eligible agent for task")
var ErrNoEligibleAgent = coreerr.E("TaskRouter", "no eligible agent for task", nil)
// TaskRouter selects an agent for a given task from a list of candidates.
type TaskRouter interface {

View file

@ -6,6 +6,7 @@ import (
"strings"
"forge.lthn.ai/core/go-inference"
coreerr "forge.lthn.ai/core/go-log"
)
// Engine drives the agent loop: prompt the model, parse tool calls, execute
@ -56,7 +57,7 @@ func New(opts ...Option) *Engine {
// until the model produces a response with no tool blocks or maxTurns is hit.
func (e *Engine) Run(ctx context.Context, userMessage string) (*Result, error) {
if e.model == nil {
return nil, fmt.Errorf("loop: no model configured")
return nil, coreerr.E("loop.Run", "no model configured", nil)
}
system := e.system
@ -74,7 +75,7 @@ func (e *Engine) Run(ctx context.Context, userMessage string) (*Result, error) {
for turn := 0; turn < e.maxTurns; turn++ {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("loop: context cancelled: %w", err)
return nil, coreerr.E("loop.Run", "context cancelled", err)
}
prompt := BuildFullPrompt(system, history, "")
@ -83,7 +84,7 @@ func (e *Engine) Run(ctx context.Context, userMessage string) (*Result, error) {
response.WriteString(tok.Text)
}
if err := e.model.Err(); err != nil {
return nil, fmt.Errorf("loop: inference error: %w", err)
return nil, coreerr.E("loop.Run", "inference error", err)
}
fullResponse := response.String()
@ -127,5 +128,5 @@ func (e *Engine) Run(ctx context.Context, userMessage string) (*Result, error) {
}
}
return nil, fmt.Errorf("loop: max turns (%d) exceeded", e.maxTurns)
return nil, coreerr.E("loop.Run", fmt.Sprintf("max turns (%d) exceeded", e.maxTurns), nil)
}

View file

@ -8,6 +8,8 @@ import (
"io"
"net/http"
"time"
coreerr "forge.lthn.ai/core/go-log"
)
var eaasClient = &http.Client{Timeout: 30 * time.Second}
@ -59,28 +61,28 @@ func eaasPostHandler(baseURL, path string) func(context.Context, map[string]any)
return func(ctx context.Context, args map[string]any) (string, error) {
body, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
return "", coreerr.E("eaas.handler", "marshal args", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+path, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
return "", coreerr.E("eaas.handler", "create request", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := eaasClient.Do(req)
if err != nil {
return "", fmt.Errorf("eaas request: %w", err)
return "", coreerr.E("eaas.handler", "eaas request", err)
}
defer resp.Body.Close()
result, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
return "", coreerr.E("eaas.handler", "read response", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("eaas returned %d: %s", resp.StatusCode, string(result))
return "", coreerr.E("eaas.handler", fmt.Sprintf("eaas returned %d: %s", resp.StatusCode, string(result)), nil)
}
return string(result), nil

View file

@ -3,8 +3,8 @@ package loop
import (
"context"
"encoding/json"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
aimcp "forge.lthn.ai/core/mcp/pkg/mcp"
)
@ -30,7 +30,7 @@ func WrapRESTHandler(handler RESTHandlerFunc) func(context.Context, map[string]a
return func(ctx context.Context, args map[string]any) (string, error) {
body, err := json.Marshal(args)
if err != nil {
return "", fmt.Errorf("marshal args: %w", err)
return "", coreerr.E("mcp.handler", "marshal args", err)
}
result, err := handler(ctx, body)
@ -40,7 +40,7 @@ func WrapRESTHandler(handler RESTHandlerFunc) func(context.Context, map[string]a
out, err := json.Marshal(result)
if err != nil {
return "", fmt.Errorf("marshal result: %w", err)
return "", coreerr.E("mcp.handler", "marshal result", err)
}
return string(out), nil
}

View file

@ -2,10 +2,9 @@
package orchestrator
import (
"errors"
"fmt"
"maps"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/config"
)
@ -46,7 +45,7 @@ func LoadAgents(cfg *config.Config) (map[string]AgentConfig, error) {
continue
}
if ac.Host == "" {
return nil, fmt.Errorf("agentci.LoadAgents: agent %q: host is required", name)
return nil, coreerr.E("agentci.LoadAgents", "agent "+name+": host is required", nil)
}
if ac.QueueDir == "" {
ac.QueueDir = "/home/claude/ai-work/queue"
@ -96,7 +95,7 @@ func LoadClothoConfig(cfg *config.Config) (ClothoConfig, error) {
// SaveAgent writes an agent config entry to the config file.
func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
key := fmt.Sprintf("agentci.agents.%s", name)
key := "agentci.agents." + name
data := map[string]any{
"host": ac.Host,
"queue_dir": ac.QueueDir,
@ -126,10 +125,10 @@ func SaveAgent(cfg *config.Config, name string, ac AgentConfig) error {
func RemoveAgent(cfg *config.Config, name string) error {
var agents map[string]AgentConfig
if err := cfg.Get("agentci.agents", &agents); err != nil {
return errors.New("agentci.RemoveAgent: no agents configured")
return coreerr.E("agentci.RemoveAgent", "no agents configured", nil)
}
if _, ok := agents[name]; !ok {
return fmt.Errorf("agentci.RemoveAgent: agent %q not found", name)
return coreerr.E("agentci.RemoveAgent", "agent "+name+" not found", nil)
}
delete(agents, name)
return cfg.Set("agentci.agents", agents)

View file

@ -2,11 +2,12 @@ package orchestrator
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strings"
coreerr "forge.lthn.ai/core/go-log"
)
var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
@ -16,10 +17,10 @@ var safeNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\.]+$`)
func SanitizePath(input string) (string, error) {
base := filepath.Base(input)
if !safeNameRegex.MatchString(base) {
return "", fmt.Errorf("agentci.SanitizePath: invalid characters in path element: %s", input)
return "", coreerr.E("agentci.SanitizePath", "invalid characters in path element: "+input, nil)
}
if base == "." || base == ".." || base == "/" {
return "", fmt.Errorf("agentci.SanitizePath: invalid path element: %s", base)
return "", coreerr.E("agentci.SanitizePath", "invalid path element: "+base, nil)
}
return base, nil
}