From 5eb26f90fc4f2cbf1606252258559d9f65b4d517 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 16 Mar 2026 21:48:31 +0000 Subject: [PATCH] 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 --- cmd/agent/cmd.go | 22 ++++----- cmd/dispatch/cmd.go | 41 ++++++++-------- cmd/workspace/cmd_agent.go | 12 ++--- cmd/workspace/cmd_task.go | 20 ++++---- cmd/workspace/config.go | 17 ++++--- pkg/agentic/dispatch.go | 18 +++---- pkg/agentic/epic.go | 13 ++--- pkg/agentic/ingest.go | 12 +++-- pkg/agentic/plan.go | 45 +++++++++--------- pkg/agentic/pr.go | 23 ++++----- pkg/agentic/prep.go | 58 ++++++++++++----------- pkg/agentic/queue.go | 5 +- pkg/agentic/resume.go | 14 +++--- pkg/agentic/scan.go | 7 +-- pkg/agentic/status.go | 15 +++--- pkg/brain/brain.go | 4 +- pkg/brain/direct.go | 22 +++++---- pkg/brain/messaging.go | 5 +- pkg/brain/tools.go | 10 ++-- pkg/jobrunner/handlers/completion.go | 9 ++-- pkg/jobrunner/handlers/dispatch.go | 22 ++++----- pkg/jobrunner/handlers/resolve_threads.go | 3 +- pkg/jobrunner/handlers/tick_parent.go | 3 +- pkg/jobrunner/journal.go | 37 ++++++++------- pkg/lifecycle/completion.go | 2 +- pkg/lifecycle/router.go | 5 +- pkg/loop/engine.go | 9 ++-- pkg/loop/tools_eaas.go | 12 +++-- pkg/loop/tools_mcp.go | 6 +-- pkg/orchestrator/config.go | 11 ++--- pkg/orchestrator/security.go | 7 +-- 31 files changed, 258 insertions(+), 231 deletions(-) diff --git a/cmd/agent/cmd.go b/cmd/agent/cmd.go index 2f9b451..23456ce 100644 --- a/cmd/agent/cmd.go +++ b/cmd/agent/cmd.go @@ -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() diff --git a/cmd/dispatch/cmd.go b/cmd/dispatch/cmd.go index 3be2267..2f15942 100644 --- a/cmd/dispatch/cmd.go +++ b/cmd/dispatch/cmd.go @@ -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 { diff --git a/cmd/workspace/cmd_agent.go b/cmd/workspace/cmd_agent.go index f53e134..4054103 100644 --- a/cmd/workspace/cmd_agent.go +++ b/cmd/workspace/cmd_agent.go @@ -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 diff --git a/cmd/workspace/cmd_task.go b/cmd/workspace/cmd_task.go index 6cabece..7640f80 100644 --- a/cmd/workspace/cmd_task.go +++ b/cmd/workspace/cmd_task.go @@ -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 } diff --git a/cmd/workspace/config.go b/cmd/workspace/config.go index 3811353..bc9010f 100644 --- a/cmd/workspace/config.go +++ b/cmd/workspace/config.go @@ -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) } diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 28edf35..ba29053 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -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 } diff --git a/pkg/agentic/epic.go b/pkg/agentic/epic.go index dfce777..e7ffd78 100644 --- a/pkg/agentic/epic.go +++ b/pkg/agentic/epic.go @@ -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 { diff --git a/pkg/agentic/ingest.go b/pkg/agentic/ingest.go index aafef73..430f1ba 100644 --- a/pkg/agentic/ingest.go +++ b/pkg/agentic/ingest.go @@ -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, diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index d37c377..cf4cf4e 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -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 { diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index b86beb6..e200de6 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -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() diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 54af85a..be5fd19 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -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) } diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index badc8f2..c3cb4d7 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -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 diff --git a/pkg/agentic/resume.go b/pkg/agentic/resume.go index 8dc7598..fa0f8cd 100644 --- a/pkg/agentic/resume.go +++ b/pkg/agentic/resume.go @@ -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) } } diff --git a/pkg/agentic/scan.go b/pkg/agentic/scan.go index 6b2525a..6aabe82 100644 --- a/pkg/agentic/scan.go +++ b/pkg/agentic/scan.go @@ -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() diff --git a/pkg/agentic/status.go b/pkg/agentic/status.go index e0e7b86..db30b33 100644 --- a/pkg/agentic/status.go +++ b/pkg/agentic/status.go @@ -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 { diff --git a/pkg/brain/brain.go b/pkg/brain/brain.go index 2b2fd3e..1037d0a 100644 --- a/pkg/brain/brain.go +++ b/pkg/brain/brain.go @@ -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. diff --git a/pkg/brain/direct.go b/pkg/brain/direct.go index 3fd256b..d7598eb 100644 --- a/pkg/brain/direct.go +++ b/pkg/brain/direct.go @@ -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 diff --git a/pkg/brain/messaging.go b/pkg/brain/messaging.go index 895ad9e..bb13c01 100644 --- a/pkg/brain/messaging.go +++ b/pkg/brain/messaging.go @@ -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) diff --git a/pkg/brain/tools.go b/pkg/brain/tools.go index 9a8f12b..47d1e02 100644 --- a/pkg/brain/tools.go +++ b/pkg/brain/tools.go @@ -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{ diff --git a/pkg/jobrunner/handlers/completion.go b/pkg/jobrunner/handlers/completion.go index 9867b6f..0c500bd 100644 --- a/pkg/jobrunner/handlers/completion.go +++ b/pkg/jobrunner/handlers/completion.go @@ -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." diff --git a/pkg/jobrunner/handlers/dispatch.go b/pkg/jobrunner/handlers/dispatch.go index bb2ab26..46ad44f 100644 --- a/pkg/jobrunner/handlers/dispatch.go +++ b/pkg/jobrunner/handlers/dispatch.go @@ -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 } diff --git a/pkg/jobrunner/handlers/resolve_threads.go b/pkg/jobrunner/handlers/resolve_threads.go index 1f699f0..22c900f 100644 --- a/pkg/jobrunner/handlers/resolve_threads.go +++ b/pkg/jobrunner/handlers/resolve_threads.go @@ -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 diff --git a/pkg/jobrunner/handlers/tick_parent.go b/pkg/jobrunner/handlers/tick_parent.go index 42bca1f..d090f54 100644 --- a/pkg/jobrunner/handlers/tick_parent.go +++ b/pkg/jobrunner/handlers/tick_parent.go @@ -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 diff --git a/pkg/jobrunner/journal.go b/pkg/jobrunner/journal.go index 5431cfd..25f162a 100644 --- a/pkg/jobrunner/journal.go +++ b/pkg/jobrunner/journal.go @@ -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() }() diff --git a/pkg/lifecycle/completion.go b/pkg/lifecycle/completion.go index 944cdf4..8dd2da7 100644 --- a/pkg/lifecycle/completion.go +++ b/pkg/lifecycle/completion.go @@ -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 } diff --git a/pkg/lifecycle/router.go b/pkg/lifecycle/router.go index 0a91caa..b7bc86c 100644 --- a/pkg/lifecycle/router.go +++ b/pkg/lifecycle/router.go @@ -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 { diff --git a/pkg/loop/engine.go b/pkg/loop/engine.go index fbc3187..563219b 100644 --- a/pkg/loop/engine.go +++ b/pkg/loop/engine.go @@ -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) } diff --git a/pkg/loop/tools_eaas.go b/pkg/loop/tools_eaas.go index 6770c72..b5042d1 100644 --- a/pkg/loop/tools_eaas.go +++ b/pkg/loop/tools_eaas.go @@ -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 diff --git a/pkg/loop/tools_mcp.go b/pkg/loop/tools_mcp.go index 16e1dfe..58e8f7e 100644 --- a/pkg/loop/tools_mcp.go +++ b/pkg/loop/tools_mcp.go @@ -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 } diff --git a/pkg/orchestrator/config.go b/pkg/orchestrator/config.go index 78ee56b..b559d9d 100644 --- a/pkg/orchestrator/config.go +++ b/pkg/orchestrator/config.go @@ -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) diff --git a/pkg/orchestrator/security.go b/pkg/orchestrator/security.go index 81ac996..be51056 100644 --- a/pkg/orchestrator/security.go +++ b/pkg/orchestrator/security.go @@ -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 }