diff --git a/.gitignore b/.gitignore index 815e1fa..f52aea0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .core/ .idea/ +core-mcp diff --git a/docs/plans/2026-03-15-issue-driven-dispatch.md b/docs/plans/2026-03-15-issue-driven-dispatch.md new file mode 100644 index 0000000..4fe4bc1 --- /dev/null +++ b/docs/plans/2026-03-15-issue-driven-dispatch.md @@ -0,0 +1,281 @@ +# 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** + +```go +// 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) + +```go +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. + +```go +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()`: + +```go +s.registerIssueTools(server) +``` + +Registration: + +```go +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** + +```bash +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 " +``` + +--- + +## 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 diff --git a/docs/plans/2026-03-15-plan-crud-mcp.md b/docs/plans/2026-03-15-plan-crud-mcp.md new file mode 100644 index 0000000..4afbf4d --- /dev/null +++ b/docs/plans/2026-03-15-plan-crud-mcp.md @@ -0,0 +1,292 @@ +# Plan CRUD MCP Tools 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:** Add MCP tools for plan lifecycle management — create plans from templates, track phase progress, record checkpoints. Agents can structure their work into plans with phases and tasks, enabling stop/ask/resume workflows. + +**Architecture:** Thin HTTP wrappers calling the existing Laravel API at `api.lthn.sh/v1/plans/*`. Same pattern as `brain.NewDirect()` — reads `CORE_BRAIN_KEY` for auth, calls REST endpoints, returns structured results. No local storage needed (the API handles persistence). + +**Tech Stack:** Go, MCP SDK, HTTP client, JSON + +--- + +## Existing API Endpoints (Laravel — already built) + +``` +GET /v1/plans # List plans +GET /v1/plans/{slug} # Get plan detail +POST /v1/plans # Create plan +PATCH /v1/plans/{slug} # Update plan +PATCH /v1/plans/{slug}/phases/{phase} # Update phase +POST /v1/plans/{slug}/phases/{phase}/checkpoint # Add checkpoint +POST /v1/plans/{slug}/phases/{phase}/tasks/{idx}/toggle # Toggle task +``` + +## File Structure + +| File | Action | Purpose | +|------|--------|---------| +| `pkg/mcp/agentic/plans.go` | Create | Plan CRUD MCP tools — create, status, list, checkpoint, update | +| `pkg/mcp/agentic/plans_test.go` | Create | Tests with mock HTTP server | + +--- + +## Task 1: Plan MCP Tools + +**Files:** +- Create: `pkg/mcp/agentic/plans.go` +- Modify: `pkg/mcp/agentic/prep.go` (register new tools) + +- [ ] **Step 1: Write the plan tools** + +Create `plans.go` with 4 MCP tools that wrap the Laravel API: + +```go +// plans.go +package agentic + +// Tool: agentic_plan_create +// Creates a plan from a template slug with variable substitution. +// POST /v1/plans with { template: slug, variables: {}, title: "", activate: bool } + +// Tool: agentic_plan_status +// Gets plan detail including phases, tasks, and progress. +// GET /v1/plans/{slug} + +// Tool: agentic_plan_list +// Lists all plans, optionally filtered by status. +// GET /v1/plans?status={status} + +// Tool: agentic_plan_checkpoint +// Records a checkpoint on a phase (notes, completion status). +// POST /v1/plans/{slug}/phases/{phase}/checkpoint +``` + +Input/output types: + +```go +// PlanCreateInput for agentic_plan_create +type PlanCreateInput struct { + Template string `json:"template"` // Template slug: bug-fix, code-review, feature, refactor + Variables map[string]string `json:"variables,omitempty"` // Template variables + Title string `json:"title,omitempty"` // Override plan title + Activate bool `json:"activate,omitempty"` // Activate immediately (default: draft) +} + +// PlanStatusInput for agentic_plan_status +type PlanStatusInput struct { + Slug string `json:"slug"` // Plan slug +} + +// PlanListInput for agentic_plan_list +type PlanListInput struct { + Status string `json:"status,omitempty"` // Filter: draft, active, completed, archived +} + +// PlanCheckpointInput for agentic_plan_checkpoint +type PlanCheckpointInput struct { + Slug string `json:"slug"` // Plan slug + Phase int `json:"phase"` // Phase order number + Notes string `json:"notes,omitempty"` // Checkpoint notes + Done bool `json:"done,omitempty"` // Mark phase as completed +} +``` + +Each tool calls `s.apiCall()` (reuse the pattern from PrepSubsystem which already has `brainURL`, `brainKey`, and `client`). + +- [ ] **Step 2: Implement agentic_plan_create** + +```go +func (s *PrepSubsystem) planCreate(ctx context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, map[string]any, error) { + if input.Template == "" { + return nil, nil, fmt.Errorf("template is required") + } + + body := map[string]any{ + "template": input.Template, + "variables": input.Variables, + "activate": input.Activate, + } + if input.Title != "" { + body["title"] = input.Title + } + + result, err := s.planAPICall(ctx, "POST", "/v1/plans", body) + if err != nil { + return nil, nil, fmt.Errorf("agentic_plan_create: %w", err) + } + + return nil, result, nil +} +``` + +- [ ] **Step 3: Implement agentic_plan_status** + +```go +func (s *PrepSubsystem) planStatus(ctx context.Context, _ *mcp.CallToolRequest, input PlanStatusInput) (*mcp.CallToolResult, map[string]any, error) { + if input.Slug == "" { + return nil, nil, fmt.Errorf("slug is required") + } + + result, err := s.planAPICall(ctx, "GET", "/v1/plans/"+input.Slug, nil) + if err != nil { + return nil, nil, fmt.Errorf("agentic_plan_status: %w", err) + } + + return nil, result, nil +} +``` + +- [ ] **Step 4: Implement agentic_plan_list** + +```go +func (s *PrepSubsystem) planList(ctx context.Context, _ *mcp.CallToolRequest, input PlanListInput) (*mcp.CallToolResult, map[string]any, error) { + path := "/v1/plans" + if input.Status != "" { + path += "?status=" + input.Status + } + + result, err := s.planAPICall(ctx, "GET", path, nil) + if err != nil { + return nil, nil, fmt.Errorf("agentic_plan_list: %w", err) + } + + return nil, result, nil +} +``` + +- [ ] **Step 5: Implement agentic_plan_checkpoint** + +```go +func (s *PrepSubsystem) planCheckpoint(ctx context.Context, _ *mcp.CallToolRequest, input PlanCheckpointInput) (*mcp.CallToolResult, map[string]any, error) { + if input.Slug == "" { + return nil, nil, fmt.Errorf("slug is required") + } + + path := fmt.Sprintf("/v1/plans/%s/phases/%d/checkpoint", input.Slug, input.Phase) + body := map[string]any{ + "notes": input.Notes, + } + if input.Done { + body["status"] = "completed" + } + + result, err := s.planAPICall(ctx, "POST", path, body) + if err != nil { + return nil, nil, fmt.Errorf("agentic_plan_checkpoint: %w", err) + } + + return nil, result, nil +} +``` + +- [ ] **Step 6: Add planAPICall helper** + +Reuses `brainURL` and `brainKey` from PrepSubsystem (same API, same auth): + +```go +func (s *PrepSubsystem) planAPICall(ctx context.Context, method, path string, body any) (map[string]any, error) { + if s.brainKey == "" { + return nil, fmt.Errorf("no API key (set CORE_BRAIN_KEY)") + } + + var reqBody goio.Reader + if body != nil { + data, _ := json.Marshal(body) + reqBody = bytes.NewReader(data) + } + + req, _ := http.NewRequestWithContext(ctx, method, s.brainURL+path, reqBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+s.brainKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respData, _ := goio.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API %d: %s", resp.StatusCode, string(respData)) + } + + var result map[string]any + json.Unmarshal(respData, &result) + return result, nil +} +``` + +- [ ] **Step 7: Register tools in prep.go** + +Add to `RegisterTools()`: + +```go +s.registerPlanTools(server) +``` + +And the registration function: + +```go +func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_plan_create", + Description: "Create an agent work plan from a template (bug-fix, code-review, feature, refactor). Plans have phases with tasks and checkpoints.", + }, s.planCreate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_plan_status", + Description: "Get plan detail including phases, tasks, progress, and checkpoints.", + }, s.planStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_plan_list", + Description: "List all plans, optionally filtered by status (draft, active, completed, archived).", + }, s.planList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "agentic_plan_checkpoint", + Description: "Record a checkpoint on a plan phase. Include notes about progress, decisions, or blockers.", + }, s.planCheckpoint) +} +``` + +- [ ] **Step 8: Verify compilation** + +Run: `go vet ./pkg/mcp/agentic/` +Expected: clean + +- [ ] **Step 9: Commit** + +```bash +git add pkg/mcp/agentic/plans.go pkg/mcp/agentic/prep.go +git commit -m "feat(agentic): plan CRUD MCP tools — create, status, list, checkpoint + +Thin HTTP wrappers calling api.lthn.sh/v1/plans/* REST endpoints. +Same auth pattern as brain tools (CORE_BRAIN_KEY). +Templates: bug-fix, code-review, feature, refactor. + +Co-Authored-By: Virgil " +``` + +--- + +## Summary + +**Total: 1 task, 9 steps** + +After completion, agents have 4 new MCP tools: +- `agentic_plan_create` — start structured work from a template +- `agentic_plan_status` — check progress +- `agentic_plan_list` — see all plans +- `agentic_plan_checkpoint` — record milestones + +Combined with `agentic_status` + `agentic_resume`, this gives the full lifecycle: +1. Create plan → 2. Dispatch agent → 3. Agent works through phases → 4. Agent hits blocker → writes BLOCKED.md → 5. Reviewer answers → 6. Resume → 7. Agent checkpoints completion + +Available templates (from PHP system): +- `bug-fix` — diagnose, fix, test, verify +- `code-review` — audit, report, fix, re-check +- `feature` — design, implement, test, document +- `refactor` — analyse, restructure, test, verify