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:
parent
6e37bd22f0
commit
6e03287178
9 changed files with 414 additions and 457 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue