agent/pkg/agentic/plan_compat.go
Virgil 534df4278a feat(agentic): persist template metadata on plans
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:21:03 +00:00

335 lines
9.8 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// out := agentic.PlanCompatibilitySummary{Slug: "my-plan-abc123", Title: "My Plan", Status: "draft", Phases: 3}
type PlanCompatibilitySummary struct {
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
Phases int `json:"phases"`
}
// progress := agentic.PlanProgress{Total: 5, Completed: 2, Percentage: 40}
type PlanProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Percentage int `json:"percentage"`
}
// out := agentic.PlanCompatibilityCreateOutput{Success: true, Plan: agentic.PlanCompatibilitySummary{Slug: "my-plan-abc123"}}
type PlanCompatibilityCreateOutput struct {
Success bool `json:"success"`
Plan PlanCompatibilitySummary `json:"plan"`
}
// out := agentic.PlanCompatibilityGetOutput{Success: true}
type PlanCompatibilityGetOutput struct {
Success bool `json:"success"`
Plan PlanCompatibilityView `json:"plan"`
}
// out := agentic.PlanCompatibilityListOutput{Success: true, Count: 1}
type PlanCompatibilityListOutput struct {
Success bool `json:"success"`
Plans []PlanCompatibilitySummary `json:"plans"`
Count int `json:"count"`
}
// view := agentic.PlanCompatibilityView{Slug: "my-plan-abc123", Title: "My Plan", Status: "active"}
type PlanCompatibilityView struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Progress PlanProgress `json:"progress"`
TemplateVersion PlanTemplateVersion `json:"template_version,omitempty"`
Phases []Phase `json:"phases,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
// input := agentic.PlanStatusUpdateInput{Slug: "my-plan-abc123", Status: "active"}
type PlanStatusUpdateInput struct {
Slug string `json:"slug"`
Status string `json:"status"`
}
// out := agentic.PlanArchiveOutput{Success: true, Archived: "my-plan-abc123"}
type PlanArchiveOutput struct {
Success bool `json:"success"`
Archived string `json:"archived"`
}
// out := agentic.PlanCheckOutput{Success: true, Complete: true}
type PlanCheckOutput struct {
Success bool `json:"success"`
Complete bool `json:"complete"`
Plan PlanCompatibilityView `json:"plan"`
Phase int `json:"phase,omitempty"`
PhaseName string `json:"phase_name,omitempty"`
Pending []string `json:"pending,omitempty"`
}
// result := c.Action("plan.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handlePlanGet(ctx context.Context, options core.Options) core.Result {
return s.handlePlanRead(ctx, options)
}
// result := c.Action("plan.archive").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handlePlanArchive(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planArchiveCompat(ctx, nil, PlanDeleteInput{
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
Reason: optionStringValue(options, "reason"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("plan.update_status").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "my-plan-abc123"}))
func (s *PrepSubsystem) handlePlanUpdateStatus(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planUpdateStatusCompat(ctx, nil, PlanStatusUpdateInput{
Slug: optionStringValue(options, "slug", "_arg"),
Status: optionStringValue(options, "status"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) planCreateCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, PlanCompatibilityCreateOutput, error) {
_, created, err := s.planCreate(ctx, nil, input)
if err != nil {
return nil, PlanCompatibilityCreateOutput{}, err
}
plan, err := readPlan(PlansRoot(), created.ID)
if err != nil {
return nil, PlanCompatibilityCreateOutput{}, err
}
return nil, PlanCompatibilityCreateOutput{
Success: true,
Plan: planCompatibilitySummary(*plan),
}, nil
}
func (s *PrepSubsystem) planGetCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanCompatibilityGetOutput, error) {
if input.Slug == "" && input.ID == "" {
return nil, PlanCompatibilityGetOutput{}, core.E("planGetCompat", "slug is required", nil)
}
_, output, err := s.planRead(ctx, nil, input)
if err != nil {
return nil, PlanCompatibilityGetOutput{}, err
}
return nil, PlanCompatibilityGetOutput{
Success: true,
Plan: planCompatibilityView(output.Plan),
}, nil
}
func (s *PrepSubsystem) planListCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanListInput) (*mcp.CallToolResult, PlanCompatibilityListOutput, error) {
if input.Status != "" {
input.Status = planCompatibilityInputStatus(input.Status)
}
_, output, err := s.planList(ctx, nil, input)
if err != nil {
return nil, PlanCompatibilityListOutput{}, err
}
summaries := make([]PlanCompatibilitySummary, 0, len(output.Plans))
for _, plan := range output.Plans {
summaries = append(summaries, planCompatibilitySummary(plan))
}
return nil, PlanCompatibilityListOutput{
Success: true,
Plans: summaries,
Count: len(summaries),
}, nil
}
func (s *PrepSubsystem) planUpdateStatusCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanStatusUpdateInput) (*mcp.CallToolResult, PlanCompatibilityGetOutput, error) {
if input.Slug == "" {
return nil, PlanCompatibilityGetOutput{}, core.E("planUpdateStatusCompat", "slug is required", nil)
}
if input.Status == "" {
return nil, PlanCompatibilityGetOutput{}, core.E("planUpdateStatusCompat", "status is required", nil)
}
internalStatus := planCompatibilityInputStatus(input.Status)
_, output, err := s.planUpdate(ctx, nil, PlanUpdateInput{
Slug: input.Slug,
Status: internalStatus,
})
if err != nil {
return nil, PlanCompatibilityGetOutput{}, err
}
return nil, PlanCompatibilityGetOutput{
Success: true,
Plan: planCompatibilityView(output.Plan),
}, nil
}
func (s *PrepSubsystem) planArchiveCompat(ctx context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanArchiveOutput, error) {
plan, err := archivePlanResult(input, "slug is required", "planArchiveCompat")
if err != nil {
return nil, PlanArchiveOutput{}, err
}
return nil, PlanArchiveOutput{
Success: true,
Archived: plan.Slug,
}, nil
}
func planCompatibilitySummary(plan Plan) PlanCompatibilitySummary {
return PlanCompatibilitySummary{
Slug: plan.Slug,
Title: plan.Title,
Status: planCompatibilityOutputStatus(plan.Status),
Phases: len(plan.Phases),
}
}
func planCompatibilityView(plan Plan) PlanCompatibilityView {
return PlanCompatibilityView{
Slug: plan.Slug,
Title: plan.Title,
Description: plan.Description,
Status: planCompatibilityOutputStatus(plan.Status),
Progress: planProgress(plan),
TemplateVersion: plan.TemplateVersion,
Phases: plan.Phases,
Context: plan.Context,
}
}
func archiveReasonValue(reason string) string {
trimmed := core.Trim(reason)
if trimmed == "" {
return ""
}
return core.Concat("Archived: ", trimmed)
}
func planProgress(plan Plan) PlanProgress {
total := 0
completed := 0
for _, phase := range plan.Phases {
tasks := phaseTaskList(phase)
if len(tasks) > 0 {
total += len(tasks)
for _, task := range tasks {
if task.Status == "completed" {
completed++
}
}
continue
}
total++
switch phase.Status {
case "completed", "done", "approved":
completed++
}
}
percentage := 0
if total > 0 {
percentage = (completed * 100) / total
}
return PlanProgress{
Total: total,
Completed: completed,
Percentage: percentage,
}
}
func phaseTaskList(phase Phase) []PlanTask {
if len(phase.Tasks) > 0 {
tasks := make([]PlanTask, 0, len(phase.Tasks))
for i := range phase.Tasks {
tasks = append(tasks, normalisePlanTask(phase.Tasks[i], i+1))
}
return tasks
}
if len(phase.Criteria) == 0 {
return nil
}
tasks := make([]PlanTask, 0, len(phase.Criteria))
for index, criterion := range cleanStrings(phase.Criteria) {
tasks = append(tasks, normalisePlanTask(PlanTask{Title: criterion}, index+1))
}
return tasks
}
func planCompatibilityInputStatus(status string) string {
switch status {
case "active":
return "in_progress"
case "completed":
return "approved"
default:
return status
}
}
func planCompatibilityOutputStatus(status string) string {
switch status {
case "in_progress", "needs_verification", "verified":
return "active"
case "approved":
return "completed"
default:
return status
}
}
func archivePlanResult(input PlanDeleteInput, missingMessage, op string) (*Plan, error) {
ref := planReference(input.ID, input.Slug)
if ref == "" {
return nil, core.E(op, missingMessage, nil)
}
plan, err := readPlan(PlansRoot(), ref)
if err != nil {
return nil, err
}
now := time.Now()
plan.Status = "archived"
plan.ArchivedAt = now
plan.UpdatedAt = now
if notes := archiveReasonValue(input.Reason); notes != "" {
plan.Notes = appendPlanNote(plan.Notes, notes)
}
if result := writePlanResult(PlansRoot(), plan); !result.OK {
err, _ := result.Value.(error)
if err == nil {
err = core.E(op, "failed to write plan", nil)
}
return nil, err
}
return plan, nil
}