mcp/docs/plans/2026-03-15-issue-driven-dispatch.md
Snider 3b8f17d8fd docs: add implementation plans for plan CRUD and issue dispatch
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:10:52 +00:00

9.6 KiB

Issue-Driven Dispatch Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Agents claim Forge issues, work in sandboxed workspaces with feature branches, and create PRs when done. Assignment = lock (no two agents work the same issue).

Architecture: New MCP tool agentic_dispatch_issue takes an issue number + repo, assigns the issue to the agent (lock), preps a workspace with the issue body as TODO.md, creates a feature branch agent/issue-{num}-{slug}, and dispatches. On completion, a agentic_pr tool creates a PR via the Forge API linking back to the issue.

Tech Stack: Go, MCP SDK, Forge/Gitea API (go-scm), git


Existing Infrastructure

Already built:

  • agentic_dispatch — preps workspace + spawns agent (dispatch.go)
  • agentic_scan — finds issues with actionable labels (scan.go)
  • prepWorkspace() — clones repo, creates feature branch, writes context files
  • generateTodo() — fetches issue from Forge API, writes TODO.md
  • PrepSubsystem has forgeURL, forgeToken, client fields

The issue flow just wires scan → dispatch together with assignment as the lock.

File Structure

File Action Purpose
pkg/mcp/agentic/issue.go Create agentic_dispatch_issue + agentic_pr tools
pkg/mcp/agentic/prep.go Modify Register new tools

Task 1: Issue Dispatch Tool

Files:

  • Create: pkg/mcp/agentic/issue.go

  • Modify: pkg/mcp/agentic/prep.go

  • Step 1: Create issue.go with input/output types

// issue.go
package agentic

// IssueDispatchInput for agentic_dispatch_issue
type IssueDispatchInput struct {
    Repo     string `json:"repo"`              // Target repo (e.g. "go-io")
    Org      string `json:"org,omitempty"`      // Forge org (default "core")
    Issue    int    `json:"issue"`             // Forge issue number
    Agent    string `json:"agent,omitempty"`   // "gemini", "codex", "claude" (default "claude")
    Template string `json:"template,omitempty"` // "conventions", "security", "coding" (default "coding")
    DryRun   bool   `json:"dry_run,omitempty"`
}

// PRInput for agentic_pr
type PRInput struct {
    Workspace string `json:"workspace"`           // Workspace name
    Title     string `json:"title,omitempty"`      // PR title (default: from issue)
    Body      string `json:"body,omitempty"`       // PR body (default: auto-generated)
    Base      string `json:"base,omitempty"`       // Base branch (default: "main")
}
  • Step 2: Implement agentic_dispatch_issue

Flow:

  1. Fetch issue from Forge API to validate it exists
  2. Check issue is not already assigned (if assigned, return error — it's locked)
  3. Assign issue to agent (POST /api/v1/repos/{org}/{repo}/issues/{num} with assignee)
  4. Add "in-progress" label
  5. Call existing dispatch() with the issue number (it already handles TODO.md generation)
func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequest, input IssueDispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
    if input.Issue == 0 {
        return nil, DispatchOutput{}, fmt.Errorf("issue number is required")
    }
    if input.Repo == "" {
        return nil, DispatchOutput{}, fmt.Errorf("repo is required")
    }
    if input.Org == "" {
        input.Org = "core"
    }
    if input.Agent == "" {
        input.Agent = "claude"
    }

    // 1. Fetch issue to validate and check assignment
    issueURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, input.Org, input.Repo, input.Issue)
    issueReq, _ := http.NewRequestWithContext(ctx, "GET", issueURL, nil)
    issueReq.Header.Set("Authorization", "token "+s.forgeToken)

    resp, err := s.client.Do(issueReq)
    if err != nil || resp.StatusCode != 200 {
        return nil, DispatchOutput{}, fmt.Errorf("issue %d not found in %s/%s", input.Issue, input.Org, input.Repo)
    }
    defer resp.Body.Close()

    var issue struct {
        Title    string `json:"title"`
        Assignee *struct {
            Login string `json:"login"`
        } `json:"assignee"`
        State string `json:"state"`
    }
    json.NewDecoder(resp.Body).Decode(&issue)

    if issue.State != "open" {
        return nil, DispatchOutput{}, fmt.Errorf("issue %d is %s, not open", input.Issue, issue.State)
    }

    // 2. Check lock (assignment)
    if issue.Assignee != nil && issue.Assignee.Login != "" {
        return nil, DispatchOutput{}, fmt.Errorf("issue %d already assigned to %s", input.Issue, issue.Assignee.Login)
    }

    // 3. Assign to agent (lock)
    if !input.DryRun && s.forgeToken != "" {
        assignBody, _ := json.Marshal(map[string]any{"assignees": []string{input.Agent}})
        assignReq, _ := http.NewRequestWithContext(ctx, "PATCH", issueURL, bytes.NewReader(assignBody))
        assignReq.Header.Set("Authorization", "token "+s.forgeToken)
        assignReq.Header.Set("Content-Type", "application/json")
        s.client.Do(assignReq)
    }

    // 4. Dispatch via existing dispatch()
    return s.dispatch(ctx, req, DispatchInput{
        Repo:     input.Repo,
        Org:      input.Org,
        Issue:    input.Issue,
        Task:     issue.Title,
        Agent:    input.Agent,
        Template: input.Template,
        DryRun:   input.DryRun,
    })
}
  • Step 3: Implement agentic_pr

Creates a PR from the agent's feature branch back to main, referencing the issue.

func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, input PRInput) (*mcp.CallToolResult, map[string]any, error) {
    if input.Workspace == "" {
        return nil, nil, fmt.Errorf("workspace is required")
    }

    home, _ := os.UserHomeDir()
    wsDir := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace", input.Workspace)
    srcDir := filepath.Join(wsDir, "src")

    // Read status to get repo info
    st, err := readStatus(wsDir)
    if err != nil {
        return nil, nil, fmt.Errorf("no status.json: %w", err)
    }

    // Get current branch name
    branchCmd := exec.CommandContext(ctx, "git", "branch", "--show-current")
    branchCmd.Dir = srcDir
    branchOut, err := branchCmd.Output()
    if err != nil {
        return nil, nil, fmt.Errorf("failed to get branch: %w", err)
    }
    branch := strings.TrimSpace(string(branchOut))

    // Push branch to forge
    pushCmd := exec.CommandContext(ctx, "git", "push", "origin", branch)
    pushCmd.Dir = srcDir
    if out, err := pushCmd.CombinedOutput(); err != nil {
        return nil, nil, fmt.Errorf("push failed: %s", string(out))
    }

    // Determine PR title and body
    title := input.Title
    if title == "" {
        title = st.Task
    }
    base := input.Base
    if base == "" {
        base = "main"
    }
    body := input.Body
    if body == "" {
        body = fmt.Sprintf("Automated PR from agent workspace `%s`.\n\nTask: %s", input.Workspace, st.Task)
    }

    // Create PR via Forge API
    org := "core" // TODO: extract from status
    prURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, st.Repo)
    prBody, _ := json.Marshal(map[string]any{
        "title": title,
        "body":  body,
        "head":  branch,
        "base":  base,
    })

    prReq, _ := http.NewRequestWithContext(ctx, "POST", prURL, bytes.NewReader(prBody))
    prReq.Header.Set("Authorization", "token "+s.forgeToken)
    prReq.Header.Set("Content-Type", "application/json")

    resp, err := s.client.Do(prReq)
    if err != nil {
        return nil, nil, fmt.Errorf("PR creation failed: %w", err)
    }
    defer resp.Body.Close()

    var prResult map[string]any
    json.NewDecoder(resp.Body).Decode(&prResult)

    if resp.StatusCode >= 400 {
        return nil, nil, fmt.Errorf("PR creation returned %d", resp.StatusCode)
    }

    return nil, prResult, nil
}
  • Step 4: Register tools in prep.go

Add to RegisterTools():

s.registerIssueTools(server)

Registration:

func (s *PrepSubsystem) registerIssueTools(server *mcp.Server) {
    mcp.AddTool(server, &mcp.Tool{
        Name:        "agentic_dispatch_issue",
        Description: "Dispatch an agent to work on a Forge issue. Assigns the issue (lock), preps workspace with issue body as TODO.md, creates feature branch, spawns agent.",
    }, s.dispatchIssue)

    mcp.AddTool(server, &mcp.Tool{
        Name:        "agentic_pr",
        Description: "Create a PR from an agent workspace. Pushes the feature branch and creates a pull request on Forge linking to the original issue.",
    }, s.createPR)
}
  • Step 5: Verify compilation

Run: go vet ./pkg/mcp/agentic/ Expected: clean

  • Step 6: Commit
git add pkg/mcp/agentic/issue.go pkg/mcp/agentic/prep.go
git commit -m "feat(agentic): issue-driven dispatch — claim, branch, PR

New MCP tools:
- agentic_dispatch_issue: assigns issue (lock), preps workspace, dispatches
- agentic_pr: pushes branch, creates PR via Forge API

Assignment = lock — no two agents work the same issue.

Co-Authored-By: Virgil <virgil@lethean.io>"

Summary

Total: 1 task, 6 steps

After completion, the full issue lifecycle:

  1. agentic_scan — find issues with actionable labels
  2. agentic_dispatch_issue — claim issue (assign = lock), prep workspace, spawn agent
  3. Agent works in sandboxed workspace with feature branch
  4. Agent writes BLOCKED.md if stuck → agentic_resume to continue
  5. agentic_pr — push branch, create PR referencing the issue
  6. PR reviewed and merged

Community flow:

  • Maintainer creates issue with agentic label
  • Agent scans, claims, works, PRs
  • Maintainer reviews and merges