7bcad64398
Fixes AUDIT.md's B6: nothing wrote a TASK.md into a worktree, so continuity.ValidatePickup had no caller and no file to check. - continuity.RenderTaskFile/TaskFileHash: render and hash the immutable §6.2 TASK.md from a domain.Task. - GitWorktrees.Create writes and commits TASK.md into every freshly created worktree (must be committed, not dirty, for ScratchCommit's immutability check and for a stable hash). - Coordinator.Start now runs continuity.ValidatePickup (anchor SHA, dirty-file hashes, TASK.md hash) against the real worktree before bootstrapping a successor onto a handoff_ref, and blocks the task instead of bootstrapping on a validation failure. Still open from Phase 4: handoff production (agent writing the real handoff; Release still refuses per B5), ScratchCommit wiring before release, and the §6.2 bootstrap-prompt rewrite — see AUDIT.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
286 lines
8.1 KiB
Go
286 lines
8.1 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)
|
|
}
|
|
|
|
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
|
|
}
|