AX Quality Gates (RFC-025):
- Eliminate os/exec from all test + production code (12+ files)
- Eliminate encoding/json from all test files (15 files, 66 occurrences)
- Eliminate os from all test files except TestMain (Go runtime contract)
- Eliminate path/filepath, net/url from all files
- String concat: 39 violations replaced with core.Concat()
- Test naming AX-7: 264 test functions renamed across all 6 packages
- Example test 1:1 coverage complete
Core Features Adopted:
- Task Composition: agent.completion pipeline (QA → PR → Verify → Ingest → Poke)
- PerformAsync: completion pipeline runs with WaitGroup + progress tracking
- Config: agents.yaml loaded once, feature flags (auto-qa/pr/merge/ingest)
- Named Locks: c.Lock("drain") for queue serialisation
- Registry: workspace state with cross-package QUERY access
- QUERY: c.QUERY(WorkspaceQuery{Status: "running"}) for cross-service queries
- Action descriptions: 25+ Actions self-documenting
- Data mounts: prompts/tasks/flows/personas/workspaces via c.Data()
- Content Actions: agentic.prompt/task/flow/persona callable via IPC
- Drive endpoints: forge + brain registered with tokens
- Drive REST helpers: DriveGet/DrivePost/DriveDo for Drive-aware HTTP
- HandleIPCEvents: auto-discovered by WithService (no manual wiring)
- Entitlement: frozen-queue gate on write Actions
- CLI dispatch: workspace dispatch wired to real dispatch method
- CLI: --quiet/-q and --debug/-d global flags
- CLI: banner, version, check (with service/action/command counts), env
- main.go: minimal — 5 services + c.Run(), no os import
- cmd tests: 84.2% coverage (was 0%)
Co-Authored-By: Virgil <virgil@lethean.io>
382 lines
11 KiB
Go
382 lines
11 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"time"
|
|
|
|
core "dappco.re/go/core"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Plan represents an implementation plan for agent work.
|
|
//
|
|
// plan := &Plan{ID: "migrate-core-abc", Title: "Migrate Core", Status: "draft", Objective: "..."}
|
|
// writePlan(PlansRoot(), plan)
|
|
type Plan struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"` // draft, ready, in_progress, needs_verification, verified, approved
|
|
Repo string `json:"repo,omitempty"`
|
|
Org string `json:"org,omitempty"`
|
|
Objective string `json:"objective"`
|
|
Phases []Phase `json:"phases,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// Phase represents a phase within an implementation plan.
|
|
//
|
|
// phase := agentic.Phase{Number: 1, Name: "Migrate strings", Status: "in_progress"}
|
|
type Phase struct {
|
|
Number int `json:"number"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // pending, in_progress, done
|
|
Criteria []string `json:"criteria,omitempty"`
|
|
Tests int `json:"tests,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// --- Input/Output types ---
|
|
|
|
// PlanCreateInput is the input for agentic_plan_create.
|
|
//
|
|
// input := agentic.PlanCreateInput{Title: "Migrate pkg/agentic", Objective: "Use Core primitives everywhere"}
|
|
type PlanCreateInput struct {
|
|
Title string `json:"title"`
|
|
Objective string `json:"objective"`
|
|
Repo string `json:"repo,omitempty"`
|
|
Org string `json:"org,omitempty"`
|
|
Phases []Phase `json:"phases,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// PlanCreateOutput is the output for agentic_plan_create.
|
|
//
|
|
// out := agentic.PlanCreateOutput{Success: true, ID: "migrate-pkg-agentic-abc123"}
|
|
type PlanCreateOutput struct {
|
|
Success bool `json:"success"`
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// PlanReadInput is the input for agentic_plan_read.
|
|
//
|
|
// input := agentic.PlanReadInput{ID: "migrate-pkg-agentic-abc123"}
|
|
type PlanReadInput struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// PlanReadOutput is the output for agentic_plan_read.
|
|
//
|
|
// out := agentic.PlanReadOutput{Success: true, Plan: agentic.Plan{ID: "migrate-pkg-agentic-abc123"}}
|
|
type PlanReadOutput struct {
|
|
Success bool `json:"success"`
|
|
Plan Plan `json:"plan"`
|
|
}
|
|
|
|
// PlanUpdateInput is the input for agentic_plan_update.
|
|
//
|
|
// input := agentic.PlanUpdateInput{ID: "migrate-pkg-agentic-abc123", Status: "verified"}
|
|
type PlanUpdateInput struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Objective string `json:"objective,omitempty"`
|
|
Phases []Phase `json:"phases,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Agent string `json:"agent,omitempty"`
|
|
}
|
|
|
|
// PlanUpdateOutput is the output for agentic_plan_update.
|
|
//
|
|
// out := agentic.PlanUpdateOutput{Success: true, Plan: agentic.Plan{Status: "verified"}}
|
|
type PlanUpdateOutput struct {
|
|
Success bool `json:"success"`
|
|
Plan Plan `json:"plan"`
|
|
}
|
|
|
|
// PlanDeleteInput is the input for agentic_plan_delete.
|
|
//
|
|
// input := agentic.PlanDeleteInput{ID: "migrate-pkg-agentic-abc123"}
|
|
type PlanDeleteInput struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// PlanDeleteOutput is the output for agentic_plan_delete.
|
|
//
|
|
// out := agentic.PlanDeleteOutput{Success: true, Deleted: "migrate-pkg-agentic-abc123"}
|
|
type PlanDeleteOutput struct {
|
|
Success bool `json:"success"`
|
|
Deleted string `json:"deleted"`
|
|
}
|
|
|
|
// PlanListInput is the input for agentic_plan_list.
|
|
//
|
|
// input := agentic.PlanListInput{Repo: "go-io", Status: "ready"}
|
|
type PlanListInput struct {
|
|
Status string `json:"status,omitempty"`
|
|
Repo string `json:"repo,omitempty"`
|
|
}
|
|
|
|
// PlanListOutput is the output for agentic_plan_list.
|
|
//
|
|
// out := agentic.PlanListOutput{Success: true, Count: 2, Plans: []agentic.Plan{{ID: "migrate-pkg-agentic-abc123"}}}
|
|
type PlanListOutput struct {
|
|
Success bool `json:"success"`
|
|
Count int `json:"count"`
|
|
Plans []Plan `json:"plans"`
|
|
}
|
|
|
|
// --- Registration ---
|
|
|
|
func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) {
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "agentic_plan_create",
|
|
Description: "Create an implementation plan. Plans track phased work with acceptance criteria, status lifecycle (draft → ready → in_progress → needs_verification → verified → approved), and per-phase progress.",
|
|
}, s.planCreate)
|
|
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "agentic_plan_read",
|
|
Description: "Read an implementation plan by ID. Returns the full plan with all phases, criteria, and status.",
|
|
}, s.planRead)
|
|
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "agentic_plan_update",
|
|
Description: "Update an implementation plan. Supports partial updates — only provided fields are changed. Use this to advance status, update phases, or add notes.",
|
|
}, s.planUpdate)
|
|
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "agentic_plan_delete",
|
|
Description: "Delete an implementation plan by ID. Permanently removes the plan file.",
|
|
}, s.planDelete)
|
|
|
|
mcp.AddTool(server, &mcp.Tool{
|
|
Name: "agentic_plan_list",
|
|
Description: "List implementation plans. Supports filtering by status (draft, ready, in_progress, etc.) and repo.",
|
|
}, s.planList)
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, PlanCreateOutput, error) {
|
|
if input.Title == "" {
|
|
return nil, PlanCreateOutput{}, core.E("planCreate", "title is required", nil)
|
|
}
|
|
if input.Objective == "" {
|
|
return nil, PlanCreateOutput{}, core.E("planCreate", "objective is required", nil)
|
|
}
|
|
|
|
id := generatePlanID(input.Title)
|
|
plan := Plan{
|
|
ID: id,
|
|
Title: input.Title,
|
|
Status: "draft",
|
|
Repo: input.Repo,
|
|
Org: input.Org,
|
|
Objective: input.Objective,
|
|
Phases: input.Phases,
|
|
Notes: input.Notes,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Default phase status to pending
|
|
for i := range plan.Phases {
|
|
if plan.Phases[i].Status == "" {
|
|
plan.Phases[i].Status = "pending"
|
|
}
|
|
if plan.Phases[i].Number == 0 {
|
|
plan.Phases[i].Number = i + 1
|
|
}
|
|
}
|
|
|
|
path, err := writePlan(PlansRoot(), &plan)
|
|
if err != nil {
|
|
return nil, PlanCreateOutput{}, core.E("planCreate", "failed to write plan", err)
|
|
}
|
|
|
|
return nil, PlanCreateOutput{
|
|
Success: true,
|
|
ID: id,
|
|
Path: path,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanReadOutput, error) {
|
|
if input.ID == "" {
|
|
return nil, PlanReadOutput{}, core.E("planRead", "id is required", nil)
|
|
}
|
|
|
|
plan, err := readPlan(PlansRoot(), input.ID)
|
|
if err != nil {
|
|
return nil, PlanReadOutput{}, err
|
|
}
|
|
|
|
return nil, PlanReadOutput{
|
|
Success: true,
|
|
Plan: *plan,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, input PlanUpdateInput) (*mcp.CallToolResult, PlanUpdateOutput, error) {
|
|
if input.ID == "" {
|
|
return nil, PlanUpdateOutput{}, core.E("planUpdate", "id is required", nil)
|
|
}
|
|
|
|
plan, err := readPlan(PlansRoot(), input.ID)
|
|
if err != nil {
|
|
return nil, PlanUpdateOutput{}, err
|
|
}
|
|
|
|
// Apply partial updates
|
|
if input.Status != "" {
|
|
if !validPlanStatus(input.Status) {
|
|
return nil, PlanUpdateOutput{}, core.E("planUpdate", core.Concat("invalid status: ", input.Status, " (valid: draft, ready, in_progress, needs_verification, verified, approved)"), nil)
|
|
}
|
|
plan.Status = input.Status
|
|
}
|
|
if input.Title != "" {
|
|
plan.Title = input.Title
|
|
}
|
|
if input.Objective != "" {
|
|
plan.Objective = input.Objective
|
|
}
|
|
if input.Phases != nil {
|
|
plan.Phases = input.Phases
|
|
}
|
|
if input.Notes != "" {
|
|
plan.Notes = input.Notes
|
|
}
|
|
if input.Agent != "" {
|
|
plan.Agent = input.Agent
|
|
}
|
|
|
|
plan.UpdatedAt = time.Now()
|
|
|
|
if _, err := writePlan(PlansRoot(), plan); err != nil {
|
|
return nil, PlanUpdateOutput{}, core.E("planUpdate", "failed to write plan", err)
|
|
}
|
|
|
|
return nil, PlanUpdateOutput{
|
|
Success: true,
|
|
Plan: *plan,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanDeleteOutput, error) {
|
|
if input.ID == "" {
|
|
return nil, PlanDeleteOutput{}, core.E("planDelete", "id is required", nil)
|
|
}
|
|
|
|
path := planPath(PlansRoot(), input.ID)
|
|
if !fs.Exists(path) {
|
|
return nil, PlanDeleteOutput{}, core.E("planDelete", core.Concat("plan not found: ", input.ID), nil)
|
|
}
|
|
|
|
if r := fs.Delete(path); !r.OK {
|
|
err, _ := r.Value.(error)
|
|
return nil, PlanDeleteOutput{}, core.E("planDelete", "failed to delete plan", err)
|
|
}
|
|
|
|
return nil, PlanDeleteOutput{
|
|
Success: true,
|
|
Deleted: input.ID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, input PlanListInput) (*mcp.CallToolResult, PlanListOutput, error) {
|
|
dir := PlansRoot()
|
|
if r := fs.EnsureDir(dir); !r.OK {
|
|
err, _ := r.Value.(error)
|
|
return nil, PlanListOutput{}, core.E("planList", "failed to access plans directory", err)
|
|
}
|
|
|
|
jsonFiles := core.PathGlob(core.JoinPath(dir, "*.json"))
|
|
|
|
var plans []Plan
|
|
for _, f := range jsonFiles {
|
|
id := core.TrimSuffix(core.PathBase(f), ".json")
|
|
plan, err := readPlan(dir, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Apply filters
|
|
if input.Status != "" && plan.Status != input.Status {
|
|
continue
|
|
}
|
|
if input.Repo != "" && plan.Repo != input.Repo {
|
|
continue
|
|
}
|
|
|
|
plans = append(plans, *plan)
|
|
}
|
|
|
|
return nil, PlanListOutput{
|
|
Success: true,
|
|
Count: len(plans),
|
|
Plans: plans,
|
|
}, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func planPath(dir, id string) string {
|
|
// Sanitise ID to prevent path traversal
|
|
safe := core.PathBase(id)
|
|
if safe == "." || safe == ".." || safe == "" {
|
|
safe = "invalid"
|
|
}
|
|
return core.JoinPath(dir, core.Concat(safe, ".json"))
|
|
}
|
|
|
|
func generatePlanID(title string) string {
|
|
slug := sanitisePlanSlug(title)
|
|
|
|
// Append short random suffix for uniqueness
|
|
b := make([]byte, 3)
|
|
rand.Read(b)
|
|
return core.Concat(slug, "-", hex.EncodeToString(b))
|
|
}
|
|
|
|
func readPlan(dir, id string) (*Plan, error) {
|
|
r := fs.Read(planPath(dir, id))
|
|
if !r.OK {
|
|
return nil, core.E("readPlan", core.Concat("plan not found: ", id), nil)
|
|
}
|
|
|
|
var plan Plan
|
|
if ur := core.JSONUnmarshalString(r.Value.(string), &plan); !ur.OK {
|
|
return nil, core.E("readPlan", core.Concat("failed to parse plan ", id), nil)
|
|
}
|
|
return &plan, nil
|
|
}
|
|
|
|
func writePlan(dir string, plan *Plan) (string, error) {
|
|
if r := fs.EnsureDir(dir); !r.OK {
|
|
err, _ := r.Value.(error)
|
|
return "", core.E("writePlan", "failed to create plans directory", err)
|
|
}
|
|
|
|
path := planPath(dir, plan.ID)
|
|
|
|
if r := fs.Write(path, core.JSONMarshalString(plan)); !r.OK {
|
|
err, _ := r.Value.(error)
|
|
return "", core.E("writePlan", "failed to write plan", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func validPlanStatus(status string) bool {
|
|
switch status {
|
|
case "draft", "ready", "in_progress", "needs_verification", "verified", "approved":
|
|
return true
|
|
}
|
|
return false
|
|
}
|