330 lines
9.9 KiB
Go
330 lines
9.9 KiB
Go
// 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)
|
|
}
|
|
b.WriteString("\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"`
|
|
}
|
|
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"`
|
|
}
|
|
|
|
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true}
|
|
|
|
const maxAuthoredLine = 200
|
|
|
|
var circularAction = regexp.MustCompile(`(?i)handoff|report\.md|^continue the task`)
|
|
var circularCommand = regexp.MustCompile(`(?i)\.orchestra-handoff|handoff-report|report\.md`)
|
|
|
|
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 := validateAuthoredLine(h.Action); 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 == "" || 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 {
|
|
if strings.TrimSpace(s) == "" {
|
|
return errors.New("invalid handoff authored field: empty item")
|
|
}
|
|
if strings.Contains(s, "\n#") || len(s) > maxAuthoredLine {
|
|
return errors.New("invalid handoff authored field: prose smuggled into list")
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
if err := exec.Command("git", "-C", root, "add", "-A").Run(); err != nil {
|
|
return err
|
|
}
|
|
full, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(full) == 0 {
|
|
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
|
|
}
|