[object Object]

← back to Cli Printing Press

feat(pipeline): add pipeline state manager with phase tracking

e2560ea7bf4d04667d1b36a917e04f26f93794d6 · 2026-03-24 07:11:52 -0700 · Matt Van Horn

PipelineState tracks 6 phases (preflight, scaffold, enrich, regenerate,
review, ship) with status transitions (pending -> planned -> executing ->
completed/failed). State persists as JSON in the pipeline directory.
Foundation for plan-first autonomous CLI generation pipeline.

Files touched

Diff

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

    feat(pipeline): add pipeline state manager with phase tracking
    
    PipelineState tracks 6 phases (preflight, scaffold, enrich, regenerate,
    review, ship) with status transitions (pending -> planned -> executing ->
    completed/failed). State persists as JSON in the pipeline directory.
    Foundation for plan-first autonomous CLI generation pipeline.
---
 internal/pipeline/state.go      | 160 ++++++++++++++++++++++++++++++++++++++++
 internal/pipeline/state_test.go |  70 ++++++++++++++++++
 2 files changed, 230 insertions(+)

diff --git a/internal/pipeline/state.go b/internal/pipeline/state.go
new file mode 100644
index 00000000..4e2fd79b
--- /dev/null
+++ b/internal/pipeline/state.go
@@ -0,0 +1,160 @@
+package pipeline
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Phase names in execution order.
+const (
+	PhasePreflight  = "preflight"
+	PhaseScaffold   = "scaffold"
+	PhaseEnrich     = "enrich"
+	PhaseRegenerate = "regenerate"
+	PhaseReview     = "review"
+	PhaseShip       = "ship"
+)
+
+// PhaseOrder defines execution order.
+var PhaseOrder = []string{
+	PhasePreflight,
+	PhaseScaffold,
+	PhaseEnrich,
+	PhaseRegenerate,
+	PhaseReview,
+	PhaseShip,
+}
+
+const (
+	StatusPending   = "pending"
+	StatusPlanned   = "planned"   // plan.md exists but not yet executed
+	StatusExecuting = "executing" // ce:work is running on the plan
+	StatusCompleted = "completed"
+	StatusFailed    = "failed"
+)
+
+// PipelineState tracks which phases are done across sessions.
+type PipelineState struct {
+	APIName   string                `json:"api_name"`
+	OutputDir string                `json:"output_dir"`
+	StartedAt time.Time             `json:"started_at"`
+	Phases    map[string]PhaseState `json:"phases"`
+	SpecPath  string                `json:"spec_path,omitempty"`
+	SpecURL   string                `json:"spec_url,omitempty"`
+}
+
+// PhaseState tracks a single phase.
+type PhaseState struct {
+	Status   string `json:"status"`
+	PlanPath string `json:"plan_path,omitempty"`
+}
+
+// PipelineDir returns the pipeline state directory path.
+func PipelineDir(apiName string) string {
+	return filepath.Join("docs", "plans", apiName+"-pipeline")
+}
+
+// StatePath returns the state.json path for an API pipeline.
+func StatePath(apiName string) string {
+	return filepath.Join(PipelineDir(apiName), "state.json")
+}
+
+// NewState creates a fresh pipeline state.
+func NewState(apiName, outputDir string) *PipelineState {
+	phases := make(map[string]PhaseState, len(PhaseOrder))
+	for i, name := range PhaseOrder {
+		phases[name] = PhaseState{
+			Status:   StatusPending,
+			PlanPath: filepath.Join(PipelineDir(apiName), fmt.Sprintf("%02d-%s-plan.md", i, name)),
+		}
+	}
+	return &PipelineState{
+		APIName:   apiName,
+		OutputDir: outputDir,
+		StartedAt: time.Now(),
+		Phases:    phases,
+	}
+}
+
+// Save writes state to disk.
+func (s *PipelineState) Save() error {
+	dir := PipelineDir(s.APIName)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return fmt.Errorf("creating pipeline dir: %w", err)
+	}
+	data, err := json.MarshalIndent(s, "", "  ")
+	if err != nil {
+		return fmt.Errorf("marshaling state: %w", err)
+	}
+	return os.WriteFile(StatePath(s.APIName), data, 0o644)
+}
+
+// LoadState reads existing state from disk.
+func LoadState(apiName string) (*PipelineState, error) {
+	data, err := os.ReadFile(StatePath(apiName))
+	if err != nil {
+		return nil, fmt.Errorf("reading state: %w", err)
+	}
+	var s PipelineState
+	if err := json.Unmarshal(data, &s); err != nil {
+		return nil, fmt.Errorf("parsing state: %w", err)
+	}
+	return &s, nil
+}
+
+// StateExists returns true if a state file exists.
+func StateExists(apiName string) bool {
+	_, err := os.Stat(StatePath(apiName))
+	return err == nil
+}
+
+// Start marks a phase as executing.
+func (s *PipelineState) Start(phase string) {
+	p := s.Phases[phase]
+	p.Status = StatusExecuting
+	s.Phases[phase] = p
+}
+
+// MarkPlanned marks a phase as having its plan.md written.
+func (s *PipelineState) MarkPlanned(phase string) {
+	p := s.Phases[phase]
+	p.Status = StatusPlanned
+	s.Phases[phase] = p
+}
+
+// Complete marks a phase as completed.
+func (s *PipelineState) Complete(phase string) {
+	p := s.Phases[phase]
+	p.Status = StatusCompleted
+	s.Phases[phase] = p
+}
+
+// Fail marks a phase as failed.
+func (s *PipelineState) Fail(phase string) {
+	p := s.Phases[phase]
+	p.Status = StatusFailed
+	s.Phases[phase] = p
+}
+
+// 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 {
+			return name
+		}
+	}
+	return ""
+}
+
+// IsComplete returns true if all phases are completed.
+func (s *PipelineState) IsComplete() bool {
+	return s.NextPhase() == ""
+}
+
+// PlanPath returns the plan.md path for a given phase.
+func (s *PipelineState) PlanPath(phase string) string {
+	return s.Phases[phase].PlanPath
+}
diff --git a/internal/pipeline/state_test.go b/internal/pipeline/state_test.go
new file mode 100644
index 00000000..ce858507
--- /dev/null
+++ b/internal/pipeline/state_test.go
@@ -0,0 +1,70 @@
+package pipeline
+
+import (
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewState(t *testing.T) {
+	s := NewState("test-api", "/tmp/test-api-cli")
+	assert.Equal(t, "test-api", s.APIName)
+	assert.Equal(t, "/tmp/test-api-cli", s.OutputDir)
+	assert.Len(t, s.Phases, len(PhaseOrder))
+
+	for _, name := range PhaseOrder {
+		assert.Equal(t, StatusPending, s.Phases[name].Status)
+		assert.NotEmpty(t, s.Phases[name].PlanPath)
+	}
+}
+
+func TestStateRoundTrip(t *testing.T) {
+	s := NewState("roundtrip-test", "/tmp/rt-cli")
+	s.SpecPath = "/tmp/spec.yaml"
+	s.Complete(PhasePreflight)
+	s.MarkPlanned(PhaseScaffold)
+
+	require.NoError(t, s.Save())
+	defer os.RemoveAll(PipelineDir("roundtrip-test"))
+
+	loaded, err := LoadState("roundtrip-test")
+	require.NoError(t, err)
+
+	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, StatusPlanned, loaded.Phases[PhaseScaffold].Status)
+	assert.Equal(t, StatusPending, loaded.Phases[PhaseEnrich].Status)
+}
+
+func TestNextPhase(t *testing.T) {
+	s := NewState("next-test", "/tmp/test")
+	assert.Equal(t, PhasePreflight, s.NextPhase())
+
+	s.Complete(PhasePreflight)
+	assert.Equal(t, PhaseScaffold, s.NextPhase())
+
+	for _, name := range PhaseOrder {
+		s.Complete(name)
+	}
+	assert.Equal(t, "", s.NextPhase())
+	assert.True(t, s.IsComplete())
+}
+
+func TestPhaseTransitions(t *testing.T) {
+	s := NewState("transition-test", "/tmp/test")
+
+	s.MarkPlanned(PhasePreflight)
+	assert.Equal(t, StatusPlanned, s.Phases[PhasePreflight].Status)
+
+	s.Start(PhasePreflight)
+	assert.Equal(t, StatusExecuting, s.Phases[PhasePreflight].Status)
+
+	s.Complete(PhasePreflight)
+	assert.Equal(t, StatusCompleted, s.Phases[PhasePreflight].Status)
+
+	s.Fail(PhaseScaffold)
+	assert.Equal(t, StatusFailed, s.Phases[PhaseScaffold].Status)
+}

← 9cfae3e4 docs(plans): autonomous CLI pipeline plan and status updates  ·  back to Cli Printing Press  ·  feat(pipeline): spec discovery registry and overlay merge ty 5d456c67 →