← 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
M internal/pipeline/state.goM internal/pipeline/state_test.go
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 →