package domain import "fmt" // WorkPhase is the cognitive phase of a task. It is orthogonal to TaskState: // a task can be leased in any phase, and a phase change is not a lifecycle // transition. Keeping them separate is what stops a rotation from looking // like progress and a failed experiment from looking like a failed task. type WorkPhase string const ( WorkPhaseFrame WorkPhase = "frame" WorkPhaseResearch WorkPhase = "research" WorkPhasePlan WorkPhase = "plan" WorkPhaseImplement WorkPhase = "implement" WorkPhaseReview WorkPhase = "review" ) // EventWorkPhaseChanged is emitted by Orchestra, never by an agent. An agent // asks for a phase change through the approval surface and Orchestra decides. const EventWorkPhaseChanged = "WorkPhaseChanged" func (p WorkPhase) Valid() bool { switch p { case WorkPhaseFrame, WorkPhaseResearch, WorkPhasePlan, WorkPhaseImplement, WorkPhaseReview: return true } return false } // legalPhaseTransitions is the full set of moves Orchestra may make. A // project's declared path is a subset of this, checked where the registry is // visible. Skipping ahead is allowed, going backwards is not, except for // review sending work back to implement. var legalPhaseTransitions = map[WorkPhase][]WorkPhase{ WorkPhaseFrame: {WorkPhaseResearch, WorkPhaseImplement}, WorkPhaseResearch: {WorkPhasePlan, WorkPhaseImplement}, WorkPhasePlan: {WorkPhaseImplement}, WorkPhaseImplement: {WorkPhaseReview}, WorkPhaseReview: {WorkPhaseImplement}, } // CanTransitionPhase reports whether Orchestra may move from one phase to // another. An empty from is treated as frame, the phase every task starts in. func CanTransitionPhase(from, to WorkPhase) bool { if from == "" { from = WorkPhaseFrame } if !from.Valid() || !to.Valid() { return false } for _, allowed := range legalPhaseTransitions[from] { if allowed == to { return true } } return false } // ValidateWorkPhaseChanged checks the payload shape. Whether the transition // is legal from the task's current phase is checked at the append boundary, // where the current phase is visible. func ValidateWorkPhaseChanged(p map[string]any) error { phase, _ := p["phase"].(string) if !WorkPhase(phase).Valid() { return fmt.Errorf("%w: phase invalid", ErrInvalid) } if v, ok := p["from"]; ok { s, ok := v.(string) if !ok || !WorkPhase(s).Valid() { return fmt.Errorf("%w: from invalid", ErrInvalid) } } // A sealed artifact is what makes the next phase's context cheap. It is // required when leaving research or plan, because those phases exist to // produce one. if v, ok := p["result_sha"]; ok { s, ok := v.(string) if !ok || len(s) != 40 { return fmt.Errorf("%w: result_sha invalid", ErrInvalid) } } if v, ok := p["artifact_ref"]; ok { s, _ := v.(string) if err := requiredHash(map[string]any{"artifact_ref": s}, "artifact_ref"); err != nil { return err } } return nil } // NextPhases returns the phases Orchestra may move to from this one. A // project's declared path narrows this further, so it is what an agent may // legally ask for rather than what it will certainly be granted. func NextPhases(from WorkPhase) []WorkPhase { if from == "" { from = WorkPhaseFrame } return append([]WorkPhase(nil), legalPhaseTransitions[from]...) }