Implement handoffs using pinned beads (gt-cu7r)

Replace mail-based handoff system with pinned beads that persist
across sessions. This fixes the issue where handoff messages get
closed before successors can read them.

Changes:
- beads: Add StatusPinned constant and handoff functions:
  - HandoffBeadTitle() for well-known naming
  - FindHandoffBead() to locate role handoff bead
  - GetOrCreateHandoffBead() to ensure bead exists
  - UpdateHandoffContent() to set handoff message
  - ClearHandoffContent() to reset after reading

- cmd/handoff: Update to use pinned beads instead of mail
  - sendHandoffMail() now updates pinned bead content

- cmd/prime: Display handoff content on startup
  - outputHandoffContent() reads and shows handoff bead

- cmd/rig: Add reset command with --handoff flag
  - gt rig reset --handoff clears handoff content

Generated with Claude Code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 01:51:14 -08:00
parent 717bc89132
commit 4edacde590
4 changed files with 196 additions and 31 deletions

View File

@@ -383,6 +383,88 @@ func (b *Beads) IsBeadsRepo() bool {
return err == nil || !errors.Is(err, ErrNotARepo)
}
// StatusPinned is the status for pinned beads that never get closed.
const StatusPinned = "pinned"
// HandoffBeadTitle returns the well-known title for a role's handoff bead.
func HandoffBeadTitle(role string) string {
return role + " Handoff"
}
// FindHandoffBead finds the pinned handoff bead for a role by title.
// Returns nil if not found (not an error).
func (b *Beads) FindHandoffBead(role string) (*Issue, error) {
issues, err := b.List(ListOptions{Status: StatusPinned, Priority: -1})
if err != nil {
return nil, fmt.Errorf("listing pinned issues: %w", err)
}
targetTitle := HandoffBeadTitle(role)
for _, issue := range issues {
if issue.Title == targetTitle {
return issue, nil
}
}
return nil, nil
}
// GetOrCreateHandoffBead returns the handoff bead for a role, creating it if needed.
func (b *Beads) GetOrCreateHandoffBead(role string) (*Issue, error) {
// Check if it exists
existing, err := b.FindHandoffBead(role)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
// Create new handoff bead
issue, err := b.Create(CreateOptions{
Title: HandoffBeadTitle(role),
Type: "task",
Priority: 2,
Description: "", // Empty until first handoff
})
if err != nil {
return nil, fmt.Errorf("creating handoff bead: %w", err)
}
// Update to pinned status
status := StatusPinned
if err := b.Update(issue.ID, UpdateOptions{Status: &status}); err != nil {
return nil, fmt.Errorf("setting handoff bead to pinned: %w", err)
}
// Re-fetch to get updated status
return b.Show(issue.ID)
}
// UpdateHandoffContent updates the handoff bead's description with new content.
func (b *Beads) UpdateHandoffContent(role, content string) error {
issue, err := b.GetOrCreateHandoffBead(role)
if err != nil {
return err
}
return b.Update(issue.ID, UpdateOptions{Description: &content})
}
// ClearHandoffContent clears the handoff bead's description.
func (b *Beads) ClearHandoffContent(role string) error {
issue, err := b.FindHandoffBead(role)
if err != nil {
return err
}
if issue == nil {
return nil // Nothing to clear
}
empty := ""
return b.Update(issue.ID, UpdateOptions{Description: &empty})
}
// MRFields holds the structured fields for a merge-request issue.
// These fields are stored as key: value lines in the issue description.
type MRFields struct {

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -95,12 +96,12 @@ func runHandoff(cmd *cobra.Command, args []string) error {
}
}
// For cycle, send handoff mail to self
// For cycle, update handoff bead for successor
if action == HandoffCycle {
if err := sendHandoffMail(role, townRoot); err != nil {
return fmt.Errorf("sending handoff mail: %w", err)
return fmt.Errorf("updating handoff bead: %w", err)
}
fmt.Printf("%s Sent handoff mail to self\n", style.Bold.Render("✓"))
fmt.Printf("%s Updated handoff bead for successor\n", style.Bold.Render("✓"))
}
// Send lifecycle request to manager
@@ -236,24 +237,12 @@ func getManager(role Role) string {
}
}
// sendHandoffMail sends a handoff message to ourselves for the successor to read.
// sendHandoffMail updates the pinned handoff bead for the successor to read.
func sendHandoffMail(role Role, townRoot string) error {
// Determine our address
var selfAddr string
switch role {
case RoleMayor:
selfAddr = "mayor/"
case RoleWitness:
selfAddr = "witness/" // Would need rig prefix
default:
selfAddr = string(role) + "/"
}
// Build handoff message
subject := "🤝 HANDOFF: Session cycling"
body := handoffMessage
if body == "" {
body = fmt.Sprintf(`Handoff from previous session.
// Build handoff content
content := handoffMessage
if content == "" {
content = fmt.Sprintf(`🤝 HANDOFF: Session cycling
Time: %s
Role: %s
@@ -264,15 +253,14 @@ Check gt mail inbox for messages received during transition.
`, time.Now().Format(time.RFC3339), role)
}
// Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>)
cmd := exec.Command("bd", "mail", "send", selfAddr,
"-s", subject,
"-m", body,
)
cmd.Dir = townRoot
// Determine the handoff role key
// For role-specific handoffs, use the role name
roleKey := string(role)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", err, string(out))
// Update the pinned handoff bead
bd := beads.New(townRoot)
if err := bd.UpdateHandoffContent(roleKey, content); err != nil {
return fmt.Errorf("updating handoff bead: %w", err)
}
return nil

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
@@ -71,7 +72,14 @@ func runPrime(cmd *cobra.Command, args []string) error {
ctx := detectRole(cwd, townRoot)
// Output context
return outputPrimeContext(ctx)
if err := outputPrimeContext(ctx); err != nil {
return err
}
// Output handoff content if present
outputHandoffContent(ctx)
return nil
}
func detectRole(cwd, townRoot string) RoleContext {
@@ -307,3 +315,31 @@ func outputUnknownContext(ctx RoleContext) {
fmt.Println()
fmt.Printf("Town root: %s\n", style.Dim.Render(ctx.TownRoot))
}
// outputHandoffContent reads and displays the pinned handoff bead for the role.
func outputHandoffContent(ctx RoleContext) {
if ctx.Role == RoleUnknown {
return
}
// Get role key for handoff bead lookup
roleKey := string(ctx.Role)
bd := beads.New(ctx.TownRoot)
issue, err := bd.FindHandoffBead(roleKey)
if err != nil {
// Silently skip if beads lookup fails (might not be a beads repo)
return
}
if issue == nil || issue.Description == "" {
// No handoff content
return
}
// Display handoff content
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🤝 Handoff from Previous Session"))
fmt.Println(issue.Description)
fmt.Println()
fmt.Println(style.Dim.Render("(Clear with: gt rig reset --handoff)"))
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig"
@@ -63,10 +64,25 @@ var rigRemoveCmd = &cobra.Command{
RunE: runRigRemove,
}
var rigResetCmd = &cobra.Command{
Use: "reset",
Short: "Reset rig state (handoff content, etc.)",
Long: `Reset various rig state.
By default, resets all resettable state. Use flags to reset specific items.
Examples:
gt rig reset # Reset all state
gt rig reset --handoff # Clear handoff content only`,
RunE: runRigReset,
}
// Flags
var (
rigAddPrefix string
rigAddCrew string
rigAddPrefix string
rigAddCrew string
rigResetHandoff bool
rigResetRole string
)
func init() {
@@ -74,9 +90,13 @@ func init() {
rigCmd.AddCommand(rigAddCmd)
rigCmd.AddCommand(rigListCmd)
rigCmd.AddCommand(rigRemoveCmd)
rigCmd.AddCommand(rigResetCmd)
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace name")
rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content")
rigResetCmd.Flags().StringVar(&rigResetRole, "role", "", "Role to reset (default: auto-detect from cwd)")
}
func runRigAdd(cmd *cobra.Command, args []string) error {
@@ -238,6 +258,45 @@ func runRigRemove(cmd *cobra.Command, args []string) error {
return nil
}
func runRigReset(cmd *cobra.Command, args []string) error {
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Determine role to reset
roleKey := rigResetRole
if roleKey == "" {
// Auto-detect from cwd
ctx := detectRole(cwd, townRoot)
if ctx.Role == RoleUnknown {
return fmt.Errorf("could not detect role from current directory; use --role to specify")
}
roleKey = string(ctx.Role)
}
// If no specific flags, reset all; otherwise only reset what's specified
resetAll := !rigResetHandoff
bd := beads.New(townRoot)
// Reset handoff content
if resetAll || rigResetHandoff {
if err := bd.ClearHandoffContent(roleKey); err != nil {
return fmt.Errorf("clearing handoff content: %w", err)
}
fmt.Printf("%s Cleared handoff content for %s\n", style.Success.Render("✓"), roleKey)
}
return nil
}
// Helper to check if path exists
func pathExists(path string) bool {
_, err := os.Stat(path)