diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go new file mode 100644 index 0000000..51cdf28 --- /dev/null +++ b/internal/continuity/continuity.go @@ -0,0 +1,184 @@ +// 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 +} + +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") + } + 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 +} diff --git a/internal/continuity/continuity_test.go b/internal/continuity/continuity_test.go new file mode 100644 index 0000000..f0d9ae1 --- /dev/null +++ b/internal/continuity/continuity_test.go @@ -0,0 +1,56 @@ +package continuity + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "testing" + + "orchestra/internal/store" +) + +func TestHandoffCASAndPickup(t *testing.T) { + root := t.TempDir() + run := func(a ...string) { + c := exec.Command("git", append([]string{"-C", root}, a...)...) + c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example") + if b, e := c.CombinedOutput(); e != nil { + t.Fatalf("git: %s %v", b, e) + } + } + os.WriteFile(filepath.Join(root, "TASK.md"), []byte("original"), 0644) + run("init") + run("add", "TASK.md") + run("commit", "-m", "init") + head := runOut(t, root, "rev-parse", "HEAD") + task := sha256.Sum256([]byte("original")) + s, _ := store.Open(t.TempDir()) + h := Handoff{Meta: Meta{ID: "h1", Reason: "manual"}, Anchor: Anchor{GitSHA: head, Branch: "main"}, Goal: "ship", DoneWhen: []string{"tests pass"}, Action: "test", Command: "go test ./...", LastResult: Result{AtSHA: head}} + ref, e := Save(h, s) + if e != nil { + t.Fatal(e) + } + got, e := Load(ref, s) + if e != nil || got.Meta.ID != "h1" { + t.Fatalf("load: %v", e) + } + if e = ValidatePickup(root, got, hex.EncodeToString(task[:])); e != nil { + t.Fatal(e) + } +} +func runOut(t *testing.T, root string, a ...string) string { + b, e := exec.Command("git", append([]string{"-C", root}, a...)...).Output() + if e != nil { + t.Fatal(e) + } + return string(b[:len(b)-1]) +} + +func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) { + _, e := Decode([]byte(`{"meta":{"id":"x","reason":"manual","rotation_index":0},"unknown":1}`)) + if e == nil { + t.Fatal("expected strict schema error") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 450aefa..e9aec55 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -209,6 +209,21 @@ func (s *Store) PutArtifact(b []byte) (string, error) { return h, nil } +// Artifact returns a CAS artifact after verifying its content address. +func (s *Store) Artifact(ref string) ([]byte, error) { + if len(ref) != 64 { + return nil, fmt.Errorf("%w: invalid artifact reference", domain.ErrInvalid) + } + b, err := os.ReadFile(filepath.Join(s.cas, ref)) + if err != nil { + return nil, err + } + if domain.Hash(b) != ref { + return nil, fmt.Errorf("%w: corrupt artifact", domain.ErrInvalid) + } + return b, nil +} + func (s *Store) Task(id string) (domain.Task, bool) { s.mu.Lock() defer s.mu.Unlock() diff --git a/progress.md b/progress.md index 2c2b9f5..52c394d 100644 --- a/progress.md +++ b/progress.md @@ -36,12 +36,11 @@ This is the implementation-oriented breakdown of the specification. It is a proj - Done: native session usage readers and bounded current-turn occupancy calculation. - Done: anchor validation primitive for split-then-close rotation safety. -6. **Continuity** — **not started** - - Handoff schema and validator - - CAS-backed handoffs/reports - - Immutable `TASK.md` handling - - Scratch-branch pickup contract - - Shared Markdown change notifications +6. **Continuity** — **complete** + - Done: strict JSON handoff schema/validator, including framed knowledge fields and size-safe typed fields. + - Done: CAS-backed handoff save/load with content-address verification. + - Done: pickup validation against repository HEAD, dirty-file hashes, and immutable `TASK.md` hash. + - Done: scratch-branch WIP commit helper and Markdown change notices. 7. **Authorization and surfaces** — **minimal groundwork only** - Done: a basic notify-only guard for Telegram/ntfy-style requests. @@ -73,6 +72,7 @@ This is the implementation-oriented breakdown of the specification. It is a proj - Unit tests pass with `go test ./...`. - Implemented item 4 router assignment, lease-expiry polling, and retry policy. - Implemented item 5 herdr socket integration, harness adapters, native occupancy readers, bootstrap, and anchor validation. +- Implemented item 6 continuity: validated CAS handoffs, pickup anchors/TASK.md, scratch-branch commits, and shared Markdown change notices. ## Current API additions