[object Object]

← back to Cli Printing Press

feat(pipeline): add plan_status field to PhaseState for seed expansion tracking

fa2459dd9e1697cd3798273581b7143c3d04df42 · 2026-03-24 11:02:43 -0700 · Matt Van Horn

Adds PlanStatus (seed/expanded/completed) to track whether a phase's plan
file is a thin seed, has been expanded by ce:plan, or is completed.
New methods: MarkSeedWritten, MarkExpanded, IsSeed. Backward compatible
with existing state.json files via omitempty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit fa2459dd9e1697cd3798273581b7143c3d04df42
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Tue Mar 24 11:02:43 2026 -0700

    feat(pipeline): add plan_status field to PhaseState for seed expansion tracking
    
    Adds PlanStatus (seed/expanded/completed) to track whether a phase's plan
    file is a thin seed, has been expanded by ce:plan, or is completed.
    New methods: MarkSeedWritten, MarkExpanded, IsSeed. Backward compatible
    with existing state.json files via omitempty.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 internal/pipeline/state.go      | 37 +++++++++++++++++++++++++++---
 internal/pipeline/state_test.go | 50 +++++++++++++++++++++++++++++++++++++++--
 2 files changed, 82 insertions(+), 5 deletions(-)

diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
index fee655b1..2c5a2c10 100644
--- a/internal/pipeline/state.go
+++ b/internal/pipeline/state.go
@@ -36,6 +36,12 @@ const (
 	StatusFailed    = "failed"
 )
 
+const (
+	PlanStatusSeed      = "seed"
+	PlanStatusExpanded  = "expanded"
+	PlanStatusCompleted = "completed"
+)
+
 // PipelineState tracks which phases are done across sessions.
 type PipelineState struct {
 	APIName        string                `json:"api_name"`
@@ -49,8 +55,9 @@ type PipelineState struct {
 
 // PhaseState tracks a single phase.
 type PhaseState struct {
-	Status   string `json:"status"`
-	PlanPath string `json:"plan_path,omitempty"`
+	Status     string `json:"status"`
+	PlanPath   string `json:"plan_path,omitempty"`
+	PlanStatus string `json:"plan_status,omitempty"`
 }
 
 // PipelineDir returns the pipeline state directory path.
@@ -128,10 +135,29 @@ func (s *PipelineState) MarkPlanned(phase string) {
 	s.Phases[phase] = p
 }
 
+// MarkSeedWritten marks a phase as having its initial seed plan written.
+func (s *PipelineState) MarkSeedWritten(phase string) {
+	s.MarkPlanned(phase)
+	p := s.Phases[phase]
+	p.PlanStatus = PlanStatusSeed
+	s.Phases[phase] = p
+}
+
+// MarkExpanded marks a phase plan as expanded beyond the initial seed.
+func (s *PipelineState) MarkExpanded(phase string) {
+	p := s.Phases[phase]
+	if p.Status == "" || p.Status == StatusPending {
+		p.Status = StatusPlanned
+	}
+	p.PlanStatus = PlanStatusExpanded
+	s.Phases[phase] = p
+}
+
 // Complete marks a phase as completed.
 func (s *PipelineState) Complete(phase string) {
 	p := s.Phases[phase]
 	p.Status = StatusCompleted
+	p.PlanStatus = PlanStatusCompleted
 	s.Phases[phase] = p
 }
 
@@ -145,7 +171,7 @@ func (s *PipelineState) Fail(phase string) {
 // NextPhase returns the name of the next incomplete phase, or "".
 func (s *PipelineState) NextPhase() string {
 	for _, name := range PhaseOrder {
-		if s.Phases[name].Status != StatusCompleted {
+		if s.Phases[name].PlanStatus != PlanStatusCompleted {
 			return name
 		}
 	}
@@ -161,3 +187,8 @@ func (s *PipelineState) IsComplete() bool {
 func (s *PipelineState) PlanPath(phase string) string {
 	return s.Phases[phase].PlanPath
 }
+
+// IsSeed reports whether a phase is still at the seed-plan stage.
+func (s *PipelineState) IsSeed(phase string) bool {
+	return s.Phases[phase].PlanStatus == "" || s.Phases[phase].PlanStatus == PlanStatusSeed
+}
diff --git a/internal/pipeline/state_test.go b/internal/pipeline/state_test.go
index ce858507..deb4f6bf 100644
--- a/internal/pipeline/state_test.go
+++ b/internal/pipeline/state_test.go
@@ -1,6 +1,7 @@
 package pipeline
 
 import (
+	"encoding/json"
 	"os"
 	"testing"
 
@@ -24,7 +25,7 @@ func TestStateRoundTrip(t *testing.T) {
 	s := NewState("roundtrip-test", "/tmp/rt-cli")
 	s.SpecPath = "/tmp/spec.yaml"
 	s.Complete(PhasePreflight)
-	s.MarkPlanned(PhaseScaffold)
+	s.MarkSeedWritten(PhaseScaffold)
 
 	require.NoError(t, s.Save())
 	defer os.RemoveAll(PipelineDir("roundtrip-test"))
@@ -35,8 +36,11 @@ func TestStateRoundTrip(t *testing.T) {
 	assert.Equal(t, "roundtrip-test", loaded.APIName)
 	assert.Equal(t, "/tmp/spec.yaml", loaded.SpecPath)
 	assert.Equal(t, StatusCompleted, loaded.Phases[PhasePreflight].Status)
+	assert.Equal(t, PlanStatusCompleted, loaded.Phases[PhasePreflight].PlanStatus)
 	assert.Equal(t, StatusPlanned, loaded.Phases[PhaseScaffold].Status)
+	assert.Equal(t, PlanStatusSeed, loaded.Phases[PhaseScaffold].PlanStatus)
 	assert.Equal(t, StatusPending, loaded.Phases[PhaseEnrich].Status)
+	assert.Empty(t, loaded.Phases[PhaseEnrich].PlanStatus)
 }
 
 func TestNextPhase(t *testing.T) {
@@ -46,6 +50,9 @@ func TestNextPhase(t *testing.T) {
 	s.Complete(PhasePreflight)
 	assert.Equal(t, PhaseScaffold, s.NextPhase())
 
+	s.Complete(PhaseScaffold)
+	assert.Equal(t, PhaseEnrich, s.NextPhase())
+
 	for _, name := range PhaseOrder {
 		s.Complete(name)
 	}
@@ -56,15 +63,54 @@ func TestNextPhase(t *testing.T) {
 func TestPhaseTransitions(t *testing.T) {
 	s := NewState("transition-test", "/tmp/test")
 
-	s.MarkPlanned(PhasePreflight)
+	s.MarkSeedWritten(PhasePreflight)
+	assert.Equal(t, StatusPlanned, s.Phases[PhasePreflight].Status)
+	assert.Equal(t, PlanStatusSeed, s.Phases[PhasePreflight].PlanStatus)
+
+	s.MarkExpanded(PhasePreflight)
 	assert.Equal(t, StatusPlanned, s.Phases[PhasePreflight].Status)
+	assert.Equal(t, PlanStatusExpanded, s.Phases[PhasePreflight].PlanStatus)
 
 	s.Start(PhasePreflight)
 	assert.Equal(t, StatusExecuting, s.Phases[PhasePreflight].Status)
 
 	s.Complete(PhasePreflight)
 	assert.Equal(t, StatusCompleted, s.Phases[PhasePreflight].Status)
+	assert.Equal(t, PlanStatusCompleted, s.Phases[PhasePreflight].PlanStatus)
 
 	s.Fail(PhaseScaffold)
 	assert.Equal(t, StatusFailed, s.Phases[PhaseScaffold].Status)
 }
+
+func TestMarkExpandedFromPendingMarksPlanned(t *testing.T) {
+	s := NewState("expanded-test", "/tmp/test")
+
+	s.MarkExpanded(PhaseScaffold)
+
+	assert.Equal(t, StatusPlanned, s.Phases[PhaseScaffold].Status)
+	assert.Equal(t, PlanStatusExpanded, s.Phases[PhaseScaffold].PlanStatus)
+}
+
+func TestIsSeedBackwardCompatible(t *testing.T) {
+	s := NewState("seed-test", "/tmp/test")
+	assert.True(t, s.IsSeed(PhasePreflight))
+
+	s.MarkSeedWritten(PhasePreflight)
+	assert.True(t, s.IsSeed(PhasePreflight))
+
+	s.MarkExpanded(PhasePreflight)
+	assert.False(t, s.IsSeed(PhasePreflight))
+}
+
+func TestPhaseStateJSONIncludesPlanStatus(t *testing.T) {
+	state := PhaseState{
+		Status:     StatusPlanned,
+		PlanPath:   "docs/plans/test.md",
+		PlanStatus: PlanStatusSeed,
+	}
+
+	data, err := json.Marshal(state)
+	require.NoError(t, err)
+
+	assert.JSONEq(t, `{"status":"planned","plan_path":"docs/plans/test.md","plan_status":"seed"}`, string(data))
+}

← 15f3d250 docs(plans): template sanitization round 2 + pipeline E2E pl  ·  back to Cli Printing Press  ·  refactor(pipeline): use MarkSeedWritten in Init instead of M 1f360464 →