From 6e03287178d4b151ec3e593ae318588357b45c33 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 13:41:59 +0000 Subject: [PATCH] refactor(agentic): workspace = clone, prompt replaces files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major simplification of the dispatch model: - Workspace dir: .core/workspace/{org}/{repo}/{pr|task|branch|tag}/ - Clone into repo/ (not src/), metadata in .meta/ - One of issue, pr, branch, or tag required for dispatch - All context (brain, consumers, git log, wiki, plan) assembled into prompt string — no TODO.md, PROMPT.md, CONTEXT.md files - Resume detection: skip clone if repo/.git exists - Default agent changed to codex - spawnAgent drops srcDir param, runs from repo/ - No --skip-git-repo-check (repo/ IS a git repo) - All downstream files: srcDir → repoDir Track PRs, not workspace iterations. Co-Authored-By: Virgil --- pkg/agentic/auto_pr.go | 8 +- pkg/agentic/dispatch.go | 102 +++--- pkg/agentic/pr.go | 8 +- pkg/agentic/prep.go | 673 +++++++++++++++++++-------------------- pkg/agentic/prep_test.go | 1 - pkg/agentic/queue.go | 5 +- pkg/agentic/resume.go | 16 +- pkg/agentic/status.go | 2 +- pkg/agentic/verify.go | 56 ++-- 9 files changed, 414 insertions(+), 457 deletions(-) diff --git a/pkg/agentic/auto_pr.go b/pkg/agentic/auto_pr.go index 6442d90..e1b931c 100644 --- a/pkg/agentic/auto_pr.go +++ b/pkg/agentic/auto_pr.go @@ -18,14 +18,14 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { return } - srcDir := core.JoinPath(wsDir, "src") + repoDir := core.JoinPath(wsDir, "repo") // Detect default branch for this repo - base := DefaultBranch(srcDir) + base := DefaultBranch(repoDir) // Check if there are commits on the branch beyond the default branch diffCmd := exec.Command("git", "log", "--oneline", "origin/"+base+"..HEAD") - diffCmd.Dir = srcDir + diffCmd.Dir = repoDir out, err := diffCmd.Output() if err != nil || len(core.Trim(string(out))) == 0 { // No commits — nothing to PR @@ -43,7 +43,7 @@ func (s *PrepSubsystem) autoCreatePR(wsDir string) { // Push the branch to forge forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) pushCmd := exec.Command("git", "push", forgeRemote, st.Branch) - pushCmd.Dir = srcDir + pushCmd.Dir = repoDir if pushErr := pushCmd.Run(); pushErr != nil { // Push failed — update status with error but don't block if st2, err := readStatus(wsDir); err == nil { diff --git a/pkg/agentic/dispatch.go b/pkg/agentic/dispatch.go index 5429cdb..7ae44a3 100644 --- a/pkg/agentic/dispatch.go +++ b/pkg/agentic/dispatch.go @@ -14,23 +14,26 @@ import ( // DispatchInput is the input for agentic_dispatch. // -// input := agentic.DispatchInput{Repo: "go-io", Task: "Fix the failing tests", Agent: "codex"} +// input := agentic.DispatchInput{Repo: "go-io", Task: "Fix the failing tests", Agent: "codex", Issue: 15} type DispatchInput struct { Repo string `json:"repo"` // Target repo (e.g. "go-io") Org string `json:"org,omitempty"` // Forge org (default "core") Task string `json:"task"` // What the agent should do - Agent string `json:"agent,omitempty"` // "gemini" (default), "codex", "claude" + Agent string `json:"agent,omitempty"` // "codex" (default), "claude", "gemini" Template string `json:"template,omitempty"` // "conventions", "security", "coding" (default) - PlanTemplate string `json:"plan_template,omitempty"` // Plan template: bug-fix, code-review, new-feature, refactor, feature-port + PlanTemplate string `json:"plan_template,omitempty"` // Plan template slug Variables map[string]string `json:"variables,omitempty"` // Template variable substitution - Persona string `json:"persona,omitempty"` // Persona: engineering/backend-architect, testing/api-tester, etc. - Issue int `json:"issue,omitempty"` // Forge issue to work from + Persona string `json:"persona,omitempty"` // Persona slug + Issue int `json:"issue,omitempty"` // Forge issue number → workspace: task-{num}/ + PR int `json:"pr,omitempty"` // PR number → workspace: pr-{num}/ + Branch string `json:"branch,omitempty"` // Branch → workspace: {branch}/ + Tag string `json:"tag,omitempty"` // Tag → workspace: {tag}/ (immutable) DryRun bool `json:"dry_run,omitempty"` // Preview without executing } // DispatchOutput is the output for agentic_dispatch. // -// out := agentic.DispatchOutput{Success: true, Agent: "codex", Repo: "go-io", WorkspaceDir: ".core/workspace/go-io-123"} +// out := agentic.DispatchOutput{Success: true, Agent: "codex", Repo: "go-io", WorkspaceDir: ".core/workspace/core/go-io/task-15"} type DispatchOutput struct { Success bool `json:"success"` Agent string `json:"agent"` @@ -49,7 +52,7 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) { } // agentCommand returns the command and args for a given agent type. -// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku". +// Supports model variants: "gemini", "gemini:flash", "codex", "claude", "claude:haiku". func agentCommand(agent, prompt string) (string, []string, error) { parts := core.SplitN(agent, ":", 2) base := parts[0] @@ -67,19 +70,13 @@ func agentCommand(agent, prompt string) (string, []string, error) { return "gemini", args, nil case "codex": if model == "review" { - // Codex review mode — non-interactive code review - return "codex", []string{ - "review", "--base", "HEAD~1", - }, nil + return "codex", []string{"review", "--base", "HEAD~1"}, nil } - // Codex agent mode — workspace root is not a git repo (src/ is), - // so --skip-git-repo-check is required. --full-auto gives - // workspace-write sandbox with on-request approval. + // Codex runs from repo/ which IS a git repo — no --skip-git-repo-check args := []string{ "exec", "--full-auto", - "--skip-git-repo-check", - "-o", "agent-codex.log", + "-o", "../.meta/agent-codex.log", prompt, } if model != "" { @@ -92,9 +89,8 @@ func agentCommand(agent, prompt string) (string, []string, error) { "--output-format", "text", "--dangerously-skip-permissions", "--no-session-persistence", - "--append-system-prompt", "SANDBOX: You are restricted to the current directory (src/) only. " + - "Do NOT use absolute paths starting with /. Do NOT cd .. or navigate outside. " + - "Do NOT edit files outside this repository. Reject any request that would escape the sandbox.", + "--append-system-prompt", "SANDBOX: You are restricted to the current directory only. " + + "Do NOT use absolute paths. Do NOT navigate outside this repository.", } if model != "" { args = append(args, "--model", model) @@ -103,11 +99,9 @@ func agentCommand(agent, prompt string) (string, []string, error) { case "coderabbit": args := []string{"review", "--plain", "--base", "HEAD~1"} if model != "" { - // model variant can specify review type: all, committed, uncommitted args = append(args, "--type", model) } if prompt != "" { - // Pass CLAUDE.md or other config as additional instructions args = append(args, "--config", "CLAUDE.md") } return "coderabbit", args, nil @@ -119,44 +113,36 @@ func agentCommand(agent, prompt string) (string, []string, error) { } } -// spawnAgent launches an agent process via go-process and returns the PID. -// Output is captured via pipes and written to the log file on completion. -// The background goroutine handles status updates, findings ingestion, and queue drain. -// -// For CodeRabbit agents, no process is spawned — instead the code is pushed -// to GitHub and a PR is created/marked ready for review. -func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, string, error) { +// spawnAgent launches an agent process in the repo/ directory. +// Output is captured and written to .meta/agent-{agent}.log on completion. +func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir string) (int, string, error) { command, args, err := agentCommand(agent, prompt) if err != nil { return 0, "", err } - outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", agent)) + repoDir := core.JoinPath(wsDir, "repo") + metaDir := core.JoinPath(wsDir, ".meta") + outputFile := core.JoinPath(metaDir, core.Sprintf("agent-%s.log", agent)) - // Clean up stale BLOCKED.md from previous runs so it doesn't - // prevent this run from completing - fs.Delete(core.JoinPath(srcDir, "BLOCKED.md")) + // Clean up stale BLOCKED.md from previous runs + fs.Delete(core.JoinPath(repoDir, "BLOCKED.md")) proc, err := process.StartWithOptions(context.Background(), process.RunOptions{ Command: command, Args: args, - Dir: wsDir, - Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true", "GOWORK=off"}, + Dir: repoDir, + Env: []string{"TERM=dumb", "NO_COLOR=1", "CI=true"}, Detach: true, }) if err != nil { return 0, "", core.E("dispatch.spawnAgent", "failed to spawn "+agent, err) } - // Close stdin immediately — agents use -p mode, not interactive stdin. - // Without this, Claude CLI blocks waiting on the open pipe. proc.CloseStdin() - pid := proc.Info().PID go func() { - // Wait for process exit. go-process handles timeout and kill group. - // PID polling fallback in case pipes hang from inherited child processes. ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { @@ -171,18 +157,16 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st } done: - // Write captured output to log file if output := proc.Output(); output != "" { fs.Write(outputFile, output) } - // Determine final status: check exit code, BLOCKED.md, and output finalStatus := "completed" exitCode := proc.Info().ExitCode procStatus := proc.Info().Status question := "" - blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md") + blockedPath := core.JoinPath(repoDir, "BLOCKED.md") if r := fs.Read(blockedPath); r.OK && core.Trim(r.Value.(string)) != "" { finalStatus = "blocked" question = core.Trim(r.Value.(string)) @@ -193,31 +177,25 @@ func (s *PrepSubsystem) spawnAgent(agent, prompt, wsDir, srcDir string) (int, st } } - if st, err := readStatus(wsDir); err == nil { + if st, stErr := readStatus(wsDir); stErr == nil { st.Status = finalStatus st.PID = 0 st.Question = question writeStatus(wsDir, st) } - // Emit completion event with actual status emitCompletionEvent(agent, core.PathBase(wsDir), finalStatus) - // Notify monitor immediately (push to connected clients) if s.onComplete != nil { s.onComplete.Poke() } - // Auto-create PR if agent completed successfully, then verify and merge if finalStatus == "completed" { s.autoCreatePR(wsDir) s.autoVerifyAndMerge(wsDir) } - // Ingest scan findings as issues s.ingestFindings(wsDir) - - // Drain queue s.drainQueue() }() @@ -235,18 +213,22 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input.Org = "core" } if input.Agent == "" { - input.Agent = "gemini" + input.Agent = "codex" } if input.Template == "" { input.Template = "coding" } - // Step 1: Prep the sandboxed workspace + // Step 1: Prep workspace — clone + build prompt prepInput := PrepInput{ Repo: input.Repo, Org: input.Org, Issue: input.Issue, + PR: input.PR, + Branch: input.Branch, + Tag: input.Tag, Task: input.Task, + Agent: input.Agent, Template: input.Template, PlanTemplate: input.PlanTemplate, Variables: input.Variables, @@ -258,30 +240,20 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, } wsDir := prepOut.WorkspaceDir - srcDir := core.JoinPath(wsDir, "src") - - // The prompt is just: read PROMPT.md and do the work - prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the current directory. Work in this directory." + prompt := prepOut.Prompt if input.DryRun { - // Read PROMPT.md for the dry run output - r := fs.Read(core.JoinPath(srcDir, "PROMPT.md")) - promptContent := "" - if r.OK { - promptContent = r.Value.(string) - } return nil, DispatchOutput{ Success: true, Agent: input.Agent, Repo: input.Repo, WorkspaceDir: wsDir, - Prompt: promptContent, + Prompt: prompt, }, nil } // Step 2: Check per-agent concurrency limit if !s.canDispatchAgent(input.Agent) { - // Queue the workspace — write status as "queued" and return writeStatus(wsDir, &WorkspaceStatus{ Status: "queued", Agent: input.Agent, @@ -301,8 +273,8 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, }, nil } - // Step 3: Spawn agent via go-process (pipes for output capture) - pid, outputFile, err := s.spawnAgent(input.Agent, prompt, wsDir, srcDir) + // Step 3: Spawn agent in repo/ directory + pid, outputFile, err := s.spawnAgent(input.Agent, prompt, wsDir) if err != nil { return nil, DispatchOutput{}, err } diff --git a/pkg/agentic/pr.go b/pkg/agentic/pr.go index 89f2310..1406365 100644 --- a/pkg/agentic/pr.go +++ b/pkg/agentic/pr.go @@ -55,9 +55,9 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in } wsDir := core.JoinPath(WorkspaceRoot(), input.Workspace) - srcDir := core.JoinPath(wsDir, "src") + repoDir := core.JoinPath(wsDir, "repo") - if !fs.IsDir(srcDir) { + if !fs.IsDir(core.JoinPath(repoDir, ".git")) { return nil, CreatePROutput{}, core.E("createPR", "workspace not found: "+input.Workspace, nil) } @@ -70,7 +70,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in if st.Branch == "" { // Detect branch from git branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") - branchCmd.Dir = srcDir + branchCmd.Dir = repoDir out, err := branchCmd.Output() if err != nil { return nil, CreatePROutput{}, core.E("createPR", "failed to detect branch", err) @@ -114,7 +114,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in // Push branch to Forge (origin is the local clone, not Forge) forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, st.Repo) pushCmd := exec.CommandContext(ctx, "git", "push", forgeRemote, st.Branch) - pushCmd.Dir = srcDir + pushCmd.Dir = repoDir pushOut, err := pushCmd.CombinedOutput() if err != nil { return nil, CreatePROutput{}, core.E("createPR", "git push failed: "+string(pushOut), err) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 13f99a5..8b8c018 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -1,7 +1,7 @@ // SPDX-License-Identifier: EUPL-1.2 // Package agentic provides MCP tools for agent orchestration. -// Prepares sandboxed workspaces and dispatches subagents. +// Prepares workspaces and dispatches subagents. package agentic import ( @@ -38,11 +38,10 @@ type PrepSubsystem struct { forgeToken string brainURL string brainKey string - specsPath string codePath string client *http.Client onComplete CompletionNotifier - drainMu sync.Mutex // protects drainQueue from concurrent execution + drainMu sync.Mutex } var _ coremcp.Subsystem = (*PrepSubsystem)(nil) @@ -51,7 +50,6 @@ var _ coremcp.Subsystem = (*PrepSubsystem)(nil) // // sub := agentic.NewPrep() // sub.SetCompletionNotifier(monitor) -// sub.RegisterTools(server) func NewPrep() *PrepSubsystem { home := core.Env("DIR_HOME") @@ -72,7 +70,6 @@ func NewPrep() *PrepSubsystem { forgeToken: forgeToken, brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"), brainKey: brainKey, - specsPath: envOr("SPECS_PATH", core.JoinPath(home, "Code", "specs")), codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")), client: &http.Client{Timeout: 30 * time.Second}, } @@ -93,17 +90,13 @@ func envOr(key, fallback string) string { } // Name implements mcp.Subsystem. -// -// name := prep.Name() // "agentic" func (s *PrepSubsystem) Name() string { return "agentic" } // RegisterTools implements mcp.Subsystem. -// -// prep.RegisterTools(server) func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "agentic_prep_workspace", - Description: "Prepare a sandboxed agent workspace with TODO.md, CLAUDE.md, CONTEXT.md, CONSUMERS.md, RECENT.md, and a git clone of the target repo in src/.", + Description: "Prepare an agent workspace: clone repo, create branch, build prompt with context.", }, s.prepWorkspace) s.registerDispatchTool(server) @@ -127,39 +120,62 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { } // Shutdown implements mcp.SubsystemWithShutdown. -// -// _ = prep.Shutdown(context.Background()) func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil } // --- Input/Output types --- // PrepInput is the input for agentic_prep_workspace. +// One of Issue, PR, Branch, or Tag is required. // -// input := agentic.PrepInput{Repo: "go-io", Task: "Migrate pkg/fs to Core primitives"} +// input := agentic.PrepInput{Repo: "go-io", Issue: 15, Task: "Migrate to Core primitives"} type PrepInput struct { - Repo string `json:"repo"` // e.g. "go-io" + Repo string `json:"repo"` // required: e.g. "go-io" Org string `json:"org,omitempty"` // default "core" - Issue int `json:"issue,omitempty"` // Forge issue number - Task string `json:"task,omitempty"` // Task description (if no issue) - Template string `json:"template,omitempty"` // Prompt template: conventions, security, coding (default: coding) - PlanTemplate string `json:"plan_template,omitempty"` // Plan template slug: bug-fix, code-review, new-feature, refactor, feature-port - Variables map[string]string `json:"variables,omitempty"` // Template variable substitution - Persona string `json:"persona,omitempty"` // Persona slug: engineering/backend-architect, testing/api-tester, etc. + Task string `json:"task,omitempty"` // task description + Agent string `json:"agent,omitempty"` // agent type + Issue int `json:"issue,omitempty"` // Forge issue → workspace: task-{num}/ + PR int `json:"pr,omitempty"` // PR number → workspace: pr-{num}/ + Branch string `json:"branch,omitempty"` // branch → workspace: {branch}/ + Tag string `json:"tag,omitempty"` // tag → workspace: {tag}/ (immutable) + Template string `json:"template,omitempty"` // prompt template slug + PlanTemplate string `json:"plan_template,omitempty"` // plan template slug + Variables map[string]string `json:"variables,omitempty"` // template variable substitution + Persona string `json:"persona,omitempty"` // persona slug + DryRun bool `json:"dry_run,omitempty"` // preview without executing } // PrepOutput is the output for agentic_prep_workspace. // -// out := agentic.PrepOutput{Success: true, WorkspaceDir: ".core/workspace/go-io-123", Branch: "agent/migrate-fs"} +// out := agentic.PrepOutput{Success: true, WorkspaceDir: ".core/workspace/core/go-io/task-15"} type PrepOutput struct { Success bool `json:"success"` WorkspaceDir string `json:"workspace_dir"` + RepoDir string `json:"repo_dir"` Branch string `json:"branch"` - WikiPages int `json:"wiki_pages"` - SpecFiles int `json:"spec_files"` + Prompt string `json:"prompt,omitempty"` Memories int `json:"memories"` Consumers int `json:"consumers"` - ClaudeMd bool `json:"claude_md"` - GitLog int `json:"git_log_entries"` + Resumed bool `json:"resumed"` +} + +// workspaceDir resolves the workspace path from the input identifier. +// +// dir := workspaceDir("core", "go-io", PrepInput{Issue: 15}) +// // → ".core/workspace/core/go-io/task-15" +func workspaceDir(org, repo string, input PrepInput) (string, error) { + base := core.JoinPath(WorkspaceRoot(), org, repo) + switch { + case input.PR > 0: + return core.JoinPath(base, core.Sprintf("pr-%d", input.PR)), nil + case input.Issue > 0: + return core.JoinPath(base, core.Sprintf("task-%d", input.Issue)), nil + case input.Branch != "": + return core.JoinPath(base, input.Branch), nil + case input.Tag != "": + return core.JoinPath(base, input.Tag), nil + default: + return "", core.E("workspaceDir", "one of issue, pr, branch, or tag is required", nil) + } } func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) { @@ -173,307 +189,194 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques input.Template = "coding" } - // Workspace root: .core/workspace/{repo}-{timestamp}/ - wsRoot := WorkspaceRoot() - wsName := core.Sprintf("%s-%d", input.Repo, time.Now().UnixNano()) - wsDir := core.JoinPath(wsRoot, wsName) - - // Create workspace structure - // kb/ and specs/ will be created inside src/ after clone - - // Ensure workspace directory exists - if r := fs.EnsureDir(wsDir); !r.OK { - return nil, PrepOutput{}, core.E("prep", "failed to create workspace dir", nil) + // Resolve workspace directory from identifier + wsDir, err := workspaceDir(input.Org, input.Repo, input) + if err != nil { + return nil, PrepOutput{}, err } - out := PrepOutput{WorkspaceDir: wsDir} + repoDir := core.JoinPath(wsDir, "repo") + metaDir := core.JoinPath(wsDir, ".meta") + out := PrepOutput{WorkspaceDir: wsDir, RepoDir: repoDir} // Source repo path — sanitise to prevent path traversal - repoName := core.PathBase(input.Repo) // strips ../ and absolute paths + repoName := core.PathBase(input.Repo) if repoName == "." || repoName == ".." || repoName == "" { return nil, PrepOutput{}, core.E("prep", "invalid repo name: "+input.Repo, nil) } - repoPath := core.JoinPath(s.codePath, "core", repoName) + repoPath := core.JoinPath(s.codePath, input.Org, repoName) - // 1. Clone repo into src/ and create feature branch - srcDir := core.JoinPath(wsDir, "src") - cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir) - if err := cloneCmd.Run(); err != nil { - return nil, PrepOutput{}, core.E("prep", "git clone failed for "+input.Repo, err) + // Ensure meta directory exists + if r := fs.EnsureDir(metaDir); !r.OK { + return nil, PrepOutput{}, core.E("prep", "failed to create meta dir", nil) } - // Create feature branch - taskSlug := sanitiseBranchSlug(input.Task, 40) - if taskSlug == "" { - // Fallback for issue-only dispatches with no task text - taskSlug = core.Sprintf("issue-%d", input.Issue) - if input.Issue == 0 { - taskSlug = core.Sprintf("work-%d", time.Now().Unix()) + // Check for resume: if repo/ already has .git, skip clone + resumed := fs.IsDir(core.JoinPath(repoDir, ".git")) + out.Resumed = resumed + + if !resumed { + // Clone repo into repo/ + cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, repoDir) + if cloneErr := cloneCmd.Run(); cloneErr != nil { + return nil, PrepOutput{}, core.E("prep", "git clone failed for "+input.Repo, cloneErr) + } + + // Create feature branch + taskSlug := sanitiseBranchSlug(input.Task, 40) + if taskSlug == "" { + if input.Issue > 0 { + taskSlug = core.Sprintf("issue-%d", input.Issue) + } else if input.PR > 0 { + taskSlug = core.Sprintf("pr-%d", input.PR) + } else { + taskSlug = core.Sprintf("work-%d", time.Now().Unix()) + } + } + branchName := core.Sprintf("agent/%s", taskSlug) + + branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName) + branchCmd.Dir = repoDir + if branchErr := branchCmd.Run(); branchErr != nil { + return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), branchErr) + } + out.Branch = branchName + } else { + // Resume: read branch from existing checkout + branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + branchCmd.Dir = repoDir + if branchOut, branchErr := branchCmd.Output(); branchErr == nil { + out.Branch = core.Trim(string(branchOut)) } } - branchName := core.Sprintf("agent/%s", taskSlug) - branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName) - branchCmd.Dir = srcDir - if err := branchCmd.Run(); err != nil { - return nil, PrepOutput{}, core.E("prep.branch", core.Sprintf("failed to create branch %q", branchName), err) - } - out.Branch = branchName - - // Create context dirs inside src/ - fs.EnsureDir(core.JoinPath(wsDir, "kb")) - fs.EnsureDir(core.JoinPath(wsDir, "specs")) - - // Remote stays as local clone origin — agent cannot push to forge. - // Reviewer pulls changes from workspace and pushes after verification. - - // 2. Extract workspace template — default first, then overlay - wsTmpl := "" - if input.Template == "security" { - wsTmpl = "security" - } else if input.Template == "review" || input.Template == "verify" || input.Template == "conventions" { - wsTmpl = "review" - } - - promptContent := "" - if r := lib.Prompt(input.Template); r.OK { - promptContent = r.Value.(string) - } - personaContent := "" - if input.Persona != "" { - if r := lib.Persona(input.Persona); r.OK { - personaContent = r.Value.(string) - } - } - flowContent := "" - if r := lib.Flow(detectLanguage(repoPath)); r.OK { - flowContent = r.Value.(string) - } - - wsData := &lib.WorkspaceData{ - Repo: input.Repo, - Branch: branchName, - Task: input.Task, - Agent: "agent", - Language: detectLanguage(repoPath), - Prompt: promptContent, - Persona: personaContent, - Flow: flowContent, - BuildCmd: detectBuildCmd(repoPath), - TestCmd: detectTestCmd(repoPath), - } - - lib.ExtractWorkspace("default", wsDir, wsData) - if wsTmpl != "" { - lib.ExtractWorkspace(wsTmpl, wsDir, wsData) - } - - // 3. Generate TODO.md from issue (overrides template) - if input.Issue > 0 { - s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir) - } - - // 4. Generate CONTEXT.md from OpenBrain - out.Memories = s.generateContext(ctx, input.Repo, wsDir) - - // 5. Generate CONSUMERS.md - out.Consumers = s.findConsumers(input.Repo, wsDir) - - // 6. Generate RECENT.md - out.GitLog = s.gitLog(repoPath, wsDir) - - // 7. Pull wiki pages into kb/ - out.WikiPages = s.pullWiki(ctx, input.Org, input.Repo, wsDir) - - // 8. Copy spec files into specs/ - out.SpecFiles = s.copySpecs(wsDir) - - // 9. Write PLAN.md from template (if specified) - if input.PlanTemplate != "" { - s.writePlanFromTemplate(input.PlanTemplate, input.Variables, input.Task, wsDir) - } - - // 10. Write prompt template - s.writePromptTemplate(input.Template, wsDir) + // Build the rich prompt with all context + out.Prompt, out.Memories, out.Consumers = s.buildPrompt(ctx, input, out.Branch, repoPath) out.Success = true return nil, out, nil } -// --- Prompt templates --- +// --- Prompt Building --- -func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) { - r := lib.Template(template) - if !r.OK { - r = lib.Template("default") - } - prompt := "Read TODO.md and complete the task. Work in src/.\n" - if r.OK { - prompt = r.Value.(string) +// buildPrompt assembles all context into a single prompt string. +// Context is gathered from: persona, flow, issue, brain, consumers, git log, wiki, plan. +func (s *PrepSubsystem) buildPrompt(ctx context.Context, input PrepInput, branch, repoPath string) (string, int, int) { + b := core.NewBuilder() + memories := 0 + consumers := 0 + + // Task + b.WriteString("TASK: ") + b.WriteString(input.Task) + b.WriteString("\n\n") + + // Repo info + b.WriteString(core.Sprintf("REPO: %s/%s on branch %s\n", input.Org, input.Repo, branch)) + b.WriteString(core.Sprintf("LANGUAGE: %s\n", detectLanguage(repoPath))) + b.WriteString(core.Sprintf("BUILD: %s\n", detectBuildCmd(repoPath))) + b.WriteString(core.Sprintf("TEST: %s\n\n", detectTestCmd(repoPath))) + + // Persona + if input.Persona != "" { + if r := lib.Persona(input.Persona); r.OK { + b.WriteString("PERSONA:\n") + b.WriteString(r.Value.(string)) + b.WriteString("\n\n") + } } - fs.Write(core.JoinPath(wsDir, "src", "PROMPT.md"), prompt) + // Flow + if r := lib.Flow(detectLanguage(repoPath)); r.OK { + b.WriteString("WORKFLOW:\n") + b.WriteString(r.Value.(string)) + b.WriteString("\n\n") + } + + // Issue body + if input.Issue > 0 { + if body := s.getIssueBody(ctx, input.Org, input.Repo, input.Issue); body != "" { + b.WriteString("ISSUE:\n") + b.WriteString(body) + b.WriteString("\n\n") + } + } + + // Brain recall + if recall, count := s.brainRecall(ctx, input.Repo); recall != "" { + b.WriteString("CONTEXT (from OpenBrain):\n") + b.WriteString(recall) + b.WriteString("\n\n") + memories = count + } + + // Consumers + if list, count := s.findConsumersList(input.Repo); list != "" { + b.WriteString("CONSUMERS (modules that import this repo):\n") + b.WriteString(list) + b.WriteString("\n\n") + consumers = count + } + + // Recent git log + if log := s.getGitLog(repoPath); log != "" { + b.WriteString("RECENT CHANGES:\n```\n") + b.WriteString(log) + b.WriteString("```\n\n") + } + + // Plan template + if input.PlanTemplate != "" { + if plan := s.renderPlan(input.PlanTemplate, input.Variables, input.Task); plan != "" { + b.WriteString("PLAN:\n") + b.WriteString(plan) + b.WriteString("\n\n") + } + } + + // Constraints + b.WriteString("CONSTRAINTS:\n") + b.WriteString("- Read CODEX.md for coding conventions (if it exists)\n") + b.WriteString("- Read CLAUDE.md for project-specific instructions (if it exists)\n") + b.WriteString("- Commit with conventional commit format: type(scope): description\n") + b.WriteString("- Co-Authored-By: Virgil \n") + b.WriteString("- Run build and tests before committing\n") + + return b.String(), memories, consumers } -// --- Plan template rendering --- +// --- Context Helpers (return strings, not write files) --- -// writePlanFromTemplate loads a YAML plan template, substitutes variables, -// and writes PLAN.md into the workspace src/ directory. -func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) { - // Load template from embedded prompts package - r := lib.Template(templateSlug) - if !r.OK { - return // Template not found, skip silently - } - - content := r.Value.(string) - - // Substitute variables ({{variable_name}} → value) - for key, value := range variables { - content = core.Replace(content, "{{"+key+"}}", value) - content = core.Replace(content, "{{ "+key+" }}", value) - } - - // Parse the YAML to render as markdown - var tmpl struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Guidelines []string `yaml:"guidelines"` - Phases []struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Tasks []any `yaml:"tasks"` - } `yaml:"phases"` - } - - if err := yaml.Unmarshal([]byte(content), &tmpl); err != nil { - return - } - - // Render as PLAN.md - plan := core.NewBuilder() - plan.WriteString("# Plan: " + tmpl.Name + "\n\n") - if task != "" { - plan.WriteString("**Task:** " + task + "\n\n") - } - if tmpl.Description != "" { - plan.WriteString(tmpl.Description + "\n\n") - } - - if len(tmpl.Guidelines) > 0 { - plan.WriteString("## Guidelines\n\n") - for _, g := range tmpl.Guidelines { - plan.WriteString("- " + g + "\n") - } - plan.WriteString("\n") - } - - for i, phase := range tmpl.Phases { - plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) - if phase.Description != "" { - plan.WriteString(phase.Description + "\n\n") - } - for _, task := range phase.Tasks { - switch t := task.(type) { - case string: - plan.WriteString("- [ ] " + t + "\n") - case map[string]any: - if name, ok := t["name"].(string); ok { - plan.WriteString("- [ ] " + name + "\n") - } - } - } - plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n") - } - - fs.Write(core.JoinPath(wsDir, "src", "PLAN.md"), plan.String()) -} - -// --- Helpers (unchanged) --- - -func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) int { +func (s *PrepSubsystem) getIssueBody(ctx context.Context, org, repo string, issue int) string { if s.forgeToken == "" { - return 0 + return "" } - url := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) + url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) - if err != nil { - return 0 + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + return "" } defer resp.Body.Close() - if resp.StatusCode != 200 { - return 0 + var issueData struct { + Title string `json:"title"` + Body string `json:"body"` } + json.NewDecoder(resp.Body).Decode(&issueData) - var pages []struct { - Title string `json:"title"` - SubURL string `json:"sub_url"` - } - json.NewDecoder(resp.Body).Decode(&pages) - - count := 0 - for _, page := range pages { - subURL := page.SubURL - if subURL == "" { - subURL = page.Title - } - - pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) - pageReq, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil) - pageReq.Header.Set("Authorization", "token "+s.forgeToken) - - pageResp, err := s.client.Do(pageReq) - if err != nil { - continue - } - if pageResp.StatusCode != 200 { - pageResp.Body.Close() - continue - } - - var pageData struct { - ContentBase64 string `json:"content_base64"` - } - json.NewDecoder(pageResp.Body).Decode(&pageData) - pageResp.Body.Close() - - if pageData.ContentBase64 == "" { - continue - } - - content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64) - filename := sanitiseFilename(page.Title) + ".md" - - fs.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content)) - count++ - } - - return count + return core.Sprintf("# %s\n\n%s", issueData.Title, issueData.Body) } -func (s *PrepSubsystem) copySpecs(wsDir string) int { - specFiles := []string{"AGENT_CONTEXT.md", "TASK_PROTOCOL.md"} - count := 0 - - for _, file := range specFiles { - src := core.JoinPath(s.specsPath, file) - if r := fs.Read(src); r.OK { - fs.Write(core.JoinPath(wsDir, "src", "specs", file), r.Value.(string)) - count++ - } - } - - return count -} - -func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) int { +func (s *PrepSubsystem) brainRecall(ctx context.Context, repo string) (string, int) { if s.brainKey == "" { - return 0 + return "", 0 } body, _ := json.Marshal(map[string]any{ @@ -489,44 +392,42 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) req.Header.Set("Authorization", "Bearer "+s.brainKey) resp, err := s.client.Do(req) - if err != nil { - return 0 + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + return "", 0 } defer resp.Body.Close() - if resp.StatusCode != 200 { - return 0 - } - respData, _ := goio.ReadAll(resp.Body) var result struct { Memories []map[string]any `json:"memories"` } json.Unmarshal(respData, &result) - content := core.NewBuilder() - content.WriteString("# Context — " + repo + "\n\n") - content.WriteString("> Relevant knowledge from OpenBrain.\n\n") + if len(result.Memories) == 0 { + return "", 0 + } + b := core.NewBuilder() for i, mem := range result.Memories { memType, _ := mem["type"].(string) memContent, _ := mem["content"].(string) memProject, _ := mem["project"].(string) - score, _ := mem["score"].(float64) - content.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent)) + b.WriteString(core.Sprintf("%d. [%s] %s: %s\n", i+1, memType, memProject, memContent)) } - fs.Write(core.JoinPath(wsDir, "src", "CONTEXT.md"), content.String()) - return len(result.Memories) + return b.String(), len(result.Memories) } -func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { +func (s *PrepSubsystem) findConsumersList(repo string) (string, int) { goWorkPath := core.JoinPath(s.codePath, "go.work") modulePath := "forge.lthn.ai/core/" + repo r := fs.Read(goWorkPath) if !r.OK { - return 0 + return "", 0 } workData := r.Value.(string) @@ -548,72 +449,158 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int { } } - if len(consumers) > 0 { - content := "# Consumers of " + repo + "\n\n" - content += "These modules import `" + modulePath + "`:\n\n" - for _, c := range consumers { - content += "- " + c + "\n" - } - content += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers)) - fs.Write(core.JoinPath(wsDir, "src", "CONSUMERS.md"), content) + if len(consumers) == 0 { + return "", 0 } - return len(consumers) + b := core.NewBuilder() + for _, c := range consumers { + b.WriteString("- " + c + "\n") + } + b.WriteString(core.Sprintf("Breaking change risk: %d consumers.\n", len(consumers))) + + return b.String(), len(consumers) } -func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int { +func (s *PrepSubsystem) getGitLog(repoPath string) string { cmd := exec.Command("git", "log", "--oneline", "-20") cmd.Dir = repoPath output, err := cmd.Output() if err != nil { - return 0 + return "" } - - lines := core.Split(core.Trim(string(output)), "\n") - if len(lines) > 0 && lines[0] != "" { - content := "# Recent Changes\n\n```\n" + string(output) + "```\n" - fs.Write(core.JoinPath(wsDir, "src", "RECENT.md"), content) - } - - return len(lines) + return core.Trim(string(output)) } -func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issue int, wsDir string) { +func (s *PrepSubsystem) pullWikiContent(ctx context.Context, org, repo string) string { if s.forgeToken == "" { - return + return "" } - url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue) + url := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo) req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) req.Header.Set("Authorization", "token "+s.forgeToken) resp, err := s.client.Do(req) - if err != nil { - return + if err != nil || resp.StatusCode != 200 { + if resp != nil { + resp.Body.Close() + } + return "" } defer resp.Body.Close() - if resp.StatusCode != 200 { - return + var pages []struct { + Title string `json:"title"` + SubURL string `json:"sub_url"` + } + json.NewDecoder(resp.Body).Decode(&pages) + + b := core.NewBuilder() + for _, page := range pages { + subURL := page.SubURL + if subURL == "" { + subURL = page.Title + } + + pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL) + pageReq, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil) + pageReq.Header.Set("Authorization", "token "+s.forgeToken) + + pageResp, pErr := s.client.Do(pageReq) + if pErr != nil || pageResp.StatusCode != 200 { + if pageResp != nil { + pageResp.Body.Close() + } + continue + } + + var pageData struct { + ContentBase64 string `json:"content_base64"` + } + json.NewDecoder(pageResp.Body).Decode(&pageData) + pageResp.Body.Close() + + if pageData.ContentBase64 == "" { + continue + } + + content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64) + b.WriteString("### " + page.Title + "\n\n") + b.WriteString(string(content)) + b.WriteString("\n\n") } - var issueData struct { - Title string `json:"title"` - Body string `json:"body"` - } - json.NewDecoder(resp.Body).Decode(&issueData) - - content := core.Sprintf("# TASK: %s\n\n", issueData.Title) - content += core.Sprintf("**Status:** ready\n") - content += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue) - content += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo) - content += "## Objective\n\n" + issueData.Body + "\n" - - fs.Write(core.JoinPath(wsDir, "src", "TODO.md"), content) + return b.String() } -// detectLanguage guesses the primary language from repo contents. -// Checks in priority order (Go first) to avoid nondeterministic results. +func (s *PrepSubsystem) renderPlan(templateSlug string, variables map[string]string, task string) string { + r := lib.Template(templateSlug) + if !r.OK { + return "" + } + + content := r.Value.(string) + for key, value := range variables { + content = core.Replace(content, "{{"+key+"}}", value) + content = core.Replace(content, "{{ "+key+" }}", value) + } + + var tmpl struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Guidelines []string `yaml:"guidelines"` + Phases []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Tasks []any `yaml:"tasks"` + } `yaml:"phases"` + } + + if err := yaml.Unmarshal([]byte(content), &tmpl); err != nil { + return "" + } + + plan := core.NewBuilder() + plan.WriteString("# " + tmpl.Name + "\n\n") + if task != "" { + plan.WriteString("**Task:** " + task + "\n\n") + } + if tmpl.Description != "" { + plan.WriteString(tmpl.Description + "\n\n") + } + + if len(tmpl.Guidelines) > 0 { + plan.WriteString("## Guidelines\n\n") + for _, g := range tmpl.Guidelines { + plan.WriteString("- " + g + "\n") + } + plan.WriteString("\n") + } + + for i, phase := range tmpl.Phases { + plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name)) + if phase.Description != "" { + plan.WriteString(phase.Description + "\n\n") + } + for _, t := range phase.Tasks { + switch v := t.(type) { + case string: + plan.WriteString("- [ ] " + v + "\n") + case map[string]any: + if name, ok := v["name"].(string); ok { + plan.WriteString("- [ ] " + name + "\n") + } + } + } + plan.WriteString("\n") + } + + return plan.String() +} + +// --- Detection helpers (unchanged) --- + func detectLanguage(repoPath string) string { checks := []struct { file string diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index fc6cac5..07b2052 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -168,7 +168,6 @@ func TestNewPrep_Good_EnvOverrides(t *testing.T) { assert.Equal(t, "test-token", s.forgeToken) assert.Equal(t, "https://custom-brain.example.com", s.brainURL) assert.Equal(t, "brain-key-123", s.brainKey) - assert.Equal(t, "/custom/specs", s.specsPath) assert.Equal(t, "/custom/code", s.codePath) } diff --git a/pkg/agentic/queue.go b/pkg/agentic/queue.go index 78a6be5..fafbf62 100644 --- a/pkg/agentic/queue.go +++ b/pkg/agentic/queue.go @@ -204,10 +204,9 @@ func (s *PrepSubsystem) drainQueue() { continue } - srcDir := core.JoinPath(wsDir, "src") - prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the current directory. Work in this directory." + prompt := "TASK: " + st.Task + "\n\nResume from where you left off. Read CODEX.md for conventions. Commit when done." - pid, _, err := s.spawnAgent(st.Agent, prompt, wsDir, srcDir) + pid, _, err := s.spawnAgent(st.Agent, prompt, wsDir) if err != nil { continue } diff --git a/pkg/agentic/resume.go b/pkg/agentic/resume.go index 34195f1..c4b5cef 100644 --- a/pkg/agentic/resume.go +++ b/pkg/agentic/resume.go @@ -44,10 +44,10 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } wsDir := core.JoinPath(WorkspaceRoot(), input.Workspace) - srcDir := core.JoinPath(wsDir, "src") + repoDir := core.JoinPath(wsDir, "repo") // Verify workspace exists - if !fs.IsDir(srcDir) { + if !fs.IsDir(core.JoinPath(repoDir, ".git")) { return nil, ResumeOutput{}, core.E("resume", "workspace not found: "+input.Workspace, nil) } @@ -69,7 +69,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu // Write ANSWER.md if answer provided if input.Answer != "" { - answerPath := core.JoinPath(srcDir, "ANSWER.md") + answerPath := core.JoinPath(repoDir, "ANSWER.md") content := core.Sprintf("# Answer\n\n%s\n", input.Answer) if r := fs.Write(answerPath, content); !r.OK { err, _ := r.Value.(error) @@ -77,12 +77,12 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } } - // Build resume prompt - prompt := "You are resuming previous work in this workspace. " + // Build resume prompt — inline the task and answer, no file references + prompt := "You are resuming previous work.\n\nORIGINAL TASK:\n" + st.Task if input.Answer != "" { - prompt += "Read ANSWER.md for the response to your question. " + prompt += "\n\nANSWER TO YOUR QUESTION:\n" + input.Answer } - prompt += "Read PROMPT.md for the original task. Read BLOCKED.md to see what you were stuck on. Continue working." + prompt += "\n\nContinue working. Read BLOCKED.md to see what you were stuck on. Commit when done." if input.DryRun { return nil, ResumeOutput{ @@ -94,7 +94,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu } // Spawn agent via go-process - pid, _, err := s.spawnAgent(agent, prompt, wsDir, srcDir) + pid, _, err := s.spawnAgent(agent, prompt, wsDir) if err != nil { return nil, ResumeOutput{}, err } diff --git a/pkg/agentic/status.go b/pkg/agentic/status.go index 061cbee..7536f97 100644 --- a/pkg/agentic/status.go +++ b/pkg/agentic/status.go @@ -165,7 +165,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu if st.Status == "running" && st.PID > 0 { if err := syscall.Kill(st.PID, 0); err != nil { // Process died — check for BLOCKED.md - blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md") + blockedPath := core.JoinPath(wsDir, "repo", "BLOCKED.md") if r := fs.Read(blockedPath); r.OK { info.Status = "blocked" info.Question = core.Trim(r.Value.(string)) diff --git a/pkg/agentic/verify.go b/pkg/agentic/verify.go index c2ca5d5..d98e376 100644 --- a/pkg/agentic/verify.go +++ b/pkg/agentic/verify.go @@ -27,7 +27,7 @@ func (s *PrepSubsystem) autoVerifyAndMerge(wsDir string) { return } - srcDir := core.JoinPath(wsDir, "src") + repoDir := core.JoinPath(wsDir, "repo") org := st.Org if org == "" { org = "core" @@ -47,7 +47,7 @@ func (s *PrepSubsystem) autoVerifyAndMerge(wsDir string) { } // Attempt 1: run tests and try to merge - result := s.attemptVerifyAndMerge(srcDir, org, st.Repo, st.Branch, prNum) + result := s.attemptVerifyAndMerge(repoDir, org, st.Repo, st.Branch, prNum) if result == mergeSuccess { markMerged() return @@ -55,8 +55,8 @@ func (s *PrepSubsystem) autoVerifyAndMerge(wsDir string) { // Attempt 2: rebase onto main and retry if result == mergeConflict || result == testFailed { - if s.rebaseBranch(srcDir, st.Branch) { - if s.attemptVerifyAndMerge(srcDir, org, st.Repo, st.Branch, prNum) == mergeSuccess { + if s.rebaseBranch(repoDir, st.Branch) { + if s.attemptVerifyAndMerge(repoDir, org, st.Repo, st.Branch, prNum) == mergeSuccess { markMerged() return } @@ -81,8 +81,8 @@ const ( ) // attemptVerifyAndMerge runs tests and tries to merge. Returns the outcome. -func (s *PrepSubsystem) attemptVerifyAndMerge(srcDir, org, repo, branch string, prNum int) mergeResult { - testResult := s.runVerification(srcDir) +func (s *PrepSubsystem) attemptVerifyAndMerge(repoDir, org, repo, branch string, prNum int) mergeResult { + testResult := s.runVerification(repoDir) if !testResult.passed { comment := core.Sprintf("## Verification Failed\n\n**Command:** `%s`\n\n```\n%s\n```\n\n**Exit code:** %d", @@ -107,29 +107,29 @@ func (s *PrepSubsystem) attemptVerifyAndMerge(srcDir, org, repo, branch string, } // rebaseBranch rebases the current branch onto the default branch and force-pushes. -func (s *PrepSubsystem) rebaseBranch(srcDir, branch string) bool { - base := DefaultBranch(srcDir) +func (s *PrepSubsystem) rebaseBranch(repoDir, branch string) bool { + base := DefaultBranch(repoDir) // Fetch latest default branch fetch := exec.Command("git", "fetch", "origin", base) - fetch.Dir = srcDir + fetch.Dir = repoDir if err := fetch.Run(); err != nil { return false } // Rebase onto default branch rebase := exec.Command("git", "rebase", "origin/"+base) - rebase.Dir = srcDir + rebase.Dir = repoDir if err := rebase.Run(); err != nil { // Rebase failed — abort and give up abort := exec.Command("git", "rebase", "--abort") - abort.Dir = srcDir + abort.Dir = repoDir abort.Run() return false } // Force-push the rebased branch to Forge (origin is local clone) - st, _ := readStatus(core.PathDir(srcDir)) + st, _ := readStatus(core.PathDir(repoDir)) org := "core" repo := "" if st != nil { @@ -140,7 +140,7 @@ func (s *PrepSubsystem) rebaseBranch(srcDir, branch string) bool { } forgeRemote := core.Sprintf("ssh://git@forge.lthn.ai:2223/%s/%s.git", org, repo) push := exec.Command("git", "push", "--force-with-lease", forgeRemote, branch) - push.Dir = srcDir + push.Dir = repoDir return push.Run() == nil } @@ -223,22 +223,22 @@ type verifyResult struct { } // runVerification detects the project type and runs the appropriate test suite. -func (s *PrepSubsystem) runVerification(srcDir string) verifyResult { - if fileExists(core.JoinPath(srcDir, "go.mod")) { - return s.runGoTests(srcDir) +func (s *PrepSubsystem) runVerification(repoDir string) verifyResult { + if fileExists(core.JoinPath(repoDir, "go.mod")) { + return s.runGoTests(repoDir) } - if fileExists(core.JoinPath(srcDir, "composer.json")) { - return s.runPHPTests(srcDir) + if fileExists(core.JoinPath(repoDir, "composer.json")) { + return s.runPHPTests(repoDir) } - if fileExists(core.JoinPath(srcDir, "package.json")) { - return s.runNodeTests(srcDir) + if fileExists(core.JoinPath(repoDir, "package.json")) { + return s.runNodeTests(repoDir) } return verifyResult{passed: true, testCmd: "none", output: "No test runner detected"} } -func (s *PrepSubsystem) runGoTests(srcDir string) verifyResult { +func (s *PrepSubsystem) runGoTests(repoDir string) verifyResult { cmd := exec.Command("go", "test", "./...", "-count=1", "-timeout", "120s") - cmd.Dir = srcDir + cmd.Dir = repoDir cmd.Env = append(os.Environ(), "GOWORK=off") out, err := cmd.CombinedOutput() @@ -254,9 +254,9 @@ func (s *PrepSubsystem) runGoTests(srcDir string) verifyResult { return verifyResult{passed: exitCode == 0, output: string(out), exitCode: exitCode, testCmd: "go test ./..."} } -func (s *PrepSubsystem) runPHPTests(srcDir string) verifyResult { +func (s *PrepSubsystem) runPHPTests(repoDir string) verifyResult { cmd := exec.Command("composer", "test", "--no-interaction") - cmd.Dir = srcDir + cmd.Dir = repoDir out, err := cmd.CombinedOutput() exitCode := 0 @@ -265,7 +265,7 @@ func (s *PrepSubsystem) runPHPTests(srcDir string) verifyResult { exitCode = exitErr.ExitCode() } else { cmd2 := exec.Command("./vendor/bin/pest", "--no-interaction") - cmd2.Dir = srcDir + cmd2.Dir = repoDir out2, err2 := cmd2.CombinedOutput() if err2 != nil { return verifyResult{passed: false, testCmd: "none", output: "No PHP test runner found (composer test and vendor/bin/pest both unavailable)", exitCode: 1} @@ -277,8 +277,8 @@ func (s *PrepSubsystem) runPHPTests(srcDir string) verifyResult { return verifyResult{passed: exitCode == 0, output: string(out), exitCode: exitCode, testCmd: "composer test"} } -func (s *PrepSubsystem) runNodeTests(srcDir string) verifyResult { - r := fs.Read(core.JoinPath(srcDir, "package.json")) +func (s *PrepSubsystem) runNodeTests(repoDir string) verifyResult { + r := fs.Read(core.JoinPath(repoDir, "package.json")) if !r.OK { return verifyResult{passed: true, testCmd: "none", output: "Could not read package.json"} } @@ -291,7 +291,7 @@ func (s *PrepSubsystem) runNodeTests(srcDir string) verifyResult { } cmd := exec.Command("npm", "test") - cmd.Dir = srcDir + cmd.Dir = repoDir out, err := cmd.CombinedOutput() exitCode := 0