Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.
Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence
CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods
API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval
Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)
Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
268 lines
7.6 KiB
Go
268 lines
7.6 KiB
Go
package sdlc
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Feature represents a feature under development, stored in manifest.yaml.
|
|
type Feature struct {
|
|
Slug string `yaml:"slug" json:"slug"`
|
|
Title string `yaml:"title" json:"title"`
|
|
Created time.Time `yaml:"created" json:"created"`
|
|
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
|
|
RoadmapRef string `yaml:"roadmap_ref,omitempty" json:"roadmap_ref,omitempty"`
|
|
Phase FeaturePhase `yaml:"phase" json:"phase"`
|
|
PhaseHistory []PhaseTransition `yaml:"phase_history" json:"phase_history"`
|
|
Artifacts map[ArtifactType]*Artifact `yaml:"artifacts" json:"artifacts"`
|
|
Tasks []Task `yaml:"tasks,omitempty" json:"tasks,omitempty"`
|
|
Blockers []string `yaml:"blockers,omitempty" json:"blockers,omitempty"`
|
|
Dependencies Dependencies `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` //nolint:omitzero
|
|
}
|
|
|
|
// CreateFeature creates a new feature directory and manifest.
|
|
func CreateFeature(root, slug, title string) (*Feature, error) {
|
|
if err := ValidateSlug(slug); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir := FeatureDir(root, slug)
|
|
if _, err := os.Stat(dir); err == nil {
|
|
return nil, ErrFeatureExists
|
|
}
|
|
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create feature directory: %w", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
f := &Feature{
|
|
Slug: slug,
|
|
Title: title,
|
|
Created: now,
|
|
Phase: PhaseDraft,
|
|
PhaseHistory: []PhaseTransition{
|
|
{Phase: PhaseDraft, Entered: now},
|
|
},
|
|
Artifacts: map[ArtifactType]*Artifact{
|
|
ArtifactSpec: NewArtifact(ArtifactSpec),
|
|
ArtifactDesign: NewArtifact(ArtifactDesign),
|
|
ArtifactTasks: NewArtifact(ArtifactTasks),
|
|
ArtifactQAPlan: NewArtifact(ArtifactQAPlan),
|
|
ArtifactReview: NewArtifact(ArtifactReview),
|
|
ArtifactAudit: NewArtifact(ArtifactAudit),
|
|
ArtifactQAResults: NewArtifact(ArtifactQAResults),
|
|
},
|
|
}
|
|
|
|
if err := f.Save(root); err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// LoadFeature reads a feature manifest from disk.
|
|
func LoadFeature(root, slug string) (*Feature, error) {
|
|
path := ManifestPath(root, slug)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrFeatureNotFound
|
|
}
|
|
return nil, fmt.Errorf("read feature manifest: %w", err)
|
|
}
|
|
|
|
var f Feature
|
|
if err := yaml.Unmarshal(data, &f); err != nil {
|
|
return nil, fmt.Errorf("parse feature manifest: %w", err)
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// Save writes the feature manifest to disk.
|
|
func (f *Feature) Save(root string) error {
|
|
data, err := yaml.Marshal(f)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal feature manifest: %w", err)
|
|
}
|
|
|
|
path := ManifestPath(root, f.Slug)
|
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
|
return fmt.Errorf("write feature manifest: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListFeatures returns all features found in .sdlc/features/.
|
|
func ListFeatures(root string) ([]*Feature, error) {
|
|
dir := FeaturesDirPath(root)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, ErrNotInitialized
|
|
}
|
|
return nil, fmt.Errorf("read features directory: %w", err)
|
|
}
|
|
|
|
var features []*Feature
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
manifestPath := filepath.Join(dir, entry.Name(), ManifestFile)
|
|
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
f, err := LoadFeature(root, entry.Name())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
features = append(features, f)
|
|
}
|
|
return features, nil
|
|
}
|
|
|
|
// CanTransitionTo checks if the feature can move to the target phase.
|
|
// It validates ordering, config allowances, required artifacts, and blockers.
|
|
func (f *Feature) CanTransitionTo(target FeaturePhase, cfg *Config) error {
|
|
if !IsValidPhase(target) {
|
|
return fmt.Errorf("%w: %s is not a valid phase", ErrInvalidPhase, target)
|
|
}
|
|
|
|
if cfg != nil && !cfg.IsPhaseEnabled(target) {
|
|
return fmt.Errorf("%w: phase %s is not enabled", ErrInvalidTransition, target)
|
|
}
|
|
|
|
currentIdx := PhaseIndex(f.Phase)
|
|
targetIdx := PhaseIndex(target)
|
|
|
|
if targetIdx <= currentIdx {
|
|
return fmt.Errorf("%w: cannot move from %s to %s (backward)", ErrInvalidTransition, f.Phase, target)
|
|
}
|
|
|
|
// Only allow moving to the next phase (no skipping)
|
|
if targetIdx != currentIdx+1 {
|
|
return fmt.Errorf("%w: cannot skip from %s to %s", ErrInvalidTransition, f.Phase, target)
|
|
}
|
|
|
|
// Check required artifacts for the target phase are approved/passed
|
|
if cfg != nil {
|
|
required, ok := cfg.Phases.RequiredArtifacts[target]
|
|
if ok {
|
|
for _, artType := range required {
|
|
art, exists := f.Artifacts[artType]
|
|
if !exists {
|
|
return fmt.Errorf("%w: missing required artifact %s to enter phase %s", ErrInvalidTransition, artType, target)
|
|
}
|
|
if art.Status != StatusApproved && art.Status != StatusPassed {
|
|
return fmt.Errorf("%w: artifact %s is %s, must be approved or passed to enter %s", ErrInvalidTransition, artType, art.Status, target)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check no blockers
|
|
if len(f.Blockers) > 0 {
|
|
return fmt.Errorf("%w: feature has %d blocker(s)", ErrInvalidTransition, len(f.Blockers))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Transition moves the feature to the target phase, recording history.
|
|
func (f *Feature) Transition(target FeaturePhase) error {
|
|
now := time.Now().UTC()
|
|
|
|
// Close the current phase history entry
|
|
if len(f.PhaseHistory) > 0 {
|
|
f.PhaseHistory[len(f.PhaseHistory)-1].Exited = &now
|
|
}
|
|
|
|
f.Phase = target
|
|
f.PhaseHistory = append(f.PhaseHistory, PhaseTransition{
|
|
Phase: target,
|
|
Entered: now,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddBlocker adds a blocker reason.
|
|
func (f *Feature) AddBlocker(reason string) {
|
|
f.Blockers = append(f.Blockers, reason)
|
|
}
|
|
|
|
// ClearBlockers removes all blockers.
|
|
func (f *Feature) ClearBlockers() {
|
|
f.Blockers = nil
|
|
}
|
|
|
|
// IsBlocked returns true if the feature has any blockers.
|
|
func (f *Feature) IsBlocked() bool {
|
|
return len(f.Blockers) > 0
|
|
}
|
|
|
|
// GetArtifact returns the artifact by type, or nil.
|
|
func (f *Feature) GetArtifact(t ArtifactType) *Artifact {
|
|
if f.Artifacts == nil {
|
|
return nil
|
|
}
|
|
return f.Artifacts[t]
|
|
}
|
|
|
|
// SetArtifact sets or creates an artifact entry.
|
|
func (f *Feature) SetArtifact(t ArtifactType, a *Artifact) {
|
|
if f.Artifacts == nil {
|
|
f.Artifacts = make(map[ArtifactType]*Artifact)
|
|
}
|
|
f.Artifacts[t] = a
|
|
}
|
|
|
|
// ArtifactFileExists checks if the artifact file exists on disk.
|
|
func (f *Feature) ArtifactFileExists(root string, artType ArtifactType) bool {
|
|
path := ArtifactPath(root, f.Slug, artType)
|
|
if path == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// DeleteFeature removes a feature directory and all contents.
|
|
func DeleteFeature(root, slug string) error {
|
|
dir := FeatureDir(root, slug)
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
return ErrFeatureNotFound
|
|
}
|
|
return os.RemoveAll(dir)
|
|
}
|
|
|
|
// ArchiveFeature moves a feature from features/ to archives/.
|
|
func ArchiveFeature(root, slug string) error {
|
|
src := FeatureDir(root, slug)
|
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
return ErrFeatureNotFound
|
|
}
|
|
|
|
dst := filepath.Join(SDLCRoot(root), ArchivesDir, slug)
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return fmt.Errorf("create archives directory: %w", err)
|
|
}
|
|
return os.Rename(src, dst)
|
|
}
|
|
|
|
// UpdateTaskSummary refreshes the tasks artifact with current counts.
|
|
func (f *Feature) UpdateTaskSummary() {
|
|
summary := SummarizeTasks(f.Tasks)
|
|
if art := f.GetArtifact(ArtifactTasks); art != nil {
|
|
art.Total = summary.Total
|
|
art.Completed = summary.Completed
|
|
art.InProgress = summary.InProgress
|
|
art.Blocked = summary.Blocked
|
|
}
|
|
}
|