diff --git a/internal/beads/beads.go b/internal/beads/beads.go index 043277c8..dca988b5 100644 --- a/internal/beads/beads.go +++ b/internal/beads/beads.go @@ -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 { diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 0419763a..50354b80 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -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 @@ -226,12 +227,9 @@ func getManager(role Role) string { case RoleMayor, RoleWitness: return "daemon/" case RolePolecat, RoleRefinery: - // Detect rig from environment or working directory - rigName := detectRigName() - if rigName != "" { - return rigName + "/witness" - } - return "witness/" // fallback + // Would need rig context to determine witness address + // For now, use a placeholder pattern + return "/witness" case RoleCrew: return "human" // Crew is human-managed default: @@ -239,59 +237,12 @@ func getManager(role Role) string { } } -// detectRigName detects the rig name from environment or directory context. -func detectRigName() string { - // Check environment variable first - if rig := os.Getenv("GT_RIG"); rig != "" { - return rig - } - - // Try to detect from tmux session name (format: gt--) - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() - if err == nil { - sessionName := strings.TrimSpace(string(out)) - if strings.HasPrefix(sessionName, "gt-") { - parts := strings.SplitN(sessionName, "-", 3) - if len(parts) >= 2 { - return parts[1] - } - } - } - - // Try to detect from working directory - cwd, err := os.Getwd() - if err != nil { - return "" - } - - // Look for "polecats" in path: .../rig/polecats/polecat/... - if idx := strings.Index(cwd, "/polecats/"); idx != -1 { - // Extract rig name from path before /polecats/ - rigPath := cwd[:idx] - return filepath.Base(rigPath) - } - - return "" -} - -// 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 @@ -302,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 -s -m ) - 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 @@ -324,26 +274,19 @@ func sendLifecycleRequest(manager string, role Role, action HandoffAction, townR return nil } - // Get polecat name for identification - polecatName := detectPolecatName() - rigName := detectRigName() - subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action) body := fmt.Sprintf(`Lifecycle request from %s. Action: %s -Rig: %s -Polecat: %s Time: %s Please verify state and execute lifecycle action. -`, role, action, rigName, polecatName, time.Now().Format(time.RFC3339)) +`, role, action, time.Now().Format(time.RFC3339)) // Send via bd mail (syntax: bd mail send -s -m ) cmd := exec.Command("bd", "mail", "send", manager, "-s", subject, "-m", body, - "--type", "task", // Mark as task requiring action ) cmd.Dir = townRoot @@ -354,45 +297,6 @@ Please verify state and execute lifecycle action. return nil } -// detectPolecatName detects the polecat name from environment or directory context. -func detectPolecatName() string { - // Check environment variable first - if polecat := os.Getenv("GT_POLECAT"); polecat != "" { - return polecat - } - - // Try to detect from tmux session name (format: gt--) - out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output() - if err == nil { - sessionName := strings.TrimSpace(string(out)) - if strings.HasPrefix(sessionName, "gt-") { - parts := strings.SplitN(sessionName, "-", 3) - if len(parts) >= 3 { - return parts[2] - } - } - } - - // Try to detect from working directory - cwd, err := os.Getwd() - if err != nil { - return "" - } - - // Look for "polecats" in path: .../rig/polecats/polecat/... - if idx := strings.Index(cwd, "/polecats/"); idx != -1 { - // Extract polecat name from path after /polecats/ - remainder := cwd[idx+len("/polecats/"):] - // Take first component - if slashIdx := strings.Index(remainder, "/"); slashIdx != -1 { - return remainder[:slashIdx] - } - return remainder - } - - return "" -} - // setRequestingState updates state.json to indicate we're requesting lifecycle action. func setRequestingState(role Role, action HandoffAction, townRoot string) error { // Determine state file location based on role diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 890b949e..6b29ed9b 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -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)")) +} diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 6d9d65f1..8ad74992 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -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)