// Package continuity implements the validated, artifact-backed rotation contract. package continuity import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) type Dirty struct { Path string `json:"path"` SHA256 string `json:"sha256"` } 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"` Goal string `json:"goal"` DoneWhen []string `json:"done_when"` Completed []Completed `json:"completed"` Remaining []string `json:"remaining"` Action string `json:"action"` Command string `json:"command"` Files []string `json:"files"` Invariants []string `json:"invariants"` DeadEnds []DeadEnd `json:"dead_ends"` OpenQuestions []string `json:"open_questions"` Build string `json:"build"` Test string `json:"test"` LastResult Result `json:"last_result"` } type Result struct { Command string `json:"command"` ExitCode int `json:"exit_code"` AtSHA string `json:"at_sha"` } var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true} 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.Goal) == "" || len(h.DoneWhen) == 0 || strings.TrimSpace(h.Action) == "" || strings.TrimSpace(h.Command) == "" { return errors.New("invalid handoff required fields") } for _, d := range h.Anchor.Dirty { if filepath.IsAbs(d.Path) || d.Path == "" || 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") } } 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 { 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) } type Notice struct { Path string SHA256 string At time.Time } func MarkdownChanges(root string, paths []string) ([]Notice, error) { out := []Notice{} for _, p := range paths { if !strings.HasSuffix(strings.ToLower(p), ".md") { continue } b, e := os.ReadFile(filepath.Join(root, p)) if e != nil { return nil, e } s := sha256.Sum256(b) out = append(out, Notice{p, hex.EncodeToString(s[:]), time.Now().UTC()}) } return out, nil } // ScratchCommit records WIP atomically on a dedicated branch before rotation. 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") } if strings.TrimSpace(message) == "" { return errors.New("scratch commit message required") } for _, args := range [][]string{{"switch", "-c", branch}, {"add", "-A"}, {"commit", "-m", message}} { if err := exec.Command("git", append([]string{"-C", root}, args...)...).Run(); err != nil { return err } } return nil } 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 }