From cccc02ed64f5980a9fa5c29a988726fb3252bf9e Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 13:58:57 +0000 Subject: [PATCH] feat(agentic): add RFC plan compatibility surfaces Co-Authored-By: Virgil --- pkg/agentic/phase.go | 206 +++++++++++ pkg/agentic/phase_test.go | 72 ++++ pkg/agentic/plan.go | 588 ++++++++++++++++++++++++++------ pkg/agentic/plan_compat.go | 298 ++++++++++++++++ pkg/agentic/plan_compat_test.go | 107 ++++++ pkg/agentic/prep.go | 9 + pkg/agentic/prep_test.go | 7 + pkg/agentic/task.go | 189 ++++++++++ pkg/agentic/task_test.go | 79 +++++ 9 files changed, 1459 insertions(+), 96 deletions(-) create mode 100644 pkg/agentic/phase.go create mode 100644 pkg/agentic/phase_test.go create mode 100644 pkg/agentic/plan_compat.go create mode 100644 pkg/agentic/plan_compat_test.go create mode 100644 pkg/agentic/task.go create mode 100644 pkg/agentic/task_test.go diff --git a/pkg/agentic/phase.go b/pkg/agentic/phase.go new file mode 100644 index 0000000..c762c4b --- /dev/null +++ b/pkg/agentic/phase.go @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "time" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// input := agentic.PhaseGetInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1} +type PhaseGetInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` +} + +// input := agentic.PhaseStatusInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, Status: "completed"} +type PhaseStatusInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// input := agentic.PhaseCheckpointInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, Note: "Build passes"} +type PhaseCheckpointInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` + Note string `json:"note"` + Context map[string]any `json:"context,omitempty"` +} + +// out := agentic.PhaseOutput{Success: true, Phase: agentic.Phase{Number: 1, Name: "Setup"}} +type PhaseOutput struct { + Success bool `json:"success"` + Phase Phase `json:"phase"` +} + +// result := c.Action("phase.get").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"})) +func (s *PrepSubsystem) handlePhaseGet(ctx context.Context, options core.Options) core.Result { + _, output, err := s.phaseGet(ctx, nil, PhaseGetInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("phase.update_status").Run(ctx, core.NewOptions(core.Option{Key: "status", Value: "completed"})) +func (s *PrepSubsystem) handlePhaseUpdateStatus(ctx context.Context, options core.Options) core.Result { + _, output, err := s.phaseUpdateStatus(ctx, nil, PhaseStatusInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + Status: optionStringValue(options, "status"), + Reason: optionStringValue(options, "reason"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("phase.add_checkpoint").Run(ctx, core.NewOptions(core.Option{Key: "note", Value: "Build passes"})) +func (s *PrepSubsystem) handlePhaseAddCheckpoint(ctx context.Context, options core.Options) core.Result { + _, output, err := s.phaseAddCheckpoint(ctx, nil, PhaseCheckpointInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + Note: optionStringValue(options, "note"), + Context: optionAnyMapValue(options, "context"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerPhaseTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "phase_get", + Description: "Get a phase by plan slug and phase order.", + }, s.phaseGet) + + mcp.AddTool(server, &mcp.Tool{ + Name: "phase_update_status", + Description: "Update a phase status by plan slug and phase order.", + }, s.phaseUpdateStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "phase_add_checkpoint", + Description: "Append a checkpoint note to a phase.", + }, s.phaseAddCheckpoint) +} + +func (s *PrepSubsystem) phaseGet(_ context.Context, _ *mcp.CallToolRequest, input PhaseGetInput) (*mcp.CallToolResult, PhaseOutput, error) { + plan, phaseIndex, err := planPhaseByOrder(PlansRoot(), input.PlanSlug, input.PhaseOrder) + if err != nil { + return nil, PhaseOutput{}, err + } + + return nil, PhaseOutput{ + Success: true, + Phase: plan.Phases[phaseIndex], + }, nil +} + +func (s *PrepSubsystem) phaseUpdateStatus(_ context.Context, _ *mcp.CallToolRequest, input PhaseStatusInput) (*mcp.CallToolResult, PhaseOutput, error) { + if !validPhaseStatus(input.Status) { + return nil, PhaseOutput{}, core.E("phaseUpdateStatus", core.Concat("invalid status: ", input.Status), nil) + } + + plan, phaseIndex, err := planPhaseByOrder(PlansRoot(), input.PlanSlug, input.PhaseOrder) + if err != nil { + return nil, PhaseOutput{}, err + } + + plan.Phases[phaseIndex].Status = input.Status + if reason := core.Trim(input.Reason); reason != "" { + plan.Phases[phaseIndex].Notes = appendPlanNote(plan.Phases[phaseIndex].Notes, reason) + } + plan.UpdatedAt = time.Now() + + if result := writePlanResult(PlansRoot(), plan); !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("phaseUpdateStatus", "failed to write plan", nil) + } + return nil, PhaseOutput{}, err + } + + return nil, PhaseOutput{ + Success: true, + Phase: plan.Phases[phaseIndex], + }, nil +} + +func (s *PrepSubsystem) phaseAddCheckpoint(_ context.Context, _ *mcp.CallToolRequest, input PhaseCheckpointInput) (*mcp.CallToolResult, PhaseOutput, error) { + if core.Trim(input.Note) == "" { + return nil, PhaseOutput{}, core.E("phaseAddCheckpoint", "note is required", nil) + } + + plan, phaseIndex, err := planPhaseByOrder(PlansRoot(), input.PlanSlug, input.PhaseOrder) + if err != nil { + return nil, PhaseOutput{}, err + } + + plan.Phases[phaseIndex].Checkpoints = append(plan.Phases[phaseIndex].Checkpoints, PhaseCheckpoint{ + Note: input.Note, + Context: input.Context, + CreatedAt: time.Now().Format(time.RFC3339), + }) + plan.UpdatedAt = time.Now() + + if result := writePlanResult(PlansRoot(), plan); !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("phaseAddCheckpoint", "failed to write plan", nil) + } + return nil, PhaseOutput{}, err + } + + return nil, PhaseOutput{ + Success: true, + Phase: plan.Phases[phaseIndex], + }, nil +} + +func planPhaseByOrder(dir, planSlug string, phaseOrder int) (*Plan, int, error) { + if core.Trim(planSlug) == "" { + return nil, 0, core.E("planPhaseByOrder", "plan_slug is required", nil) + } + if phaseOrder <= 0 { + return nil, 0, core.E("planPhaseByOrder", "phase_order is required", nil) + } + + plan, err := readPlan(dir, planSlug) + if err != nil { + return nil, 0, err + } + + for index := range plan.Phases { + if plan.Phases[index].Number == phaseOrder { + return plan, index, nil + } + } + + return nil, 0, core.E("planPhaseByOrder", core.Concat("phase not found: ", core.Sprint(phaseOrder)), nil) +} + +func appendPlanNote(existing, note string) string { + if existing == "" { + return note + } + return core.Concat(existing, "\n", note) +} + +func validPhaseStatus(status string) bool { + switch status { + case "pending", "in_progress", "completed", "blocked", "skipped": + return true + } + return false +} diff --git a/pkg/agentic/phase_test.go b/pkg/agentic/phase_test.go new file mode 100644 index 0000000..cfded71 --- /dev/null +++ b/pkg/agentic/phase_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPhase_PhaseGet_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Phase Get", + Description: "Read phase", + Phases: []Phase{{Number: 1, Name: "Setup"}}, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.phaseGet(context.Background(), nil, PhaseGetInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "Setup", output.Phase.Name) +} + +func TestPhase_PhaseUpdateStatus_Bad_InvalidStatus(t *testing.T) { + s := newTestPrep(t) + _, _, err := s.phaseUpdateStatus(context.Background(), nil, PhaseStatusInput{ + PlanSlug: "my-plan", + PhaseOrder: 1, + Status: "invalid", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid status") +} + +func TestPhase_PhaseAddCheckpoint_Ugly_AppendsCheckpoint(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Checkpoint Phase", + Description: "Append checkpoint", + Phases: []Phase{{Number: 1, Name: "Setup"}}, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.phaseAddCheckpoint(context.Background(), nil, PhaseCheckpointInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + Note: "Build passes", + }) + require.NoError(t, err) + assert.True(t, output.Success) + require.Len(t, output.Phase.Checkpoints, 1) + assert.Equal(t, "Build passes", output.Phase.Checkpoints[0].Note) +} diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index cde26f4..0f81eec 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -13,36 +13,61 @@ import ( // 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"` - Title string `json:"title"` - Status string `json:"status"` - 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"` + ID string `json:"id"` + 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"` + Context map[string]any `json:"context,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"` } // 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"` - Criteria []string `json:"criteria,omitempty"` - Tests int `json:"tests,omitempty"` - Notes string `json:"notes,omitempty"` + Number int `json:"number"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Criteria []string `json:"criteria,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"} +type PlanTask struct { + ID string `json:"id,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` +} + +// 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"` - Objective string `json:"objective"` - Repo string `json:"repo,omitempty"` - Org string `json:"org,omitempty"` - Phases []Phase `json:"phases,omitempty"` - Notes string `json:"notes,omitempty"` + 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"` + Repo string `json:"repo,omitempty"` + Org string `json:"org,omitempty"` + Phases []Phase `json:"phases,omitempty"` + Notes string `json:"notes,omitempty"` } type PlanCreateOutput struct { @@ -52,7 +77,8 @@ type PlanCreateOutput struct { } type PlanReadInput struct { - ID string `json:"id"` + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` } type PlanReadOutput struct { @@ -61,13 +87,16 @@ type PlanReadOutput struct { } 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"` + 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"` } type PlanUpdateOutput struct { @@ -76,7 +105,9 @@ type PlanUpdateOutput struct { } type PlanDeleteInput struct { - ID string `json:"id"` + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + Reason string `json:"reason,omitempty"` } type PlanDeleteOutput struct { @@ -87,6 +118,7 @@ type PlanDeleteOutput struct { type PlanListInput struct { Status string `json:"status,omitempty"` Repo string `json:"repo,omitempty"` + Limit int `json:"limit,omitempty"` } type PlanListOutput struct { @@ -103,12 +135,15 @@ type PlanListOutput struct { // )) func (s *PrepSubsystem) handlePlanCreate(ctx context.Context, options core.Options) core.Result { _, output, err := s.planCreate(ctx, nil, PlanCreateInput{ - Title: optionStringValue(options, "title"), - Objective: optionStringValue(options, "objective"), - Repo: optionStringValue(options, "repo"), - Org: optionStringValue(options, "org"), - Phases: planPhasesValue(options, "phases"), - Notes: optionStringValue(options, "notes"), + Title: optionStringValue(options, "title"), + Slug: optionStringValue(options, "slug"), + Objective: optionStringValue(options, "objective"), + Description: optionStringValue(options, "description"), + Context: optionAnyMapValue(options, "context"), + 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} @@ -119,7 +154,8 @@ func (s *PrepSubsystem) handlePlanCreate(ctx context.Context, options core.Optio // 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"), + ID: optionStringValue(options, "id", "_arg"), + Slug: optionStringValue(options, "slug"), }) if err != nil { return core.Result{Value: err, OK: false} @@ -135,13 +171,16 @@ func (s *PrepSubsystem) handlePlanRead(ctx context.Context, options core.Options // )) func (s *PrepSubsystem) handlePlanUpdate(ctx context.Context, options core.Options) core.Result { _, output, err := s.planUpdate(ctx, nil, PlanUpdateInput{ - ID: optionStringValue(options, "id"), - Status: optionStringValue(options, "status"), - Title: optionStringValue(options, "title"), - Objective: optionStringValue(options, "objective"), - Phases: planPhasesValue(options, "phases"), - Notes: optionStringValue(options, "notes"), - Agent: optionStringValue(options, "agent"), + 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"), }) if err != nil { return core.Result{Value: err, OK: false} @@ -152,7 +191,9 @@ func (s *PrepSubsystem) handlePlanUpdate(ctx context.Context, options core.Optio // 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"), + ID: optionStringValue(options, "id", "_arg"), + Slug: optionStringValue(options, "slug"), + Reason: optionStringValue(options, "reason"), }) if err != nil { return core.Result{Value: err, OK: false} @@ -165,6 +206,7 @@ func (s *PrepSubsystem) handlePlanList(ctx context.Context, options core.Options _, 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} @@ -197,38 +239,66 @@ func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) { Name: "agentic_plan_list", Description: "List implementation plans. Supports filtering by status (draft, ready, in_progress, etc.) and repo.", }, s.planList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "plan_create", + Description: "Create a plan using the slug-based compatibility surface described by the platform RFC.", + }, s.planCreateCompat) + + mcp.AddTool(server, &mcp.Tool{ + Name: "plan_get", + Description: "Read a plan by slug with progress details and full phases.", + }, s.planGetCompat) + + mcp.AddTool(server, &mcp.Tool{ + Name: "plan_list", + Description: "List plans using the compatibility surface with slug and progress summaries.", + }, s.planListCompat) + + mcp.AddTool(server, &mcp.Tool{ + Name: "plan_update_status", + Description: "Update a plan lifecycle status by slug.", + }, s.planUpdateStatusCompat) + + mcp.AddTool(server, &mcp.Tool{ + Name: "plan_archive", + Description: "Archive a plan by slug without deleting the local record.", + }, s.planArchiveCompat) } 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 == "" { + 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 := core.ID() 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(), - } - - 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 - } + ID: id, + Slug: planSlugValue(input.Slug, input.Title, id), + Title: input.Title, + Status: "draft", + Repo: input.Repo, + Org: input.Org, + Objective: objective, + Description: description, + Context: input.Context, + Phases: input.Phases, + Notes: input.Notes, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } + plan = normalisePlan(plan) writeResult := writePlanResult(PlansRoot(), &plan) if !writeResult.OK { @@ -251,11 +321,12 @@ func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, in } func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanReadOutput, error) { - if input.ID == "" { + ref := planReference(input.ID, input.Slug) + if ref == "" { return nil, PlanReadOutput{}, core.E("planRead", "id is required", nil) } - planResult := readPlanResult(PlansRoot(), input.ID) + planResult := readPlanResult(PlansRoot(), ref) if !planResult.OK { err, _ := planResult.Value.(error) if err == nil { @@ -275,11 +346,12 @@ func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, inpu } func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, input PlanUpdateInput) (*mcp.CallToolResult, PlanUpdateOutput, error) { - if input.ID == "" { + ref := planReference(input.ID, input.Slug) + if ref == "" { return nil, PlanUpdateOutput{}, core.E("planUpdate", "id is required", nil) } - planResult := readPlanResult(PlansRoot(), input.ID) + planResult := readPlanResult(PlansRoot(), ref) if !planResult.OK { err, _ := planResult.Value.(error) if err == nil { @@ -301,8 +373,23 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in 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 @@ -314,6 +401,7 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in plan.Agent = input.Agent } + *plan = normalisePlan(*plan) plan.UpdatedAt = time.Now() writeResult := writePlanResult(PlansRoot(), plan) @@ -332,13 +420,19 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in } func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanDeleteOutput, error) { - if input.ID == "" { + ref := planReference(input.ID, input.Slug) + if ref == "" { return nil, PlanDeleteOutput{}, core.E("planDelete", "id is required", nil) } - path := planPath(PlansRoot(), input.ID) + plan, err := readPlan(PlansRoot(), ref) + if err != nil { + return nil, PlanDeleteOutput{}, err + } + + path := planPath(PlansRoot(), plan.ID) if !fs.Exists(path) { - return nil, PlanDeleteOutput{}, core.E("planDelete", core.Concat("plan not found: ", input.ID), nil) + return nil, PlanDeleteOutput{}, core.E("planDelete", core.Concat("plan not found: ", ref), nil) } if r := fs.Delete(path); !r.OK { @@ -348,7 +442,7 @@ func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, in return nil, PlanDeleteOutput{ Success: true, - Deleted: input.ID, + Deleted: plan.ID, }, nil } @@ -381,6 +475,9 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu } plans = append(plans, *plan) + if input.Limit > 0 && len(plans) >= input.Limit { + break + } } return nil, PlanListOutput{ @@ -462,12 +559,15 @@ func phaseValue(value any) (Phase, bool) { return typed, true case map[string]any: return Phase{ - Number: intValue(typed["number"]), - Name: stringValue(typed["name"]), - Status: stringValue(typed["status"]), - Criteria: stringSliceValue(typed["criteria"]), - Tests: intValue(typed["tests"]), - Notes: stringValue(typed["notes"]), + Number: intValue(typed["number"]), + Name: stringValue(typed["name"]), + Description: stringValue(typed["description"]), + Status: stringValue(typed["status"]), + Criteria: stringSliceValue(typed["criteria"]), + 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)) @@ -483,27 +583,179 @@ func phaseValue(value any) (Phase, bool) { return Phase{}, false } +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"]) + } + return PlanTask{ + ID: stringValue(typed["id"]), + Title: title, + Description: stringValue(typed["description"]), + Status: stringValue(typed["status"]), + Notes: stringValue(typed["notes"]), + }, 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 +} + // result := readPlanResult(PlansRoot(), "plan-id") // if result.OK { plan := result.Value.(*Plan) } func readPlanResult(dir, id string) core.Result { - r := fs.Read(planPath(dir, id)) - if !r.OK { - 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} + path := planPath(dir, id) + r := fs.Read(path) + if r.OK { + return planFromReadResult(r, id) } - var plan Plan - if ur := core.JSONUnmarshalString(r.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 ", id), nil), OK: false} - } - return core.Result{Value: core.E("readPlan", core.Concat("failed to parse plan ", id), err), OK: false} + if found := findPlanBySlugResult(dir, id); found.OK { + return found } - return core.Result{Value: &plan, OK: true} + + 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") @@ -529,6 +781,8 @@ 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 { @@ -573,3 +827,145 @@ func validPlanStatus(status string) bool { } 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 + } + 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" + } + 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 + } + 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]) +} diff --git a/pkg/agentic/plan_compat.go b/pkg/agentic/plan_compat.go new file mode 100644 index 0000000..4899f1a --- /dev/null +++ b/pkg/agentic/plan_compat.go @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + + 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"` + 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"` +} + +// 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} +} + +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) { + ref := planReference(input.ID, input.Slug) + if ref == "" { + return nil, PlanArchiveOutput{}, core.E("planArchiveCompat", "slug is required", nil) + } + + plan, err := readPlan(PlansRoot(), ref) + if err != nil { + return nil, PlanArchiveOutput{}, err + } + + plan.Status = "archived" + 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("planArchiveCompat", "failed to write plan", 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), + 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 + } +} diff --git a/pkg/agentic/plan_compat_test.go b/pkg/agentic/plan_compat_test.go new file mode 100644 index 0000000..8d24956 --- /dev/null +++ b/pkg/agentic/plan_compat_test.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlancompat_PlanCreateCompat_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, output, err := s.planCreateCompat(context.Background(), nil, PlanCreateInput{ + Title: "Compatibility Plan", + Description: "Match the slug-first RFC surface", + Phases: []Phase{ + {Name: "Setup", Tasks: []PlanTask{{Title: "Review RFC"}}}, + }, + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Contains(t, output.Plan.Slug, "compatibility-plan") + assert.Equal(t, "draft", output.Plan.Status) + assert.Equal(t, 1, output.Plan.Phases) +} + +func TestPlancompat_PlanGetCompat_Good_BySlug(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Get Compat", + Description: "Read by slug", + Phases: []Phase{ + {Name: "Setup", Tasks: []PlanTask{{Title: "Review RFC", Status: "completed"}, {Title: "Patch code"}}}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.planGetCompat(context.Background(), nil, PlanReadInput{Slug: plan.Slug}) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, plan.Slug, output.Plan.Slug) + assert.Equal(t, 2, output.Plan.Progress.Total) + assert.Equal(t, 1, output.Plan.Progress.Completed) + assert.Equal(t, 50, output.Plan.Progress.Percentage) +} + +func TestPlancompat_PlanUpdateStatusCompat_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Status Compat", + Description: "Update by slug", + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.planUpdateStatusCompat(context.Background(), nil, PlanStatusUpdateInput{ + Slug: plan.Slug, + Status: "active", + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "active", output.Plan.Status) +} + +func TestPlancompat_PlanArchiveCompat_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Archive Compat", + Description: "Archive by slug", + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.planArchiveCompat(context.Background(), nil, PlanDeleteInput{ + Slug: plan.Slug, + Reason: "No longer needed", + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, plan.Slug, output.Archived) + + archivedPlan, err := readPlan(PlansRoot(), plan.Slug) + require.NoError(t, err) + assert.Equal(t, "archived", archivedPlan.Status) + assert.Contains(t, archivedPlan.Notes, "No longer needed") +} diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 401f3a8..e58995c 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -174,10 +174,17 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agentic.epic", s.handleEpic).Description = "Create sub-issues from an epic plan" c.Action("plan.create", s.handlePlanCreate).Description = "Create a structured implementation plan" + c.Action("plan.get", s.handlePlanGet).Description = "Read an implementation plan by ID or slug" c.Action("plan.read", s.handlePlanRead).Description = "Read an implementation plan by ID" c.Action("plan.update", s.handlePlanUpdate).Description = "Update plan status, phases, notes, or agent assignment" + c.Action("plan.archive", s.handlePlanArchive).Description = "Archive an implementation plan by slug" c.Action("plan.delete", s.handlePlanDelete).Description = "Delete an implementation plan by ID" c.Action("plan.list", s.handlePlanList).Description = "List implementation plans with optional filters" + c.Action("phase.get", s.handlePhaseGet).Description = "Read a plan phase by slug and order" + c.Action("phase.update_status", s.handlePhaseUpdateStatus).Description = "Update plan phase status by slug and order" + c.Action("phase.add_checkpoint", s.handlePhaseAddCheckpoint).Description = "Append a checkpoint note to a plan phase" + c.Action("task.update", s.handleTaskUpdate).Description = "Update a plan task by slug, phase, and identifier" + c.Action("task.toggle", s.handleTaskToggle).Description = "Toggle a plan task between pending and completed" c.Action("session.start", s.handleSessionStart).Description = "Start an agent session for a plan" c.Action("session.get", s.handleSessionGet).Description = "Read a session by session ID" c.Action("session.list", s.handleSessionList).Description = "List sessions with optional plan or status filters" @@ -295,6 +302,8 @@ func (s *PrepSubsystem) RegisterTools(server *mcp.Server) { s.registerShutdownTools(server) s.registerSessionTools(server) s.registerStateTools(server) + s.registerPhaseTools(server) + s.registerTaskTools(server) mcp.AddTool(server, &mcp.Tool{ Name: "agentic_scan", diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index ba6daad..e38231f 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -441,10 +441,17 @@ func TestPrep_OnStartup_Good_RegistersPlanActions(t *testing.T) { require.True(t, s.OnStartup(context.Background()).OK) assert.True(t, c.Action("plan.create").Exists()) + assert.True(t, c.Action("plan.get").Exists()) assert.True(t, c.Action("plan.read").Exists()) assert.True(t, c.Action("plan.update").Exists()) + assert.True(t, c.Action("plan.archive").Exists()) assert.True(t, c.Action("plan.delete").Exists()) assert.True(t, c.Action("plan.list").Exists()) + assert.True(t, c.Action("phase.get").Exists()) + assert.True(t, c.Action("phase.update_status").Exists()) + assert.True(t, c.Action("phase.add_checkpoint").Exists()) + assert.True(t, c.Action("task.update").Exists()) + assert.True(t, c.Action("task.toggle").Exists()) } func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { diff --git a/pkg/agentic/task.go b/pkg/agentic/task.go new file mode 100644 index 0000000..6cbe43a --- /dev/null +++ b/pkg/agentic/task.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "time" + + core "dappco.re/go/core" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// input := agentic.TaskUpdateInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: "1"} +type TaskUpdateInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` + TaskIdentifier any `json:"task_identifier"` + Status string `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` +} + +// input := agentic.TaskToggleInput{PlanSlug: "my-plan-abc123", PhaseOrder: 1, TaskIdentifier: 1} +type TaskToggleInput struct { + PlanSlug string `json:"plan_slug"` + PhaseOrder int `json:"phase_order"` + TaskIdentifier any `json:"task_identifier"` +} + +// out := agentic.TaskOutput{Success: true, Task: agentic.PlanTask{ID: "1", Title: "Review imports"}} +type TaskOutput struct { + Success bool `json:"success"` + Task PlanTask `json:"task"` +} + +// result := c.Action("task.update").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"})) +func (s *PrepSubsystem) handleTaskUpdate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.taskUpdate(ctx, nil, TaskUpdateInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + TaskIdentifier: optionAnyValue(options, "task_identifier", "task"), + Status: optionStringValue(options, "status"), + Notes: optionStringValue(options, "notes"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("task.toggle").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "my-plan-abc123"})) +func (s *PrepSubsystem) handleTaskToggle(ctx context.Context, options core.Options) core.Result { + _, output, err := s.taskToggle(ctx, nil, TaskToggleInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan", "slug"), + PhaseOrder: optionIntValue(options, "phase_order", "phase"), + TaskIdentifier: optionAnyValue(options, "task_identifier", "task"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) registerTaskTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "task_update", + Description: "Update a plan task status or notes by plan slug, phase order, and task identifier.", + }, s.taskUpdate) + + mcp.AddTool(server, &mcp.Tool{ + Name: "task_toggle", + Description: "Toggle a plan task between pending and completed.", + }, s.taskToggle) +} + +func (s *PrepSubsystem) taskUpdate(_ context.Context, _ *mcp.CallToolRequest, input TaskUpdateInput) (*mcp.CallToolResult, TaskOutput, error) { + if input.Status != "" && !validTaskStatus(input.Status) { + return nil, TaskOutput{}, core.E("taskUpdate", core.Concat("invalid status: ", input.Status), nil) + } + if taskIdentifierValue(input.TaskIdentifier) == "" { + return nil, TaskOutput{}, core.E("taskUpdate", "task_identifier is required", nil) + } + + plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier) + if err != nil { + return nil, TaskOutput{}, err + } + + if input.Status != "" { + plan.Phases[phaseIndex].Tasks[taskIndex].Status = input.Status + } + if notes := core.Trim(input.Notes); notes != "" { + plan.Phases[phaseIndex].Tasks[taskIndex].Notes = notes + } + plan.UpdatedAt = time.Now() + + if result := writePlanResult(PlansRoot(), plan); !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("taskUpdate", "failed to write plan", nil) + } + return nil, TaskOutput{}, err + } + + return nil, TaskOutput{ + Success: true, + Task: plan.Phases[phaseIndex].Tasks[taskIndex], + }, nil +} + +func (s *PrepSubsystem) taskToggle(_ context.Context, _ *mcp.CallToolRequest, input TaskToggleInput) (*mcp.CallToolResult, TaskOutput, error) { + if taskIdentifierValue(input.TaskIdentifier) == "" { + return nil, TaskOutput{}, core.E("taskToggle", "task_identifier is required", nil) + } + + plan, phaseIndex, taskIndex, err := planTaskByIdentifier(PlansRoot(), input.PlanSlug, input.PhaseOrder, input.TaskIdentifier) + if err != nil { + return nil, TaskOutput{}, err + } + + status := plan.Phases[phaseIndex].Tasks[taskIndex].Status + if status == "completed" { + plan.Phases[phaseIndex].Tasks[taskIndex].Status = "pending" + } else { + plan.Phases[phaseIndex].Tasks[taskIndex].Status = "completed" + } + plan.UpdatedAt = time.Now() + + if result := writePlanResult(PlansRoot(), plan); !result.OK { + err, _ := result.Value.(error) + if err == nil { + err = core.E("taskToggle", "failed to write plan", nil) + } + return nil, TaskOutput{}, err + } + + return nil, TaskOutput{ + Success: true, + Task: plan.Phases[phaseIndex].Tasks[taskIndex], + }, nil +} + +func planTaskByIdentifier(dir, planSlug string, phaseOrder int, taskIdentifier any) (*Plan, int, int, error) { + plan, phaseIndex, err := planPhaseByOrder(dir, planSlug, phaseOrder) + if err != nil { + return nil, 0, 0, err + } + + tasks := phaseTaskList(plan.Phases[phaseIndex]) + if len(tasks) == 0 { + return nil, 0, 0, core.E("planTaskByIdentifier", "phase has no tasks", nil) + } + plan.Phases[phaseIndex].Tasks = tasks + + identifier := taskIdentifierValue(taskIdentifier) + if identifier == "" { + return nil, 0, 0, core.E("planTaskByIdentifier", "task_identifier is required", nil) + } + + for index := range plan.Phases[phaseIndex].Tasks { + task := plan.Phases[phaseIndex].Tasks[index] + if task.ID == identifier || task.Title == identifier || core.Sprint(index+1) == identifier { + return plan, phaseIndex, index, nil + } + } + + return nil, 0, 0, core.E("planTaskByIdentifier", core.Concat("task not found: ", identifier), nil) +} + +func taskIdentifierValue(value any) string { + switch typed := value.(type) { + case string: + return core.Trim(typed) + case int: + return core.Sprint(typed) + case int64: + return core.Sprint(typed) + case float64: + return core.Sprint(int(typed)) + } + return "" +} + +func validTaskStatus(status string) bool { + switch status { + case "pending", "completed": + return true + } + return false +} diff --git a/pkg/agentic/task_test.go b/pkg/agentic/task_test.go new file mode 100644 index 0000000..611d565 --- /dev/null +++ b/pkg/agentic/task_test.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTask_TaskUpdate_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Task Update", + Description: "Update task by identifier", + Phases: []Phase{ + {Name: "Setup", Tasks: []PlanTask{{ID: "1", Title: "Review RFC"}}}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.taskUpdate(context.Background(), nil, TaskUpdateInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + TaskIdentifier: "1", + Status: "completed", + Notes: "Done", + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "completed", output.Task.Status) + assert.Equal(t, "Done", output.Task.Notes) +} + +func TestTask_TaskToggle_Bad_MissingIdentifier(t *testing.T) { + s := newTestPrep(t) + _, _, err := s.taskToggle(context.Background(), nil, TaskToggleInput{ + PlanSlug: "my-plan", + PhaseOrder: 1, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "task_identifier is required") +} + +func TestTask_TaskToggle_Ugly_CriteriaFallback(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Task Toggle", + Description: "Toggle criteria-derived task", + Phases: []Phase{ + {Name: "Setup", Criteria: []string{"Review RFC"}}, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + _, output, err := s.taskToggle(context.Background(), nil, TaskToggleInput{ + PlanSlug: plan.Slug, + PhaseOrder: 1, + TaskIdentifier: 1, + }) + require.NoError(t, err) + assert.True(t, output.Success) + assert.Equal(t, "completed", output.Task.Status) + assert.Equal(t, "Review RFC", output.Task.Title) +}