From 6bb355c472467872e35351e1febd1f56b619b1e8 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 14:37:29 +0000 Subject: [PATCH] feat(agentic): add phase dependencies to plans Co-Authored-By: Virgil --- pkg/agentic/plan.go | 75 ++++++++++++++++++++------- pkg/agentic/plan_dependencies_test.go | 47 +++++++++++++++++ 2 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 pkg/agentic/plan_dependencies_test.go diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index 931406d..e74feb8 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -32,15 +32,16 @@ type Plan struct { // phase := agentic.Phase{Number: 1, Name: "Migrate strings", Status: "in_progress"} type Phase struct { - 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"` + Number int `json:"number"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Criteria []string `json:"criteria,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Tasks []PlanTask `json:"tasks,omitempty"` + Checkpoints []PhaseCheckpoint `json:"checkpoints,omitempty"` + Tests int `json:"tests,omitempty"` + Notes string `json:"notes,omitempty"` } // task := agentic.PlanTask{ID: "1", Title: "Review imports", Status: "pending", File: "pkg/agentic/plan.go", Line: 46} @@ -569,15 +570,16 @@ func phaseValue(value any) (Phase, bool) { return typed, true case map[string]any: return Phase{ - 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"]), + Number: intValue(typed["number"]), + Name: stringValue(typed["name"]), + Description: stringValue(typed["description"]), + Status: stringValue(typed["status"]), + Criteria: stringSliceValue(typed["criteria"]), + Dependencies: phaseDependenciesValue(typed["dependencies"]), + Tasks: planTaskSliceValue(typed["tasks"]), + Checkpoints: phaseCheckpointSliceValue(typed["checkpoints"]), + Tests: intValue(typed["tests"]), + Notes: stringValue(typed["notes"]), }, true case map[string]string: return phaseValue(anyMapValue(typed)) @@ -593,6 +595,43 @@ func phaseValue(value any) (Phase, bool) { return Phase{}, false } +func phaseDependenciesValue(value any) []string { + switch typed := value.(type) { + case []string: + return cleanStrings(typed) + case []any: + dependencies := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + return nil + } + if text = core.Trim(text); text != "" { + dependencies = append(dependencies, text) + } + } + return dependencies + case string: + trimmed := core.Trim(typed) + if trimmed == "" { + return nil + } + if core.HasPrefix(trimmed, "[") { + var dependencies []string + if result := core.JSONUnmarshalString(trimmed, &dependencies); result.OK { + return cleanStrings(dependencies) + } + return nil + } + return cleanStrings(core.Split(trimmed, ",")) + default: + if text := stringValue(value); text != "" { + return []string{text} + } + } + return nil +} + func planTaskSliceValue(value any) []PlanTask { switch typed := value.(type) { case []PlanTask: diff --git a/pkg/agentic/plan_dependencies_test.go b/pkg/agentic/plan_dependencies_test.go new file mode 100644 index 0000000..29df6d2 --- /dev/null +++ b/pkg/agentic/plan_dependencies_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlanDependencies_PlanCreate_Good_PreservesPhaseDependencies(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Dependency Plan", + Objective: "Keep phase dependencies in the stored plan", + Phases: []Phase{ + { + Name: "Build", + Dependencies: []string{"Setup", "Lint"}, + Criteria: []string{"tests pass"}, + }, + }, + }) + require.NoError(t, err) + + plan, err := readPlan(PlansRoot(), created.ID) + require.NoError(t, err) + require.Len(t, plan.Phases, 1) + assert.Equal(t, []string{"Setup", "Lint"}, plan.Phases[0].Dependencies) +} + +func TestPlanDependencies_PhaseDependenciesValue_Bad_MixedTypesReturnsNil(t *testing.T) { + dependencies := phaseDependenciesValue([]any{"Setup", 7}) + + assert.Nil(t, dependencies) +} + +func TestPlanDependencies_PhaseDependenciesValue_Ugly_NilInputReturnsNil(t *testing.T) { + dependencies := phaseDependenciesValue(nil) + + assert.Nil(t, dependencies) +}