diff --git a/pkg/agentic/commands_plan.go b/pkg/agentic/commands_plan.go index 5478ddb..cd8a770 100644 --- a/pkg/agentic/commands_plan.go +++ b/pkg/agentic/commands_plan.go @@ -13,6 +13,7 @@ func (s *PrepSubsystem) registerPlanCommands() { c.Command("plan/list", core.Command{Description: "List implementation plans", Action: s.cmdPlanList}) c.Command("plan/show", core.Command{Description: "Show an implementation plan", Action: s.cmdPlanShow}) c.Command("plan/status", core.Command{Description: "Read or update an implementation plan status", Action: s.cmdPlanStatus}) + c.Command("plan/check", core.Command{Description: "Check whether a plan or phase is complete", Action: s.cmdPlanCheck}) c.Command("plan/archive", core.Command{Description: "Archive an implementation plan by slug or ID", Action: s.cmdPlanArchive}) c.Command("plan/delete", core.Command{Description: "Delete an implementation plan by ID", Action: s.cmdPlanDelete}) } @@ -166,6 +167,46 @@ func (s *PrepSubsystem) cmdPlanStatus(options core.Options) core.Result { return core.Result{Value: output, OK: true} } +func (s *PrepSubsystem) cmdPlanCheck(options core.Options) core.Result { + ctx := s.commandContext() + slug := optionStringValue(options, "slug", "_arg") + if slug == "" { + core.Print(nil, "usage: core-agent plan check [--phase=1]") + return core.Result{Value: core.E("agentic.cmdPlanCheck", "slug is required", nil), OK: false} + } + + phaseOrder := optionIntValue(options, "phase", "phase_order") + _, output, err := s.planGetCompat(ctx, nil, PlanReadInput{Slug: slug}) + if err != nil { + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + check := planCheckOutput(output.Plan, phaseOrder) + core.Print(nil, "slug: %s", check.Plan.Slug) + core.Print(nil, "status: %s", check.Plan.Status) + core.Print(nil, "progress: %d/%d (%d%%)", check.Plan.Progress.Completed, check.Plan.Progress.Total, check.Plan.Progress.Percentage) + if check.Phase > 0 { + core.Print(nil, "phase: %d %s", check.Phase, check.PhaseName) + } + if len(check.Pending) > 0 { + core.Print(nil, "pending:") + for _, item := range check.Pending { + core.Print(nil, " - %s", item) + } + } + if check.Complete { + core.Print(nil, "complete") + } else { + core.Print(nil, "incomplete") + } + + if !check.Complete { + return core.Result{Value: check, OK: false} + } + return core.Result{Value: check, OK: true} +} + func (s *PrepSubsystem) cmdPlanArchive(options core.Options) core.Result { ctx := s.commandContext() id := optionStringValue(options, "id", "slug", "_arg") @@ -223,3 +264,72 @@ func (s *PrepSubsystem) cmdPlanDelete(options core.Options) core.Result { core.Print(nil, "deleted: %s", output.Deleted) return core.Result{Value: output, OK: true} } + +func planCheckOutput(plan PlanCompatibilityView, phaseOrder int) PlanCheckOutput { + output := PlanCheckOutput{ + Success: true, + Plan: plan, + } + + if phaseOrder <= 0 { + output.Complete, output.Pending = planCompleteOutput(plan.Phases) + return output + } + + for _, phase := range plan.Phases { + if phase.Number != phaseOrder { + continue + } + output.Phase = phase.Number + output.PhaseName = phase.Name + output.Complete, output.Pending = phaseCompleteOutput(phase) + return output + } + + output.Complete = false + output.Pending = []string{core.Concat("phase ", core.Sprint(phaseOrder), " not found")} + return output +} + +func planCompleteOutput(phases []Phase) (bool, []string) { + var pending []string + for _, phase := range phases { + phaseComplete, phasePending := phaseCompleteOutput(phase) + if phaseComplete { + continue + } + if len(phasePending) == 0 { + pending = append(pending, core.Concat("phase ", core.Sprint(phase.Number), ": ", phase.Name)) + continue + } + for _, item := range phasePending { + pending = append(pending, core.Concat("phase ", core.Sprint(phase.Number), ": ", item)) + } + } + return len(pending) == 0, pending +} + +func phaseCompleteOutput(phase Phase) (bool, []string) { + tasks := phaseTaskList(phase) + if len(tasks) == 0 { + switch phase.Status { + case "completed", "done", "approved": + return true, nil + default: + return false, []string{phase.Name} + } + } + + var pending []string + for _, task := range tasks { + if task.Status == "completed" { + continue + } + label := task.Title + if label == "" { + label = task.ID + } + pending = append(pending, label) + } + return len(pending) == 0, pending +} diff --git a/pkg/agentic/commands_plan_test.go b/pkg/agentic/commands_plan_test.go new file mode 100644 index 0000000..74801f9 --- /dev/null +++ b/pkg/agentic/commands_plan_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandsPlan_CmdPlanCheck_Good_CompletePlan(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Check Plan", + Description: "Confirm the plan check command reports completion", + Phases: []Phase{ + { + Name: "Setup", + Tasks: []PlanTask{ + {ID: "1", Title: "Review RFC", Status: "completed"}, + }, + }, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + r := s.cmdPlanCheck(core.NewOptions(core.Option{Key: "_arg", Value: plan.Slug})) + require.True(t, r.OK) + + output, ok := r.Value.(PlanCheckOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.True(t, output.Complete) + assert.Empty(t, output.Pending) + assert.Equal(t, plan.Slug, output.Plan.Slug) +} + +func TestCommandsPlan_CmdPlanCheck_Bad_MissingSlug(t *testing.T) { + s := newTestPrep(t) + + r := s.cmdPlanCheck(core.NewOptions()) + + assert.False(t, r.OK) + require.Error(t, r.Value.(error)) + assert.Contains(t, r.Value.(error).Error(), "slug is required") +} + +func TestCommandsPlan_CmdPlanCheck_Ugly_IncompletePhase(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Incomplete Plan", + Description: "Leave one task pending", + Phases: []Phase{ + { + Number: 1, + Name: "Setup", + Tasks: []PlanTask{ + {ID: "1", Title: "Review RFC", Status: "completed"}, + {ID: "2", Title: "Patch code", Status: "pending"}, + }, + }, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + + r := s.cmdPlanCheck(core.NewOptions( + core.Option{Key: "slug", Value: plan.Slug}, + core.Option{Key: "phase", Value: 1}, + )) + + assert.False(t, r.OK) + output, ok := r.Value.(PlanCheckOutput) + require.True(t, ok) + assert.False(t, output.Complete) + assert.Equal(t, 1, output.Phase) + assert.Equal(t, "Setup", output.PhaseName) + assert.Equal(t, []string{"Patch code"}, output.Pending) +} diff --git a/pkg/agentic/commands_test.go b/pkg/agentic/commands_test.go index a521958..29daa63 100644 --- a/pkg/agentic/commands_test.go +++ b/pkg/agentic/commands_test.go @@ -1233,6 +1233,7 @@ func TestCommands_RegisterCommands_Good_AllRegistered(t *testing.T) { assert.Contains(t, cmds, "plan/list") assert.Contains(t, cmds, "plan/show") assert.Contains(t, cmds, "plan/status") + assert.Contains(t, cmds, "plan/check") assert.Contains(t, cmds, "plan/archive") assert.Contains(t, cmds, "plan/delete") assert.Contains(t, cmds, "pr-manage") diff --git a/pkg/agentic/plan_compat.go b/pkg/agentic/plan_compat.go index 36d9fe5..fb54dee 100644 --- a/pkg/agentic/plan_compat.go +++ b/pkg/agentic/plan_compat.go @@ -67,6 +67,16 @@ type PlanArchiveOutput struct { Archived string `json:"archived"` } +// out := agentic.PlanCheckOutput{Success: true, Complete: true} +type PlanCheckOutput struct { + Success bool `json:"success"` + Complete bool `json:"complete"` + Plan PlanCompatibilityView `json:"plan"` + Phase int `json:"phase,omitempty"` + PhaseName string `json:"phase_name,omitempty"` + Pending []string `json:"pending,omitempty"` +} + // result := c.Action("plan.get").Run(ctx, core.NewOptions(core.Option{Key: "slug", Value: "my-plan-abc123"})) func (s *PrepSubsystem) handlePlanGet(ctx context.Context, options core.Options) core.Result { return s.handlePlanRead(ctx, options)