feat(agentic): add RFC plan compatibility surfaces
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
c27af1435d
commit
cccc02ed64
9 changed files with 1459 additions and 96 deletions
206
pkg/agentic/phase.go
Normal file
206
pkg/agentic/phase.go
Normal file
|
|
@ -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
|
||||
}
|
||||
72
pkg/agentic/phase_test.go
Normal file
72
pkg/agentic/phase_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
298
pkg/agentic/plan_compat.go
Normal file
298
pkg/agentic/plan_compat.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
107
pkg/agentic/plan_compat_test.go
Normal file
107
pkg/agentic/plan_compat_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
189
pkg/agentic/task.go
Normal file
189
pkg/agentic/task.go
Normal file
|
|
@ -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
|
||||
}
|
||||
79
pkg/agentic/task_test.go
Normal file
79
pkg/agentic/task_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue