feat(checkpoint): Add polecat session checkpoint for crash recovery (gt-441j6)

Add checkpoint system for polecats and crew workers to recover state
after session crash or context limit.

Features:
- internal/checkpoint package with Checkpoint type
- gt checkpoint write/read/clear commands
- Checkpoint display in gt prime startup
- Auto-detection of molecule, step, hooked bead
- Git state capture (modified files, branch, commit)

The checkpoint captures:
- Current molecule and step being worked
- Hooked bead
- Modified files list
- Git branch and last commit
- Session notes
- Timestamp

Checkpoints are stored in .polecat-checkpoint.json and displayed
during session startup via gt prime.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rictus
2026-01-01 18:45:29 -08:00
committed by Steve Yegge
parent 65c34efd4e
commit f883a09317
3 changed files with 587 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
// Package checkpoint provides session checkpointing for crash recovery.
// When a polecat session dies (context limit, crash, timeout), checkpoints
// allow the next session to recover state and resume work.
package checkpoint
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Filename is the checkpoint file name within the polecat directory.
const Filename = ".polecat-checkpoint.json"
// Checkpoint represents a session recovery checkpoint.
type Checkpoint struct {
// MoleculeID is the current molecule being worked.
MoleculeID string `json:"molecule_id,omitempty"`
// CurrentStep is the step ID currently in progress.
CurrentStep string `json:"current_step,omitempty"`
// StepTitle is the human-readable title of the current step.
StepTitle string `json:"step_title,omitempty"`
// ModifiedFiles lists files modified since the last commit.
ModifiedFiles []string `json:"modified_files,omitempty"`
// LastCommit is the SHA of the last commit.
LastCommit string `json:"last_commit,omitempty"`
// Branch is the current git branch.
Branch string `json:"branch,omitempty"`
// HookedBead is the bead ID on the agent's hook.
HookedBead string `json:"hooked_bead,omitempty"`
// Timestamp is when the checkpoint was written.
Timestamp time.Time `json:"timestamp"`
// SessionID identifies the session that wrote the checkpoint.
SessionID string `json:"session_id,omitempty"`
// Notes contains optional context from the session.
Notes string `json:"notes,omitempty"`
}
// Path returns the checkpoint file path for a given polecat directory.
func Path(polecatDir string) string {
return filepath.Join(polecatDir, Filename)
}
// Read loads a checkpoint from the polecat directory.
// Returns nil, nil if no checkpoint exists.
func Read(polecatDir string) (*Checkpoint, error) {
path := Path(polecatDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("reading checkpoint: %w", err)
}
var cp Checkpoint
if err := json.Unmarshal(data, &cp); err != nil {
return nil, fmt.Errorf("parsing checkpoint: %w", err)
}
return &cp, nil
}
// Write saves a checkpoint to the polecat directory.
func Write(polecatDir string, cp *Checkpoint) error {
// Set timestamp if not already set
if cp.Timestamp.IsZero() {
cp.Timestamp = time.Now()
}
// Set session ID from environment if available
if cp.SessionID == "" {
cp.SessionID = os.Getenv("CLAUDE_SESSION_ID")
if cp.SessionID == "" {
cp.SessionID = fmt.Sprintf("pid-%d", os.Getpid())
}
}
data, err := json.MarshalIndent(cp, "", " ")
if err != nil {
return fmt.Errorf("marshaling checkpoint: %w", err)
}
path := Path(polecatDir)
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing checkpoint: %w", err)
}
return nil
}
// Remove deletes the checkpoint file.
func Remove(polecatDir string) error {
path := Path(polecatDir)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing checkpoint: %w", err)
}
return nil
}
// Capture creates a checkpoint by capturing current git and work state.
func Capture(polecatDir string) (*Checkpoint, error) {
cp := &Checkpoint{
Timestamp: time.Now(),
}
// Get modified files from git status
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = polecatDir
output, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, line := range lines {
if len(line) > 3 {
// Format: XY filename
file := strings.TrimSpace(line[3:])
if file != "" {
cp.ModifiedFiles = append(cp.ModifiedFiles, file)
}
}
}
}
// Get last commit SHA
cmd = exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = polecatDir
output, err = cmd.Output()
if err == nil {
cp.LastCommit = strings.TrimSpace(string(output))
}
// Get current branch
cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = polecatDir
output, err = cmd.Output()
if err == nil {
cp.Branch = strings.TrimSpace(string(output))
}
return cp, nil
}
// WithMolecule adds molecule context to a checkpoint.
func (cp *Checkpoint) WithMolecule(moleculeID, stepID, stepTitle string) *Checkpoint {
cp.MoleculeID = moleculeID
cp.CurrentStep = stepID
cp.StepTitle = stepTitle
return cp
}
// WithHookedBead adds hooked bead context to a checkpoint.
func (cp *Checkpoint) WithHookedBead(beadID string) *Checkpoint {
cp.HookedBead = beadID
return cp
}
// WithNotes adds context notes to a checkpoint.
func (cp *Checkpoint) WithNotes(notes string) *Checkpoint {
cp.Notes = notes
return cp
}
// Age returns how long ago the checkpoint was written.
func (cp *Checkpoint) Age() time.Duration {
return time.Since(cp.Timestamp)
}
// IsStale returns true if the checkpoint is older than the threshold.
func (cp *Checkpoint) IsStale(threshold time.Duration) bool {
return cp.Age() > threshold
}
// Summary returns a concise summary of the checkpoint.
func (cp *Checkpoint) Summary() string {
var parts []string
if cp.MoleculeID != "" {
if cp.CurrentStep != "" {
parts = append(parts, fmt.Sprintf("molecule %s, step %s", cp.MoleculeID, cp.CurrentStep))
} else {
parts = append(parts, fmt.Sprintf("molecule %s", cp.MoleculeID))
}
}
if cp.HookedBead != "" {
parts = append(parts, fmt.Sprintf("hooked: %s", cp.HookedBead))
}
if len(cp.ModifiedFiles) > 0 {
parts = append(parts, fmt.Sprintf("%d modified files", len(cp.ModifiedFiles)))
}
if cp.Branch != "" {
parts = append(parts, fmt.Sprintf("branch: %s", cp.Branch))
}
if len(parts) == 0 {
return "no significant state"
}
return strings.Join(parts, ", ")
}