refactor(agentic): workspace = clone, prompt replaces files

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-22 13:41:59 +00:00
parent 6e37bd22f0
commit 6e03287178
9 changed files with 414 additions and 457 deletions

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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 <virgil@lethean.io>\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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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))

View file

@ -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