Merge polecat/Corpus: handoffs using pinned beads (gt-cu7r)
Resolved conflict in handoff.go by keeping pinned bead implementation.
This commit is contained in:
@@ -383,6 +383,88 @@ func (b *Beads) IsBeadsRepo() bool {
|
|||||||
return err == nil || !errors.Is(err, ErrNotARepo)
|
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.
|
// MRFields holds the structured fields for a merge-request issue.
|
||||||
// These fields are stored as key: value lines in the issue description.
|
// These fields are stored as key: value lines in the issue description.
|
||||||
type MRFields struct {
|
type MRFields struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"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 action == HandoffCycle {
|
||||||
if err := sendHandoffMail(role, townRoot); err != nil {
|
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
|
// Send lifecycle request to manager
|
||||||
@@ -226,12 +227,9 @@ func getManager(role Role) string {
|
|||||||
case RoleMayor, RoleWitness:
|
case RoleMayor, RoleWitness:
|
||||||
return "daemon/"
|
return "daemon/"
|
||||||
case RolePolecat, RoleRefinery:
|
case RolePolecat, RoleRefinery:
|
||||||
// Detect rig from environment or working directory
|
// Would need rig context to determine witness address
|
||||||
rigName := detectRigName()
|
// For now, use a placeholder pattern
|
||||||
if rigName != "" {
|
return "<rig>/witness"
|
||||||
return rigName + "/witness"
|
|
||||||
}
|
|
||||||
return "witness/" // fallback
|
|
||||||
case RoleCrew:
|
case RoleCrew:
|
||||||
return "human" // Crew is human-managed
|
return "human" // Crew is human-managed
|
||||||
default:
|
default:
|
||||||
@@ -239,59 +237,12 @@ func getManager(role Role) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectRigName detects the rig name from environment or directory context.
|
// sendHandoffMail updates the pinned handoff bead for the successor to read.
|
||||||
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-<rig>-<polecat>)
|
|
||||||
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.
|
|
||||||
func sendHandoffMail(role Role, townRoot string) error {
|
func sendHandoffMail(role Role, townRoot string) error {
|
||||||
// Determine our address
|
// Build handoff content
|
||||||
var selfAddr string
|
content := handoffMessage
|
||||||
switch role {
|
if content == "" {
|
||||||
case RoleMayor:
|
content = fmt.Sprintf(`🤝 HANDOFF: Session cycling
|
||||||
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.
|
|
||||||
|
|
||||||
Time: %s
|
Time: %s
|
||||||
Role: %s
|
Role: %s
|
||||||
@@ -302,15 +253,14 @@ Check gt mail inbox for messages received during transition.
|
|||||||
`, time.Now().Format(time.RFC3339), role)
|
`, time.Now().Format(time.RFC3339), role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>)
|
// Determine the handoff role key
|
||||||
cmd := exec.Command("bd", "mail", "send", selfAddr,
|
// For role-specific handoffs, use the role name
|
||||||
"-s", subject,
|
roleKey := string(role)
|
||||||
"-m", body,
|
|
||||||
)
|
|
||||||
cmd.Dir = townRoot
|
|
||||||
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
// Update the pinned handoff bead
|
||||||
return fmt.Errorf("%w: %s", err, string(out))
|
bd := beads.New(townRoot)
|
||||||
|
if err := bd.UpdateHandoffContent(roleKey, content); err != nil {
|
||||||
|
return fmt.Errorf("updating handoff bead: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -324,26 +274,19 @@ func sendLifecycleRequest(manager string, role Role, action HandoffAction, townR
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get polecat name for identification
|
|
||||||
polecatName := detectPolecatName()
|
|
||||||
rigName := detectRigName()
|
|
||||||
|
|
||||||
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
|
subject := fmt.Sprintf("LIFECYCLE: %s requesting %s", role, action)
|
||||||
body := fmt.Sprintf(`Lifecycle request from %s.
|
body := fmt.Sprintf(`Lifecycle request from %s.
|
||||||
|
|
||||||
Action: %s
|
Action: %s
|
||||||
Rig: %s
|
|
||||||
Polecat: %s
|
|
||||||
Time: %s
|
Time: %s
|
||||||
|
|
||||||
Please verify state and execute lifecycle action.
|
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 <recipient> -s <subject> -m <body>)
|
// Send via bd mail (syntax: bd mail send <recipient> -s <subject> -m <body>)
|
||||||
cmd := exec.Command("bd", "mail", "send", manager,
|
cmd := exec.Command("bd", "mail", "send", manager,
|
||||||
"-s", subject,
|
"-s", subject,
|
||||||
"-m", body,
|
"-m", body,
|
||||||
"--type", "task", // Mark as task requiring action
|
|
||||||
)
|
)
|
||||||
cmd.Dir = townRoot
|
cmd.Dir = townRoot
|
||||||
|
|
||||||
@@ -354,45 +297,6 @@ Please verify state and execute lifecycle action.
|
|||||||
return nil
|
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-<rig>-<polecat>)
|
|
||||||
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.
|
// setRequestingState updates state.json to indicate we're requesting lifecycle action.
|
||||||
func setRequestingState(role Role, action HandoffAction, townRoot string) error {
|
func setRequestingState(role Role, action HandoffAction, townRoot string) error {
|
||||||
// Determine state file location based on role
|
// Determine state file location based on role
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -71,7 +72,14 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
ctx := detectRole(cwd, townRoot)
|
ctx := detectRole(cwd, townRoot)
|
||||||
|
|
||||||
// Output context
|
// 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 {
|
func detectRole(cwd, townRoot string) RoleContext {
|
||||||
@@ -307,3 +315,31 @@ func outputUnknownContext(ctx RoleContext) {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("Town root: %s\n", style.Dim.Render(ctx.TownRoot))
|
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)"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
@@ -63,10 +64,25 @@ var rigRemoveCmd = &cobra.Command{
|
|||||||
RunE: runRigRemove,
|
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
|
// Flags
|
||||||
var (
|
var (
|
||||||
rigAddPrefix string
|
rigAddPrefix string
|
||||||
rigAddCrew string
|
rigAddCrew string
|
||||||
|
rigResetHandoff bool
|
||||||
|
rigResetRole string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -74,9 +90,13 @@ func init() {
|
|||||||
rigCmd.AddCommand(rigAddCmd)
|
rigCmd.AddCommand(rigAddCmd)
|
||||||
rigCmd.AddCommand(rigListCmd)
|
rigCmd.AddCommand(rigListCmd)
|
||||||
rigCmd.AddCommand(rigRemoveCmd)
|
rigCmd.AddCommand(rigRemoveCmd)
|
||||||
|
rigCmd.AddCommand(rigResetCmd)
|
||||||
|
|
||||||
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
|
rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)")
|
||||||
rigAddCmd.Flags().StringVar(&rigAddCrew, "crew", "main", "Default crew workspace 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 {
|
func runRigAdd(cmd *cobra.Command, args []string) error {
|
||||||
@@ -238,6 +258,45 @@ func runRigRemove(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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
|
// Helper to check if path exists
|
||||||
func pathExists(path string) bool {
|
func pathExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user