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>
479 lines
12 KiB
Go
479 lines
12 KiB
Go
package sdlc
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// DefaultRules returns all classifier rules in priority order.
|
|
// First matching rule wins.
|
|
func DefaultRules() []Rule {
|
|
return []Rule{
|
|
blockedDependencyRule(),
|
|
needsSpecRule(),
|
|
specNeedsApprovalRule(),
|
|
specApprovedRule(),
|
|
needsDesignRule(),
|
|
designNeedsApprovalRule(),
|
|
needsTasksRule(),
|
|
tasksNeedApprovalRule(),
|
|
needsQAPlanRule(),
|
|
qaPlanNeedsApprovalRule(),
|
|
planningCompleteRule(),
|
|
readyToImplementRule(),
|
|
implementNextTaskRule(),
|
|
implementationCompleteRule(),
|
|
needsReviewRule(),
|
|
reviewHasIssuesRule(),
|
|
reviewPassedRule(),
|
|
needsAuditRule(),
|
|
auditHasIssuesRule(),
|
|
auditPassedRule(),
|
|
needsQARule(),
|
|
qaHasFailuresRule(),
|
|
qaPassedRule(),
|
|
needsMergeRule(),
|
|
archiveFeatureRule(),
|
|
}
|
|
}
|
|
|
|
func blockedDependencyRule() Rule {
|
|
return Rule{
|
|
ID: "blocked-dependency",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
return ctx.Feature.IsBlocked()
|
|
},
|
|
Action: ActionBlocked,
|
|
Message: func(ctx *EvalContext) string {
|
|
return fmt.Sprintf("Feature blocked by: %s", strings.Join(ctx.Feature.Blockers, ", "))
|
|
},
|
|
}
|
|
}
|
|
|
|
func needsSpecRule() Rule {
|
|
return Rule{
|
|
ID: "needs-spec",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseDraft {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
|
return art == nil || art.Status == StatusPending
|
|
},
|
|
Action: ActionCreateSpec,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/spec-feature " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/spec.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func specNeedsApprovalRule() Rule {
|
|
return Rule{
|
|
ID: "spec-needs-approval",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseDraft {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
|
return art != nil && art.Status == StatusDraft
|
|
},
|
|
Action: ActionAwaitApproval,
|
|
Message: func(_ *EvalContext) string {
|
|
return "Specification requires user approval"
|
|
},
|
|
}
|
|
}
|
|
|
|
func specApprovedRule() Rule {
|
|
return Rule{
|
|
ID: "spec-approved",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseDraft {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactSpec)
|
|
return art != nil && art.Status == StatusApproved
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseSpecified,
|
|
}
|
|
}
|
|
|
|
func needsDesignRule() Rule {
|
|
return Rule{
|
|
ID: "needs-design",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactDesign)
|
|
return art == nil || art.Status == StatusPending
|
|
},
|
|
Action: ActionCreateDesign,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/design-feature " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/design.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func designNeedsApprovalRule() Rule {
|
|
return Rule{
|
|
ID: "design-needs-approval",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactDesign)
|
|
return art != nil && (art.Status == StatusDraft || art.Status == StatusRejected)
|
|
},
|
|
Action: ActionAwaitApproval,
|
|
Message: func(_ *EvalContext) string {
|
|
return "Design document requires user approval"
|
|
},
|
|
}
|
|
}
|
|
|
|
func needsTasksRule() Rule {
|
|
return Rule{
|
|
ID: "needs-tasks",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
design := ctx.Feature.GetArtifact(ArtifactDesign)
|
|
if design == nil || design.Status != StatusApproved {
|
|
return false
|
|
}
|
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
|
return tasks == nil || tasks.Status == StatusPending
|
|
},
|
|
Action: ActionCreateTasks,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/breakdown-feature " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func tasksNeedApprovalRule() Rule {
|
|
return Rule{
|
|
ID: "tasks-need-approval",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
|
return tasks != nil && (tasks.Status == StatusDraft || tasks.Status == StatusRejected)
|
|
},
|
|
Action: ActionAwaitApproval,
|
|
Message: func(_ *EvalContext) string {
|
|
return "Task breakdown requires user approval"
|
|
},
|
|
}
|
|
}
|
|
|
|
func needsQAPlanRule() Rule {
|
|
return Rule{
|
|
ID: "needs-qa-plan",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
tasks := ctx.Feature.GetArtifact(ArtifactTasks)
|
|
if tasks == nil || tasks.Status != StatusApproved {
|
|
return false
|
|
}
|
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
|
return qa == nil || qa.Status == StatusPending
|
|
},
|
|
Action: ActionCreateQAPlan,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/create-qa-plan " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-plan.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func qaPlanNeedsApprovalRule() Rule {
|
|
return Rule{
|
|
ID: "qa-plan-needs-approval",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
|
return qa != nil && (qa.Status == StatusDraft || qa.Status == StatusRejected)
|
|
},
|
|
Action: ActionAwaitApproval,
|
|
Message: func(_ *EvalContext) string {
|
|
return "QA plan requires user approval"
|
|
},
|
|
}
|
|
}
|
|
|
|
func planningCompleteRule() Rule {
|
|
return Rule{
|
|
ID: "planning-complete",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseSpecified {
|
|
return false
|
|
}
|
|
qa := ctx.Feature.GetArtifact(ArtifactQAPlan)
|
|
return qa != nil && qa.Status == StatusApproved
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhasePlanned,
|
|
}
|
|
}
|
|
|
|
func readyToImplementRule() Rule {
|
|
return Rule{
|
|
ID: "ready-to-implement",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
return ctx.Feature.Phase == PhasePlanned || ctx.Feature.Phase == PhaseReady
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseReady,
|
|
Message: func(ctx *EvalContext) string {
|
|
if ctx.Feature.Phase == PhasePlanned {
|
|
return "Ready for implementation: transition to ready, then implementation"
|
|
}
|
|
return "Ready for implementation"
|
|
},
|
|
}
|
|
}
|
|
|
|
func implementNextTaskRule() Rule {
|
|
return Rule{
|
|
ID: "implement-next-task",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseImplementation {
|
|
return false
|
|
}
|
|
next := NextTask(ctx.Feature.Tasks)
|
|
return next != nil
|
|
},
|
|
Action: ActionImplementTask,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
next := NextTask(ctx.Feature.Tasks)
|
|
if next == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID)
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md"
|
|
},
|
|
TaskID: func(ctx *EvalContext) string {
|
|
next := NextTask(ctx.Feature.Tasks)
|
|
if next == nil {
|
|
return ""
|
|
}
|
|
return next.ID
|
|
},
|
|
}
|
|
}
|
|
|
|
func implementationCompleteRule() Rule {
|
|
return Rule{
|
|
ID: "implementation-complete",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks)
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseReview,
|
|
}
|
|
}
|
|
|
|
func needsReviewRule() Rule {
|
|
return Rule{
|
|
ID: "needs-review",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseReview {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
|
return art == nil || art.Status == StatusPending
|
|
},
|
|
Action: ActionReviewCode,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/review-feature " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/review.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func reviewHasIssuesRule() Rule {
|
|
return Rule{
|
|
ID: "review-has-issues",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseReview {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
|
return art != nil && art.Status == StatusNeedsFix
|
|
},
|
|
Action: ActionFixReviewIssues,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/fix-review-issues " + ctx.Feature.Slug
|
|
},
|
|
}
|
|
}
|
|
|
|
func reviewPassedRule() Rule {
|
|
return Rule{
|
|
ID: "review-passed",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseReview {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactReview)
|
|
return art != nil && art.Status == StatusPassed
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseAudit,
|
|
}
|
|
}
|
|
|
|
func needsAuditRule() Rule {
|
|
return Rule{
|
|
ID: "needs-audit",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseAudit {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
|
return art == nil || art.Status == StatusPending
|
|
},
|
|
Action: ActionAuditCode,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/audit-feature " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func auditHasIssuesRule() Rule {
|
|
return Rule{
|
|
ID: "audit-has-issues",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseAudit {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
|
return art != nil && art.Status == StatusNeedsFix
|
|
},
|
|
Action: ActionRemediateAudit,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/remediate-audit " + ctx.Feature.Slug
|
|
},
|
|
}
|
|
}
|
|
|
|
func auditPassedRule() Rule {
|
|
return Rule{
|
|
ID: "audit-passed",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseAudit {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactAudit)
|
|
return art != nil && art.Status == StatusPassed
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseQA,
|
|
}
|
|
}
|
|
|
|
func needsQARule() Rule {
|
|
return Rule{
|
|
ID: "needs-qa",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseQA {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
|
return art == nil || art.Status == StatusPending
|
|
},
|
|
Action: ActionRunQA,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/run-qa " + ctx.Feature.Slug
|
|
},
|
|
OutputPath: func(ctx *EvalContext) string {
|
|
return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md"
|
|
},
|
|
}
|
|
}
|
|
|
|
func qaHasFailuresRule() Rule {
|
|
return Rule{
|
|
ID: "qa-has-failures",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseQA {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
|
return art != nil && art.Status == StatusFailed
|
|
},
|
|
Action: ActionFixQAFailures,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/fix-qa-failures " + ctx.Feature.Slug
|
|
},
|
|
}
|
|
}
|
|
|
|
func qaPassedRule() Rule {
|
|
return Rule{
|
|
ID: "qa-passed",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
if ctx.Feature.Phase != PhaseQA {
|
|
return false
|
|
}
|
|
art := ctx.Feature.GetArtifact(ArtifactQAResults)
|
|
return art != nil && art.Status == StatusPassed
|
|
},
|
|
Action: ActionTransition,
|
|
TransitionTo: PhaseMerge,
|
|
}
|
|
}
|
|
|
|
func needsMergeRule() Rule {
|
|
return Rule{
|
|
ID: "needs-merge",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
return ctx.Feature.Phase == PhaseMerge
|
|
},
|
|
Action: ActionMergeFeature,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/merge-feature " + ctx.Feature.Slug
|
|
},
|
|
}
|
|
}
|
|
|
|
func archiveFeatureRule() Rule {
|
|
return Rule{
|
|
ID: "archive-feature",
|
|
Condition: func(ctx *EvalContext) bool {
|
|
return ctx.Feature.Phase == PhaseReleased
|
|
},
|
|
Action: ActionArchive,
|
|
NextCommand: func(ctx *EvalContext) string {
|
|
return "/archive-feature " + ctx.Feature.Slug
|
|
},
|
|
}
|
|
}
|