// Package continuity implements the validated, artifact-backed rotation contract. package continuity import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "orchestra/internal/domain" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) // RenderTaskFile produces the immutable §6.2 TASK.md content for a task. // Deterministic in every field that comes from the task itself, so the same // task always hashes to the same content. func RenderTaskFile(t domain.Task) []byte { var b strings.Builder fmt.Fprintf(&b, "# Task %s\n\n", t.ID) if t.Title != "" { fmt.Fprintf(&b, "%s\n\n", t.Title) } fmt.Fprintf(&b, "- Project: %s\n", t.Project) fmt.Fprintf(&b, "- Source: %s/%s\n", t.Source, t.ExternalID) fmt.Fprintf(&b, "- Priority: %d\n", t.InherentPriority) if len(t.Capability) > 0 { fmt.Fprintf(&b, "- Capability: %s\n", strings.Join(t.Capability, ", ")) } if t.Due != nil { fmt.Fprintf(&b, "- Due: %s\n", t.Due.UTC().Format(time.RFC3339)) } if t.Parent != "" { fmt.Fprintf(&b, "- Parent: %s\n", t.Parent) } if strings.TrimSpace(t.Description) != "" { fmt.Fprintf(&b, "\n## Instructions\n\n%s\n", t.Description) } if len(t.Acceptance) > 0 { b.WriteString("\n## Acceptance criteria\n") for _, criterion := range t.Acceptance { fmt.Fprintf(&b, "\n- %s", criterion) } b.WriteByte('\n') } if t.QualityGate != "" { fmt.Fprintf(&b, "\n## Quality gate\n\n%s\n", t.QualityGate) } b.WriteString("\n## Completion\n\nRun the configured quality gate. When the task is ready for the worker to verify and deliver, create `.orchestra/done`. Do not write a prose completion report.\n\nThis file is immutable for the lifetime of the task (§6.2) — its hash is\ncarried in every handoff and re-verified on every pickup. Do not edit it.\n") return []byte(b.String()) } // TaskFileHash returns the sha256 of the TASK.md at the root of a worktree. func TaskFileHash(root string) (string, error) { b, err := os.ReadFile(filepath.Join(root, "TASK.md")) if err != nil { return "", err } sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]), nil } // ConventionsFiles are the §6.3 shared docs ("Shared *.md ... all agents // contribute") whose staleness the orchestra is responsible for tracking — // never the agent, which only sees its own cached copy. var ConventionsFiles = []string{"AGENTS.md", "CLAUDE.md", "VOCAB.md"} // ConventionsHash hashes the concatenation of whichever ConventionsFiles // exist at root, in that fixed order, so the result changes iff any of their // contents change (a file appearing/disappearing also changes it, since a // length-prefixed marker precedes each file's bytes). A repo with none of // these files hashes to a stable, comparable empty-set value rather than // erroring — §6.3 is optional infrastructure, not every project uses it. func ConventionsHash(root string) (string, error) { h := sha256.New() for _, name := range ConventionsFiles { b, err := os.ReadFile(filepath.Join(root, name)) if err != nil { fmt.Fprintf(h, "%s:0\n", name) continue } fmt.Fprintf(h, "%s:%d\n", name, len(b)) h.Write(b) } return hex.EncodeToString(h.Sum(nil)), nil } type Dirty struct { Path string `json:"path"` SHA256 string `json:"sha256"` Deleted bool `json:"deleted,omitempty"` } type Completed struct { What string `json:"what"` Evidence string `json:"evidence"` } type DeadEnd struct { Tried string `json:"tried"` WhyFailed string `json:"why_failed"` } type Anchor struct { GitSHA string `json:"git_sha"` Branch string `json:"branch"` Dirty []Dirty `json:"dirty,omitempty"` } type Meta struct { ID string `json:"id"` Parent string `json:"parent,omitempty"` Reason string `json:"reason"` RotationIndex int `json:"rotation_index"` } type Handoff struct { Meta Meta `json:"meta"` Anchor Anchor `json:"anchor"` Remaining []string `json:"remaining"` Action string `json:"action"` Command string `json:"command"` DeadEnds []DeadEnd `json:"dead_ends"` OpenQuestions []string `json:"open_questions"` Learned []string `json:"learned"` } type Result struct { Command string `json:"command"` ExitCode int `json:"exit_code"` AtSHA string `json:"at_sha"` } // reconcile_failure is Orchestra's own trigger: human input could not be // reconciled at repeated verified turn boundaries, so the session is handed to // a successor rather than left running on intent that cannot be refreshed. // phase_changed is a rotation Orchestra itself triggers when a phase request // is accepted. The adapter has produced it since phase rotations landed // (handoffReason), but this list was never extended, so every phase rotation // built a handoff the validator then refused as "invalid handoff meta" (F36). // The two halves of one contract have to name the same vocabulary. var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true, "reconcile_failure": true, "phase_changed": true} const maxAuthoredLine = 200 // Action is not one authored line. parseHandoffAnswer joins the agent's NEXT // and WHY answers with " — ", and the prompt asks for a sentence each without // naming any budget. Two ordinary sentences cleared 200 characters and every // rotation on workpc failed at "prose smuggled into list" (F31). Budget the // joined field for the two lines it is actually made of. const maxAuthoredAction = 2*maxAuthoredLine + len(" — ") var circularAction = regexp.MustCompile(`(?i)handoff|report\.md|^continue the task`) var circularCommand = regexp.MustCompile(`(?i)\.orchestra-handoff|handoff-report|report\.md`) // IsCircularCommand reports whether Validate would reject this as a command // that points at a handoff. Producers need the same answer before they build a // handoff, so the rule lives in one place rather than being restated as a // second regexp that can drift. func IsCircularCommand(s string) bool { return circularCommand.MatchString(s) } func (h Handoff) Validate() error { if strings.TrimSpace(h.Meta.ID) == "" || !reasons[h.Meta.Reason] || h.Meta.RotationIndex < 0 { return errors.New("invalid handoff meta") } if len(h.Anchor.GitSHA) != 40 || h.Anchor.Branch == "" || strings.TrimSpace(h.Action) == "" { return errors.New("invalid handoff required fields") } if circularAction.MatchString(h.Action) { return errors.New("invalid handoff action: must name concrete next work, not a handoff") } if err := validateAuthored(h.Action, maxAuthoredAction); err != nil { return err } if circularCommand.MatchString(h.Command) { return errors.New("invalid handoff command: must not point to a handoff or report") } for _, item := range append(append([]string{}, h.Remaining...), append(h.OpenQuestions, h.Learned...)...) { if err := validateAuthoredLine(item); err != nil { return err } } for _, d := range h.Anchor.Dirty { if filepath.IsAbs(d.Path) || d.Path == "" || (!d.Deleted && len(d.SHA256) != 64) { return errors.New("invalid dirty anchor") } } for _, d := range h.DeadEnds { if strings.TrimSpace(d.Tried) == "" || strings.TrimSpace(d.WhyFailed) == "" { return errors.New("invalid dead end") } if err := validateAuthoredLine(d.Tried); err != nil { return err } if err := validateAuthoredLine(d.WhyFailed); err != nil { return err } } return nil } func validateAuthoredLine(s string) error { return validateAuthored(s, maxAuthoredLine) } // The two rejections used to share one message, which named the cause the // agent had not hit. An answer over budget was reported as smuggled prose, so // the agent could not tell what to shorten and retried the same text. func validateAuthored(s string, limit int) error { if strings.TrimSpace(s) == "" { return errors.New("invalid handoff authored field: empty item") } if strings.Contains(s, "\n#") { return errors.New("invalid handoff authored field: prose smuggled into list") } if len(s) > limit { return fmt.Errorf("invalid handoff authored field: %d characters, limit %d", len(s), limit) } return nil } func Encode(h Handoff) ([]byte, error) { if err := h.Validate(); err != nil { return nil, err } return json.Marshal(h) } func Decode(b []byte) (Handoff, error) { var h Handoff d := json.NewDecoder(strings.NewReader(string(b))) d.DisallowUnknownFields() if err := d.Decode(&h); err != nil { return h, err } return h, h.Validate() } func ValidatePickup(root string, h Handoff, taskFileSHA string) error { if err := h.Validate(); err != nil { return err } out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output() if err != nil || strings.TrimSpace(string(out)) != h.Anchor.GitSHA { return errors.New("handoff anchor HEAD mismatch") } for _, d := range h.Anchor.Dirty { if d.Deleted { if _, e := os.Stat(filepath.Join(root, d.Path)); !errors.Is(e, os.ErrNotExist) { return fmt.Errorf("handoff deleted file restored: %s", d.Path) } continue } b, e := os.ReadFile(filepath.Join(root, d.Path)) if e != nil { return e } sum := sha256.Sum256(b) if hex.EncodeToString(sum[:]) != d.SHA256 { return fmt.Errorf("handoff dirty file changed: %s", d.Path) } } if taskFileSHA != "" { b, e := os.ReadFile(filepath.Join(root, "TASK.md")) if e != nil { return e } sum := sha256.Sum256(b) if hex.EncodeToString(sum[:]) != taskFileSHA { return errors.New("TASK.md changed") } } return nil } // VerifyTaskFile ensures the worktree contains the original, immutable task. func VerifyTaskFile(root, taskFileSHA string) error { if taskFileSHA == "" { return errors.New("TASK.md hash required") } b, err := os.ReadFile(filepath.Join(root, "TASK.md")) if err != nil { return err } sum := sha256.Sum256(b) if hex.EncodeToString(sum[:]) != taskFileSHA { return errors.New("TASK.md changed") } return nil } type CAS interface { PutArtifact([]byte) (string, error) Artifact(string) ([]byte, error) } func Save(h Handoff, cas CAS) (string, error) { b, e := Encode(h) if e != nil { return "", e } return cas.PutArtifact(b) } func Load(ref string, cas CAS) (Handoff, error) { b, e := cas.Artifact(ref) if e != nil { return Handoff{}, e } return Decode(b) } // ScratchCommit records every piece of repository work except Orchestra's // ephemeral protocol markers. In particular, git add -A is intentional: it // includes already-staged changes, deletions, renames, and untracked files. // TASK.md is checked before touching the index; it is an immutable input, not // deliverable work. The report/done markers remain local so a successor never // mistakes an old protocol signal for a new one. func ScratchCommit(root, branch, message string) error { if branch == "" || strings.ContainsAny(branch, " \t\n") { return errors.New("invalid scratch branch") } if strings.TrimSpace(message) == "" { return errors.New("scratch commit message required") } status, err := exec.Command("git", "-C", root, "status", "--porcelain", "--", "TASK.md").Output() if err != nil { return err } if len(status) != 0 { return errors.New("TASK.md is immutable") } // Reuse the branch across rotations of the same task rather than failing // on "branch already exists" — a task can rotate more than once. if err := exec.Command("git", "-C", root, "switch", branch).Run(); err != nil { if err := exec.Command("git", "-C", root, "switch", "-c", branch).Run(); err != nil { return err } } // A harness may have staged a protocol marker itself. Remove it from the // index before staging the real checkpoint; this does not alter its working // tree contents and makes the exclusion apply to staged state too. for _, marker := range []string{".orchestra", ".orchestra-handoff.json", ".orchestra-handoff-report.md"} { if err := exec.Command("git", "-C", root, "reset", "-q", "HEAD", "--", marker).Run(); err != nil { return err } } if err := exec.Command("git", "-C", root, "add", "-A", "--", ".", ":(exclude)TASK.md", ":(exclude).orchestra", ":(exclude).orchestra-handoff.json", ":(exclude).orchestra-handoff-report.md").Run(); err != nil { return err } // Only staged non-protocol work is committed. Remaining marker files are // expected and must not suppress a clean committed-anchor checkpoint. if exec.Command("git", "-C", root, "diff", "--cached", "--quiet").Run() == nil { return nil // nothing to snapshot; branch already reflects the worktree } return exec.Command("git", "-C", root, "commit", "-m", message).Run() } func ScratchPush(root, branch, remote string) error { if branch == "" || remote == "" { return errors.New("scratch branch and remote required") } return exec.Command("git", "-C", root, "push", remote, branch).Run() } func ScratchPull(root, branch, remote string) error { if branch == "" || remote == "" { return errors.New("scratch branch and remote required") } if err := exec.Command("git", "-C", root, "fetch", remote, branch).Run(); err != nil { return err } return exec.Command("git", "-C", root, "merge", "--ff-only", "FETCH_HEAD").Run() } // ScratchSync pushes/pulls a scratch branch. Pull uses fast-forward-only to // avoid silently merging independent WIP histories. func ScratchSync(root, branch, remote string, push bool) error { if branch == "" || strings.ContainsAny(branch, " \t\n") || remote == "" { return errors.New("invalid scratch sync") } args := []string{"-C", root, "push", remote, branch} if !push { args = []string{"-C", root, "fetch", remote, branch} } if err := exec.Command("git", args...).Run(); err != nil { return err } if !push { return exec.Command("git", "-C", root, "merge", "--ff-only", "FETCH_HEAD").Run() } return nil }