agent/pkg/agentic/prep.go
Snider 90b03191b2 feat(agent): v0.2.0 — HTTP daemon, remote dispatch, review queue, verify+merge
Major additions:
- core-agent serve: persistent HTTP daemon with PID file, health check, registry
- agentic_dispatch_remote: dispatch tasks to remote agents (Charon) over MCP HTTP
- agentic_status_remote: check remote agent workspace status
- agentic_mirror: sync Forge repos to GitHub mirrors with file count limits
- agentic_review_queue: CodeRabbit/Codex review queue with rate-limit awareness
- verify.go: auto-verify (run tests) + auto-merge + retry with rebase + needs-review label
- monitor sync: checkin API integration for cross-agent repo sync
- PostToolUse inbox notification hook (check-notify.sh)

Dispatch improvements:
- --dangerously-skip-permissions (CLI flag changed)
- proc.CloseStdin() after spawn (Claude CLI stdin pipe fix)
- GOWORK=off in agent env and verify
- Exit code / BLOCKED.md / failure detection
- Monitor poke for instant notifications

New agent types:
- coderabbit: CodeRabbit CLI review (--plain --base)
- codex:review: OpenAI Codex review mode

Integrations:
- CODEX.md: OpenAI Codex conventions file
- Gemini extension: points at core-agent MCP (not Node server)
- Codex config: core-agent MCP server added
- GitHub webhook handler + CodeRabbit KPI tables (PHP)
- Forgejo provider for uptelligence webhooks
- Agent checkin endpoint for repo sync

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:45:04 +00:00

659 lines
21 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
// Package agentic provides MCP tools for agent orchestration.
// Prepares sandboxed workspaces and dispatches subagents.
package agentic
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/modelcontextprotocol/go-sdk/mcp"
"gopkg.in/yaml.v3"
)
// CompletionNotifier is called when an agent completes, to trigger
// immediate notifications to connected clients.
type CompletionNotifier interface {
Poke()
}
// PrepSubsystem provides agentic MCP tools.
type PrepSubsystem struct {
forgeURL string
forgeToken string
brainURL string
brainKey string
specsPath string
codePath string
client *http.Client
onComplete CompletionNotifier
}
// NewPrep creates an agentic subsystem.
func NewPrep() *PrepSubsystem {
home, _ := os.UserHomeDir()
forgeToken := os.Getenv("FORGE_TOKEN")
if forgeToken == "" {
forgeToken = os.Getenv("GITEA_TOKEN")
}
brainKey := os.Getenv("CORE_BRAIN_KEY")
if brainKey == "" {
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
brainKey = strings.TrimSpace(data)
}
}
return &PrepSubsystem{
forgeURL: envOr("FORGE_URL", "https://forge.lthn.ai"),
forgeToken: forgeToken,
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
brainKey: brainKey,
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
client: &http.Client{Timeout: 30 * time.Second},
}
}
// SetCompletionNotifier wires up the monitor for immediate push on agent completion.
func (s *PrepSubsystem) SetCompletionNotifier(n CompletionNotifier) {
s.onComplete = n
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Name implements mcp.Subsystem.
func (s *PrepSubsystem) Name() string { return "agentic" }
// RegisterTools implements mcp.Subsystem.
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/.",
}, s.prepWorkspace)
s.registerDispatchTool(server)
s.registerStatusTool(server)
s.registerResumeTool(server)
s.registerCreatePRTool(server)
s.registerListPRsTool(server)
s.registerEpicTool(server)
s.registerMirrorTool(server)
s.registerRemoteDispatchTool(server)
s.registerRemoteStatusTool(server)
s.registerReviewQueueTool(server)
mcp.AddTool(server, &mcp.Tool{
Name: "agentic_scan",
Description: "Scan Forge repos for open issues with actionable labels (agentic, help-wanted, bug).",
}, s.scan)
s.registerPlanTools(server)
s.registerWatchTool(server)
}
// Shutdown implements mcp.SubsystemWithShutdown.
func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
// --- Input/Output types ---
// PrepInput is the input for agentic_prep_workspace.
type PrepInput struct {
Repo string `json:"repo"` // 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.
}
// PrepOutput is the output for agentic_prep_workspace.
type PrepOutput struct {
Success bool `json:"success"`
WorkspaceDir string `json:"workspace_dir"`
Branch string `json:"branch"`
WikiPages int `json:"wiki_pages"`
SpecFiles int `json:"spec_files"`
Memories int `json:"memories"`
Consumers int `json:"consumers"`
ClaudeMd bool `json:"claude_md"`
GitLog int `json:"git_log_entries"`
}
func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) {
if input.Repo == "" {
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "repo is required", nil)
}
if input.Org == "" {
input.Org = "core"
}
if input.Template == "" {
input.Template = "coding"
}
// Workspace root: .core/workspace/{repo}-{timestamp}/
home, _ := os.UserHomeDir()
wsRoot := filepath.Join(home, "Code", "host-uk", "core", ".core", "workspace")
wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix())
wsDir := filepath.Join(wsRoot, wsName)
// Create workspace structure
// kb/ and specs/ will be created inside src/ after clone
out := PrepOutput{WorkspaceDir: wsDir}
// Source repo path
repoPath := filepath.Join(s.codePath, "core", input.Repo)
// 1. Clone repo into src/ and create feature branch
srcDir := filepath.Join(wsDir, "src")
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
cloneCmd.Run()
// Create feature branch
taskSlug := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
return r
}
if r >= 'A' && r <= 'Z' {
return r + 32 // lowercase
}
return '-'
}, input.Task)
if len(taskSlug) > 40 {
taskSlug = taskSlug[:40]
}
taskSlug = strings.Trim(taskSlug, "-")
branchName := fmt.Sprintf("agent/%s", taskSlug)
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
branchCmd.Dir = srcDir
branchCmd.Run()
out.Branch = branchName
// Create context dirs inside src/
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs"))
// Remote stays as local clone origin — agent cannot push to forge.
// Reviewer pulls changes from workspace and pushes after verification.
// 2. Copy CLAUDE.md and GEMINI.md to workspace
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
out.ClaudeMd = true
}
// Copy GEMINI.md from core/agent (ethics framework for all agents)
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
}
// Copy persona if specified
if input.Persona != "" {
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", input.Persona+".md")
if data, err := coreio.Local.Read(personaPath); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
}
}
// 3. Generate TODO.md
if input.Issue > 0 {
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
} else if input.Task != "" {
todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n",
input.Task, input.Org, input.Repo, input.Task)
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
}
// 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)
out.Success = true
return nil, out, nil
}
// --- Prompt templates ---
func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) {
var prompt string
switch template {
case "conventions":
prompt = `## SANDBOX: You are restricted to this directory only. No absolute paths, no cd .., no editing outside src/.
Read CLAUDE.md for project conventions.
Review all Go files in src/ for:
- Error handling: should use coreerr.E() from go-log, not fmt.Errorf or errors.New
- Compile-time interface checks: var _ Interface = (*Impl)(nil)
- Import aliasing: stdlib io aliased as goio
- UK English in comments (colour not color, initialise not initialize)
- No fmt.Print* debug statements (use go-log)
- Test coverage gaps
Report findings with file:line references. Do not fix — only report.
`
case "security":
prompt = `## SANDBOX: You are restricted to this directory only. No absolute paths, no cd .., no editing outside src/.
Read CLAUDE.md for project context.
Review all Go files in src/ for security issues:
- Path traversal vulnerabilities
- Unvalidated input
- SQL injection (if applicable)
- Hardcoded credentials or tokens
- Unsafe type assertions
- Missing error checks
- Race conditions (shared state without mutex)
- Unsafe use of os/exec
Report findings with severity (critical/high/medium/low) and file:line references.
`
case "verify":
prompt = `Read PERSONA.md if it exists — adopt that identity and approach.
Read CLAUDE.md for project conventions and context.
You are verifying a pull request. The code in src/ contains changes on a feature branch.
## Your Tasks
1. **Run tests**: Execute the project's test suite (go test ./..., composer test, or npm test). Report results.
2. **Review diff**: Run ` + "`git diff origin/main..HEAD`" + ` to see all changes. Review for:
- Correctness: Does the code do what the commit messages say?
- Security: Path traversal, injection, hardcoded secrets, unsafe input handling
- Conventions: coreerr.E() not fmt.Errorf, go-io not os.ReadFile, UK English
- Test coverage: Are new functions tested?
3. **Verdict**: Write VERDICT.md with:
- PASS or FAIL (first line, nothing else)
- Summary of findings (if any)
- List of issues by severity (critical/high/medium/low)
If PASS: the PR will be auto-merged.
If FAIL: your findings will be commented on the PR for the original agent to address.
Be strict but fair. A missing test is medium. A security issue is critical. A typo is low.
## SANDBOX BOUNDARY (HARD LIMIT)
You are restricted to the current directory and its subdirectories ONLY.
- Do NOT use absolute paths
- Do NOT navigate outside this repository
`
case "coding":
prompt = `Read PERSONA.md if it exists — adopt that identity and approach.
Read CLAUDE.md for project conventions and context.
Read TODO.md for your task.
Read PLAN.md if it exists — work through each phase in order.
Read CONTEXT.md for relevant knowledge from previous sessions.
Read CONSUMERS.md to understand breaking change risk.
Read RECENT.md for recent changes.
Work in the src/ directory. Follow the conventions in CLAUDE.md.
## SANDBOX BOUNDARY (HARD LIMIT)
You are restricted to the current directory and its subdirectories ONLY.
- Do NOT use absolute paths (e.g., /Users/..., /home/...)
- Do NOT navigate with cd .. or cd /
- Do NOT edit files outside this repository
- Do NOT access parent directories or other repos
- Any path in Edit/Write tool calls MUST be relative to the current directory
Violation of these rules will cause your work to be rejected.
## Workflow
If PLAN.md exists, you MUST work through it phase by phase:
1. Complete all tasks in the current phase
2. STOP and commit before moving on: type(scope): phase N - description
3. Only then start the next phase
4. If you are blocked or unsure, write BLOCKED.md explaining the question and stop
5. Do NOT skip phases or combine multiple phases into one commit
Each phase = one commit. This is not optional.
If no PLAN.md, complete TODO.md as a single unit of work.
## Commit Convention
Commit message format: type(scope): description
Co-Author: Co-Authored-By: Virgil <virgil@lethean.io>
Do NOT push. Commit only — a reviewer will verify and push.
`
default:
prompt = "SANDBOX: Restricted to this directory only. No absolute paths, no cd ..\n\nRead TODO.md and complete the task. Work in src/.\n"
}
coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
}
// --- Plan template rendering ---
// 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) {
// Look for template in core/agent/prompts/templates/
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
data, err := coreio.Local.Read(templatePath)
if err != nil {
// Try .yml extension
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
data, err = coreio.Local.Read(templatePath)
if err != nil {
return // Template not found, skip silently
}
}
content := data
// Substitute variables ({{variable_name}} → value)
for key, value := range variables {
content = strings.ReplaceAll(content, "{{"+key+"}}", value)
content = strings.ReplaceAll(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
var plan strings.Builder
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(fmt.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")
}
coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
}
// --- Helpers (unchanged) ---
func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) int {
if s.forgeToken == "" {
return 0
}
url := fmt.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 || resp.StatusCode != 200 {
return 0
}
defer resp.Body.Close()
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 := fmt.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 || pageResp.StatusCode != 200 {
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 := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
return r
}
return '-'
}, page.Title) + ".md"
coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content))
count++
}
return count
}
func (s *PrepSubsystem) copySpecs(wsDir string) int {
specFiles := []string{"AGENT_CONTEXT.md", "TASK_PROTOCOL.md"}
count := 0
for _, file := range specFiles {
src := filepath.Join(s.specsPath, file)
if data, err := coreio.Local.Read(src); err == nil {
coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data)
count++
}
}
return count
}
func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) int {
if s.brainKey == "" {
return 0
}
body, _ := json.Marshal(map[string]any{
"query": "architecture conventions key interfaces for " + repo,
"top_k": 10,
"project": repo,
"agent_id": "cladius",
})
req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body)))
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 || resp.StatusCode != 200 {
return 0
}
defer resp.Body.Close()
respData, _ := io.ReadAll(resp.Body)
var result struct {
Memories []map[string]any `json:"memories"`
}
json.Unmarshal(respData, &result)
var content strings.Builder
content.WriteString("# Context — " + repo + "\n\n")
content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
for i, mem := range result.Memories {
memType, _ := mem["type"].(string)
memContent, _ := mem["content"].(string)
memProject, _ := mem["project"].(string)
score, _ := mem["score"].(float64)
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
}
coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
return len(result.Memories)
}
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
goWorkPath := filepath.Join(s.codePath, "go.work")
modulePath := "forge.lthn.ai/core/" + repo
workData, err := coreio.Local.Read(goWorkPath)
if err != nil {
return 0
}
var consumers []string
for _, line := range strings.Split(workData, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "./") {
continue
}
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
goMod := filepath.Join(dir, "go.mod")
modData, err := coreio.Local.Read(goMod)
if err != nil {
continue
}
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
consumers = append(consumers, filepath.Base(dir))
}
}
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 += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
}
return len(consumers)
}
func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
cmd := exec.Command("git", "log", "--oneline", "-20")
cmd.Dir = repoPath
output, err := cmd.Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 0 && lines[0] != "" {
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content)
}
return len(lines)
}
func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issue int, wsDir string) {
if s.forgeToken == "" {
return
}
url := fmt.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 || resp.StatusCode != 200 {
return
}
defer resp.Body.Close()
var issueData struct {
Title string `json:"title"`
Body string `json:"body"`
}
json.NewDecoder(resp.Body).Decode(&issueData)
content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title)
content += fmt.Sprintf("**Status:** ready\n")
content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
content += "## Objective\n\n" + issueData.Body + "\n"
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
}