Files
orchestra/internal/continuity/continuity.go
T
kami 62bb17e05d fix(continuity): wire scratch-commit-before-release and rewrite bootstrap prompt (Phase 4 items 3, 5, 6)
Release now re-verifies every handoff Anchor.Dirty file hash (previously
unchecked after the top-level anchor SHA compare), snapshots dirty state
onto a per-task scratch branch before uploading, and rewrites the anchor
to the new commit so successor pickup collapses to a single HEAD compare.
ScratchCommit made idempotent for repeated rotations of the same task.

Bootstrap's prompt now points the agent at the scratch-branch commit
history instead of vague "read the handoff" prose, and does not claim a
GET /v1/artifacts/<ref> endpoint that doesn't exist.

MarkdownChanges had zero callers and zero tests; deleted per AUDIT.md's
explicit deletion option rather than half-wiring an undesigned feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
2026-07-27 22:06:06 +04:00

273 lines
8.0 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"
"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)
}
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
}
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)
}
// 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
}