agent/pkg/agentic/plan.go
Snider 39914fbf14 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00

1185 lines
34 KiB
Go

// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"crypto/rand"
"encoding/hex"
"strconv"
"sync/atomic"
"time"
core "dappco.re/go/core"
coremcp "dappco.re/go/mcp/pkg/mcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// plan := &Plan{ID: "id-1-a3f2b1", Title: "Migrate Core", Status: "draft", Objective: "Replace raw process calls with Core.Process()"}
// r := writePlanResult(PlansRoot(), plan)
type Plan struct {
ID string `json:"id"`
WorkspaceID int `json:"workspace_id,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Status string `json:"status"`
Repo string `json:"repo,omitempty"`
Org string `json:"org,omitempty"`
Objective string `json:"objective"`
Description string `json:"description,omitempty"`
AgentType string `json:"agent_type,omitempty"`
Context map[string]any `json:"context,omitempty"`
TemplateVersionID int `json:"template_version_id,omitempty"`
TemplateVersion PlanTemplateVersion `json:"template_version,omitempty"`
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"`
ArchivedAt time.Time `json:"archived_at,omitempty"`
}
// plan := agentic.AgentPlan{Slug: "ax-follow-up", Title: "AX follow-up"}
type AgentPlan = Plan
// phase := agentic.Phase{Number: 1, Name: "Migrate strings", Status: "in_progress"}
type Phase struct {
AgentPlanID int `json:"agent_plan_id,omitempty"`
Number int `json:"number"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Criteria []string `json:"criteria,omitempty"`
CompletionCriteria []string `json:"completion_criteria,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
Tasks []PlanTask `json:"tasks,omitempty"`
Checkpoints []PhaseCheckpoint `json:"checkpoints,omitempty"`
Tests int `json:"tests,omitempty"`
Notes string `json:"notes,omitempty"`
}
// task := agentic.PlanTask{ID: "1", Title: "Review imports", Status: "pending", File: "pkg/agentic/plan.go", Line: 46}
type PlanTask struct {
ID string `json:"id,omitempty"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority string `json:"priority,omitempty"`
Category string `json:"category,omitempty"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
FileRef string `json:"file_ref,omitempty"`
LineRef int `json:"line_ref,omitempty"`
}
// task := agentic.Task{ID: "1", Title: "Review imports"}
type Task = PlanTask
// checkpoint := agentic.PhaseCheckpoint{Note: "Build passes", CreatedAt: "2026-03-31T00:00:00Z"}
type PhaseCheckpoint struct {
Note string `json:"note"`
Context map[string]any `json:"context,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type PlanCreateInput struct {
Title string `json:"title"`
Slug string `json:"slug,omitempty"`
Objective string `json:"objective,omitempty"`
Description string `json:"description,omitempty"`
Context map[string]any `json:"context,omitempty"`
AgentType string `json:"agent_type,omitempty"`
TemplateVersion PlanTemplateVersion `json:"template_version,omitempty"`
Repo string `json:"repo,omitempty"`
Org string `json:"org,omitempty"`
Phases []Phase `json:"phases,omitempty"`
Notes string `json:"notes,omitempty"`
}
type PlanCreateOutput struct {
Success bool `json:"success"`
ID string `json:"id"`
Path string `json:"path"`
}
type PlanReadInput struct {
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
}
type PlanReadOutput struct {
Success bool `json:"success"`
Plan Plan `json:"plan"`
}
type PlanUpdateInput struct {
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
Status string `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Objective string `json:"objective,omitempty"`
Description string `json:"description,omitempty"`
Context map[string]any `json:"context,omitempty"`
Phases []Phase `json:"phases,omitempty"`
Notes string `json:"notes,omitempty"`
Agent string `json:"agent,omitempty"`
AgentType string `json:"agent_type,omitempty"`
}
type PlanUpdateOutput struct {
Success bool `json:"success"`
Plan Plan `json:"plan"`
}
type PlanDeleteInput struct {
ID string `json:"id,omitempty"`
Slug string `json:"slug,omitempty"`
Reason string `json:"reason,omitempty"`
}
type PlanDeleteOutput struct {
Success bool `json:"success"`
Deleted string `json:"deleted"`
}
type PlanListInput struct {
Status string `json:"status,omitempty"`
Repo string `json:"repo,omitempty"`
Limit int `json:"limit,omitempty"`
}
type PlanListOutput struct {
Success bool `json:"success"`
Count int `json:"count"`
Plans []Plan `json:"plans"`
}
const planListDefaultLimit = 20
var planIDCounter atomic.Uint64
// result := c.Action("plan.create").Run(ctx, core.NewOptions(
//
// core.Option{Key: "title", Value: "AX RFC follow-up"},
// core.Option{Key: "objective", Value: "Register plan actions"},
//
// ))
func (s *PrepSubsystem) handlePlanCreate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planCreate(ctx, nil, PlanCreateInput{
Title: optionStringValue(options, "title"),
Slug: optionStringValue(options, "slug"),
Objective: optionStringValue(options, "objective"),
Description: optionStringValue(options, "description"),
Context: optionAnyMapValue(options, "context"),
AgentType: optionStringValue(options, "agent_type", "agent"),
Repo: optionStringValue(options, "repo"),
Org: optionStringValue(options, "org"),
Phases: planPhasesValue(options, "phases"),
Notes: optionStringValue(options, "notes"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("plan.read").Run(ctx, core.NewOptions(core.Option{Key: "id", Value: "id-42-a3f2b1"}))
func (s *PrepSubsystem) handlePlanRead(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planRead(ctx, nil, PlanReadInput{
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("plan.update").Run(ctx, core.NewOptions(
//
// core.Option{Key: "id", Value: "id-42-a3f2b1"},
// core.Option{Key: "status", Value: "ready"},
//
// ))
func (s *PrepSubsystem) handlePlanUpdate(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planUpdate(ctx, nil, PlanUpdateInput{
ID: optionStringValue(options, "id", "_arg"),
Slug: optionStringValue(options, "slug"),
Status: optionStringValue(options, "status"),
Title: optionStringValue(options, "title"),
Objective: optionStringValue(options, "objective"),
Description: optionStringValue(options, "description"),
Context: optionAnyMapValue(options, "context"),
Phases: planPhasesValue(options, "phases"),
Notes: optionStringValue(options, "notes"),
Agent: optionStringValue(options, "agent"),
AgentType: optionStringValue(options, "agent_type", "agent-type"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
// result := c.Action("plan.delete").Run(ctx, core.NewOptions(core.Option{Key: "id", Value: "id-42-a3f2b1"}))
func (s *PrepSubsystem) handlePlanDelete(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planDelete(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.list").Run(ctx, core.NewOptions(core.Option{Key: "repo", Value: "go-io"}))
func (s *PrepSubsystem) handlePlanList(ctx context.Context, options core.Options) core.Result {
_, output, err := s.planList(ctx, nil, PlanListInput{
Status: optionStringValue(options, "status"),
Repo: optionStringValue(options, "repo"),
Limit: optionIntValue(options, "limit"),
})
if err != nil {
return core.Result{Value: err, OK: false}
}
return core.Result{Value: output, OK: true}
}
func (s *PrepSubsystem) registerPlanTools(svc *coremcp.Service) {
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &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)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &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)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_get",
Description: "Read an implementation plan by slug with progress details and full phases.",
}, s.planGetCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &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)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_delete",
Description: "Delete an implementation plan by ID. Permanently removes the plan file.",
}, s.planDelete)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_list",
Description: "List implementation plans. Supports filtering by status (draft, ready, in_progress, etc.) and repo.",
}, s.planList)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_create",
Description: "Create a plan using the slug-based compatibility surface described by the platform RFC.",
}, s.planCreateCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_read",
Description: "Read a plan using the legacy plain-name MCP alias.",
}, s.planRead)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_get",
Description: "Read a plan by slug with progress details and full phases.",
}, s.planGetCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_list",
Description: "List plans using the compatibility surface with slug and progress summaries.",
}, s.planListCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_check",
Description: "Check whether a plan or phase is complete using the compatibility surface.",
}, s.planCheck)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_check",
Description: "Check whether a plan or phase is complete using the compatibility surface.",
}, s.planCheck)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_update",
Description: "Update a plan using the legacy plain-name MCP alias.",
}, s.planUpdate)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_update_status",
Description: "Update a plan lifecycle status by slug.",
}, s.planUpdateStatusCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_update_status",
Description: "Update a plan lifecycle status by slug.",
}, s.planUpdateStatusCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_delete",
Description: "Delete a plan using the legacy plain-name MCP alias.",
}, s.planDelete)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_archive",
Description: "Archive a plan by slug without deleting the local record.",
}, s.planArchiveCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_archive",
Description: "Archive a plan by slug without deleting the local record.",
}, s.planArchiveCompat)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "plan_from_issue",
Description: "Create an implementation plan from a tracked issue slug or ID.",
}, s.planFromIssue)
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
Name: "agentic_plan_from_issue",
Description: "Create an implementation plan from a tracked issue slug or ID.",
}, s.planFromIssue)
}
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)
}
description := input.Description
if description == "" {
description = input.Objective
}
objective := input.Objective
if objective == "" {
objective = description
}
if objective == "" {
return nil, PlanCreateOutput{}, core.E("planCreate", "objective is required", nil)
}
id := planID()
plan := Plan{
ID: id,
Slug: planSlugValue(input.Slug, input.Title, id),
Title: input.Title,
Status: "draft",
Repo: input.Repo,
Org: input.Org,
Objective: objective,
Description: description,
AgentType: core.Trim(input.AgentType),
Context: input.Context,
TemplateVersion: input.TemplateVersion,
Phases: input.Phases,
Notes: input.Notes,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if plan.AgentType == "" {
plan.AgentType = core.Trim(plan.Agent)
}
plan = normalisePlan(plan)
writeResult := writePlanResult(PlansRoot(), &plan)
if !writeResult.OK {
err, _ := writeResult.Value.(error)
if err == nil {
err = core.E("planCreate", "failed to write plan", nil)
}
return nil, PlanCreateOutput{}, err
}
path, ok := writeResult.Value.(string)
if !ok {
return nil, PlanCreateOutput{}, core.E("planCreate", "invalid plan write result", nil)
}
return nil, PlanCreateOutput{
Success: true,
ID: id,
Path: path,
}, nil
}
func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanReadOutput, error) {
ref := planReference(input.ID, input.Slug)
if ref == "" {
return nil, PlanReadOutput{}, core.E("planRead", "id is required", nil)
}
planResult := readPlanResult(PlansRoot(), ref)
if !planResult.OK {
err, _ := planResult.Value.(error)
if err == nil {
err = core.E("planRead", "failed to read plan", nil)
}
return nil, PlanReadOutput{}, err
}
plan, ok := planResult.Value.(*Plan)
if !ok || plan == nil {
return nil, PlanReadOutput{}, core.E("planRead", "invalid plan payload", nil)
}
return nil, PlanReadOutput{
Success: true,
Plan: *plan,
}, nil
}
func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, input PlanUpdateInput) (*mcp.CallToolResult, PlanUpdateOutput, error) {
ref := planReference(input.ID, input.Slug)
if ref == "" {
return nil, PlanUpdateOutput{}, core.E("planUpdate", "id is required", nil)
}
planResult := readPlanResult(PlansRoot(), ref)
if !planResult.OK {
err, _ := planResult.Value.(error)
if err == nil {
err = core.E("planUpdate", "failed to read plan", nil)
}
return nil, PlanUpdateOutput{}, err
}
plan, ok := planResult.Value.(*Plan)
if !ok || plan == nil {
return nil, PlanUpdateOutput{}, core.E("planUpdate", "invalid plan payload", nil)
}
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.Slug != "" {
plan.Slug = planSlugValue(input.Slug, plan.Title, plan.ID)
}
if input.Objective != "" {
plan.Objective = input.Objective
if plan.Description == "" {
plan.Description = input.Objective
}
}
if input.Description != "" {
plan.Description = input.Description
if plan.Objective == "" || input.Objective == "" {
plan.Objective = input.Description
}
}
if input.Context != nil {
plan.Context = input.Context
}
if input.Phases != nil {
plan.Phases = input.Phases
}
if input.Notes != "" {
plan.Notes = input.Notes
}
if input.Agent != "" {
plan.Agent = input.Agent
}
if input.AgentType != "" {
plan.AgentType = input.AgentType
}
*plan = normalisePlan(*plan)
plan.UpdatedAt = time.Now()
writeResult := writePlanResult(PlansRoot(), plan)
if !writeResult.OK {
err, _ := writeResult.Value.(error)
if err == nil {
err = core.E("planUpdate", "failed to write plan", nil)
}
return nil, PlanUpdateOutput{}, err
}
return nil, PlanUpdateOutput{
Success: true,
Plan: *plan,
}, nil
}
func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanDeleteOutput, error) {
plan, err := deletePlanResult(input, "id is required", "planDelete")
if err != nil {
return nil, PlanDeleteOutput{}, err
}
return nil, PlanDeleteOutput{
Success: true,
Deleted: plan.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)
}
limit := input.Limit
if limit <= 0 {
limit = planListDefaultLimit
}
jsonFiles := core.PathGlob(core.JoinPath(dir, "*.json"))
var plans []Plan
for _, f := range jsonFiles {
id := core.TrimSuffix(core.PathBase(f), ".json")
planResult := readPlanResult(dir, id)
if !planResult.OK {
continue
}
plan, ok := planResult.Value.(*Plan)
if !ok || plan == nil {
continue
}
if input.Status != "" && plan.Status != input.Status {
continue
}
if input.Repo != "" && plan.Repo != input.Repo {
continue
}
plans = append(plans, *plan)
if len(plans) >= limit {
break
}
}
return nil, PlanListOutput{
Success: true,
Count: len(plans),
Plans: plans,
}, nil
}
func planPath(dir, id string) string {
return core.JoinPath(dir, core.Concat(pathKey(id), ".json"))
}
func planPhasesValue(options core.Options, keys ...string) []Phase {
for _, key := range keys {
result := options.Get(key)
if !result.OK {
continue
}
phases := phaseSliceValue(result.Value)
if len(phases) > 0 {
return phases
}
}
return nil
}
func phaseSliceValue(value any) []Phase {
switch typed := value.(type) {
case []Phase:
return typed
case []any:
phases := make([]Phase, 0, len(typed))
for _, item := range typed {
phase, ok := phaseValue(item)
if ok {
phases = append(phases, phase)
}
}
return phases
case string:
trimmed := core.Trim(typed)
if trimmed == "" {
return nil
}
if core.HasPrefix(trimmed, "[") {
var phases []Phase
if result := core.JSONUnmarshalString(trimmed, &phases); result.OK {
return phases
}
if values := anyMapSliceValue(trimmed); len(values) > 0 {
return phaseSliceValue(values)
}
var generic []any
if result := core.JSONUnmarshalString(trimmed, &generic); result.OK {
return phaseSliceValue(generic)
}
}
case []map[string]any:
phases := make([]Phase, 0, len(typed))
for _, item := range typed {
phase, ok := phaseValue(item)
if ok {
phases = append(phases, phase)
}
}
return phases
}
if phase, ok := phaseValue(value); ok {
return []Phase{phase}
}
return nil
}
func phaseValue(value any) (Phase, bool) {
switch typed := value.(type) {
case Phase:
return typed, true
case map[string]any:
criteria := stringSliceValue(typed["criteria"])
completionCriteria := phaseCriteriaValue(typed["completion_criteria"], typed["completion-criteria"])
if len(criteria) == 0 {
criteria = completionCriteria
}
if len(completionCriteria) == 0 {
completionCriteria = criteria
}
return Phase{
Number: intValue(typed["number"]),
Name: stringValue(typed["name"]),
Description: stringValue(typed["description"]),
Status: stringValue(typed["status"]),
Criteria: criteria,
CompletionCriteria: completionCriteria,
Dependencies: phaseDependenciesValue(typed["dependencies"]),
Tasks: planTaskSliceValue(typed["tasks"]),
Checkpoints: phaseCheckpointSliceValue(typed["checkpoints"]),
Tests: intValue(typed["tests"]),
Notes: stringValue(typed["notes"]),
}, true
case map[string]string:
return phaseValue(anyMapValue(typed))
case string:
trimmed := core.Trim(typed)
if trimmed == "" || !core.HasPrefix(trimmed, "{") {
return Phase{}, false
}
if values := anyMapValue(trimmed); len(values) > 0 {
return phaseValue(values)
}
}
return Phase{}, false
}
func phaseDependenciesValue(value any) []string {
switch typed := value.(type) {
case []string:
return cleanStrings(typed)
case []any:
dependencies := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok {
return nil
}
if text = core.Trim(text); text != "" {
dependencies = append(dependencies, text)
}
}
return dependencies
case string:
trimmed := core.Trim(typed)
if trimmed == "" {
return nil
}
if core.HasPrefix(trimmed, "[") {
var dependencies []string
if result := core.JSONUnmarshalString(trimmed, &dependencies); result.OK {
return cleanStrings(dependencies)
}
return nil
}
return cleanStrings(core.Split(trimmed, ","))
default:
if text := stringValue(value); text != "" {
return []string{text}
}
}
return nil
}
func phaseCriteriaValue(values ...any) []string {
for _, value := range values {
criteria := stringSliceValue(value)
if len(criteria) > 0 {
return criteria
}
}
return nil
}
func planID() string {
counter := planIDCounter.Add(1)
suffix := planRandomHex()
return core.Concat("id-", strconv.FormatUint(counter, 10), "-", suffix)
}
func planRandomHex() string {
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
return "000000"
}
return hex.EncodeToString(bytes)
}
func planTaskSliceValue(value any) []PlanTask {
switch typed := value.(type) {
case []PlanTask:
return typed
case []string:
tasks := make([]PlanTask, 0, len(typed))
for _, title := range cleanStrings(typed) {
tasks = append(tasks, PlanTask{Title: title})
}
return tasks
case []any:
tasks := make([]PlanTask, 0, len(typed))
for _, item := range typed {
if task, ok := planTaskValue(item); ok {
tasks = append(tasks, task)
}
}
return tasks
case string:
trimmed := core.Trim(typed)
if trimmed == "" {
return nil
}
if core.HasPrefix(trimmed, "[") {
var tasks []PlanTask
if result := core.JSONUnmarshalString(trimmed, &tasks); result.OK {
return tasks
}
var generic []any
if result := core.JSONUnmarshalString(trimmed, &generic); result.OK {
return planTaskSliceValue(generic)
}
var titles []string
if result := core.JSONUnmarshalString(trimmed, &titles); result.OK {
return planTaskSliceValue(titles)
}
}
case []map[string]any:
tasks := make([]PlanTask, 0, len(typed))
for _, item := range typed {
if task, ok := planTaskValue(item); ok {
tasks = append(tasks, task)
}
}
return tasks
}
if task, ok := planTaskValue(value); ok {
return []PlanTask{task}
}
return nil
}
func planTaskValue(value any) (PlanTask, bool) {
switch typed := value.(type) {
case PlanTask:
return typed, true
case map[string]any:
title := stringValue(typed["title"])
if title == "" {
title = stringValue(typed["name"])
}
file := stringValue(typed["file"])
if file == "" {
file = stringValue(typed["file_ref"])
}
line := intValue(typed["line"])
if line == 0 {
line = intValue(typed["line_ref"])
}
return PlanTask{
ID: stringValue(typed["id"]),
Title: title,
Description: stringValue(typed["description"]),
Priority: stringValue(typed["priority"]),
Category: stringValue(typed["category"]),
Status: stringValue(typed["status"]),
Notes: stringValue(typed["notes"]),
File: file,
Line: line,
FileRef: file,
LineRef: line,
}, title != ""
case map[string]string:
return planTaskValue(anyMapValue(typed))
case string:
title := core.Trim(typed)
if title == "" {
return PlanTask{}, false
}
if core.HasPrefix(title, "{") {
if values := anyMapValue(title); len(values) > 0 {
return planTaskValue(values)
}
}
return PlanTask{Title: title}, true
}
return PlanTask{}, false
}
func phaseCheckpointSliceValue(value any) []PhaseCheckpoint {
switch typed := value.(type) {
case []PhaseCheckpoint:
return typed
case []any:
checkpoints := make([]PhaseCheckpoint, 0, len(typed))
for _, item := range typed {
if checkpoint, ok := phaseCheckpointValue(item); ok {
checkpoints = append(checkpoints, checkpoint)
}
}
return checkpoints
case string:
trimmed := core.Trim(typed)
if trimmed == "" {
return nil
}
if core.HasPrefix(trimmed, "[") {
var checkpoints []PhaseCheckpoint
if result := core.JSONUnmarshalString(trimmed, &checkpoints); result.OK {
return checkpoints
}
var generic []any
if result := core.JSONUnmarshalString(trimmed, &generic); result.OK {
return phaseCheckpointSliceValue(generic)
}
}
case []map[string]any:
checkpoints := make([]PhaseCheckpoint, 0, len(typed))
for _, item := range typed {
if checkpoint, ok := phaseCheckpointValue(item); ok {
checkpoints = append(checkpoints, checkpoint)
}
}
return checkpoints
}
if checkpoint, ok := phaseCheckpointValue(value); ok {
return []PhaseCheckpoint{checkpoint}
}
return nil
}
func phaseCheckpointValue(value any) (PhaseCheckpoint, bool) {
switch typed := value.(type) {
case PhaseCheckpoint:
return typed, typed.Note != ""
case map[string]any:
note := stringValue(typed["note"])
return PhaseCheckpoint{
Note: note,
Context: anyMapValue(typed["context"]),
CreatedAt: stringValue(typed["created_at"]),
}, note != ""
case map[string]string:
return phaseCheckpointValue(anyMapValue(typed))
case string:
note := core.Trim(typed)
if note == "" {
return PhaseCheckpoint{}, false
}
if core.HasPrefix(note, "{") {
if values := anyMapValue(note); len(values) > 0 {
return phaseCheckpointValue(values)
}
}
return PhaseCheckpoint{Note: note}, true
}
return PhaseCheckpoint{}, false
}
func phaseCriteriaList(phase Phase) []string {
criteria := cleanStrings(phase.Criteria)
completionCriteria := cleanStrings(phase.CompletionCriteria)
if len(criteria) == 0 {
return completionCriteria
}
if len(completionCriteria) == 0 {
return criteria
}
merged := make([]string, 0, len(criteria)+len(completionCriteria))
seen := map[string]bool{}
for _, value := range criteria {
if seen[value] {
continue
}
seen[value] = true
merged = append(merged, value)
}
for _, value := range completionCriteria {
if seen[value] {
continue
}
seen[value] = true
merged = append(merged, value)
}
return merged
}
// result := readPlanResult(PlansRoot(), "plan-id")
// if result.OK { plan := result.Value.(*Plan) }
func readPlanResult(dir, id string) core.Result {
path := planPath(dir, id)
r := fs.Read(path)
if r.OK {
return planFromReadResult(r, id)
}
if found := findPlanBySlugResult(dir, id); found.OK {
return found
}
err, _ := r.Value.(error)
if err == nil {
return core.Result{Value: core.E("readPlan", core.Concat("plan not found: ", id), nil), OK: false}
}
return core.Result{Value: core.E("readPlan", core.Concat("plan not found: ", id), err), OK: false}
}
// plan, err := readPlan(PlansRoot(), "plan-id")
func readPlan(dir, id string) (*Plan, error) {
r := readPlanResult(dir, id)
if !r.OK {
err, _ := r.Value.(error)
if err == nil {
return nil, core.E("readPlan", "failed to read plan", nil)
}
return nil, err
}
plan, ok := r.Value.(*Plan)
if !ok || plan == nil {
return nil, core.E("readPlan", "invalid plan payload", nil)
}
return plan, nil
}
// result := writePlanResult(PlansRoot(), plan)
// if result.OK { path := result.Value.(string) }
func writePlanResult(dir string, plan *Plan) core.Result {
if plan == nil {
return core.Result{Value: core.E("writePlan", "plan is required", nil), OK: false}
}
normalised := normalisePlan(*plan)
plan = &normalised
if r := fs.EnsureDir(dir); !r.OK {
err, _ := r.Value.(error)
if err == nil {
return core.Result{Value: core.E("writePlan", "failed to create plans directory", nil), OK: false}
}
return core.Result{Value: core.E("writePlan", "failed to create plans directory", err), OK: false}
}
path := planPath(dir, plan.ID)
if r := fs.WriteAtomic(path, core.JSONMarshalString(plan)); !r.OK {
err, _ := r.Value.(error)
if err == nil {
return core.Result{Value: core.E("writePlan", "failed to write plan", nil), OK: false}
}
return core.Result{Value: core.E("writePlan", "failed to write plan", err), OK: false}
}
return core.Result{Value: path, OK: true}
}
// path, err := writePlan(PlansRoot(), plan)
func writePlan(dir string, plan *Plan) (string, error) {
r := writePlanResult(dir, plan)
if !r.OK {
err, _ := r.Value.(error)
if err == nil {
return "", core.E("writePlan", "failed to write plan", nil)
}
return "", err
}
path, ok := r.Value.(string)
if !ok {
return "", core.E("writePlan", "invalid plan write result", nil)
}
return path, nil
}
func validPlanStatus(status string) bool {
switch status {
case "draft", "ready", "in_progress", "needs_verification", "verified", "approved":
return true
}
return false
}
func normalisePlan(plan Plan) Plan {
if plan.Slug == "" {
plan.Slug = planSlugValue("", plan.Title, plan.ID)
}
if plan.Description == "" {
plan.Description = plan.Objective
}
if plan.Objective == "" {
plan.Objective = plan.Description
}
if plan.AgentType == "" {
plan.AgentType = plan.Agent
}
if plan.Agent == "" {
plan.Agent = plan.AgentType
}
for i := range plan.Phases {
plan.Phases[i] = normalisePhase(plan.Phases[i], i+1)
}
return plan
}
func normalisePhase(phase Phase, number int) Phase {
if phase.Number == 0 {
phase.Number = number
}
if phase.Status == "" {
phase.Status = "pending"
}
criteria := phaseCriteriaList(phase)
phase.Criteria = criteria
phase.CompletionCriteria = criteria
for i := range phase.Tasks {
phase.Tasks[i] = normalisePlanTask(phase.Tasks[i], i+1)
}
for i := range phase.Checkpoints {
if phase.Checkpoints[i].CreatedAt == "" {
phase.Checkpoints[i].CreatedAt = time.Now().Format(time.RFC3339)
}
}
return phase
}
func normalisePlanTask(task PlanTask, index int) PlanTask {
if task.ID == "" {
task.ID = core.Sprint(index)
}
if task.Status == "" {
task.Status = "pending"
}
if task.Title == "" {
task.Title = task.Description
}
task.Priority = core.Trim(task.Priority)
task.Category = core.Trim(task.Category)
if task.File == "" {
task.File = task.FileRef
}
if task.FileRef == "" {
task.FileRef = task.File
}
if task.Line == 0 {
task.Line = task.LineRef
}
if task.LineRef == 0 {
task.LineRef = task.Line
}
return task
}
func planReference(id, slug string) string {
if id != "" {
return id
}
return slug
}
func planFromReadResult(result core.Result, ref string) core.Result {
var plan Plan
if ur := core.JSONUnmarshalString(result.Value.(string), &plan); !ur.OK {
err, _ := ur.Value.(error)
if err == nil {
return core.Result{Value: core.E("readPlan", core.Concat("failed to parse plan ", ref), nil), OK: false}
}
return core.Result{Value: core.E("readPlan", core.Concat("failed to parse plan ", ref), err), OK: false}
}
normalised := normalisePlan(plan)
return core.Result{Value: &normalised, OK: true}
}
func findPlanBySlugResult(dir, slug string) core.Result {
ref := core.Trim(slug)
if ref == "" {
return core.Result{Value: core.E("readPlan", "plan not found: invalid", nil), OK: false}
}
for _, path := range core.PathGlob(core.JoinPath(dir, "*.json")) {
result := fs.Read(path)
if !result.OK {
continue
}
planResult := planFromReadResult(result, ref)
if !planResult.OK {
continue
}
plan, ok := planResult.Value.(*Plan)
if !ok || plan == nil {
continue
}
if plan.Slug == ref || plan.ID == ref {
return core.Result{Value: plan, OK: true}
}
}
return core.Result{Value: core.E("readPlan", core.Concat("plan not found: ", ref), nil), OK: false}
}
func planSlugValue(input, title, id string) string {
slug := cleanPlanSlug(input)
if slug != "" {
return slug
}
base := cleanPlanSlug(title)
if base == "" {
base = "plan"
}
suffix := planSlugSuffix(id)
if suffix == "" {
return base
}
return core.Concat(base, "-", suffix)
}
func cleanPlanSlug(value string) string {
slug := core.Lower(core.Trim(value))
if slug == "" {
return ""
}
for _, old := range []string{"/", "\\", "_", ".", ":", ";", ",", " ", "\t", "\n", "\r"} {
slug = core.Replace(slug, old, "-")
}
for core.Contains(slug, "--") {
slug = core.Replace(slug, "--", "-")
}
for core.HasPrefix(slug, "-") {
slug = slug[1:]
}
for core.HasSuffix(slug, "-") {
slug = slug[:len(slug)-1]
}
if slug == "" || slug == "invalid" {
return ""
}
return slug
}
func planSlugSuffix(id string) string {
parts := core.Split(id, "-")
if len(parts) == 0 {
return ""
}
return core.Trim(parts[len(parts)-1])
}