fix: gt unsling now correctly clears hooks for remote agents (gt-yea94)

The unsling command had two bugs:
1. It used the local beads directory instead of the target agent's rig
2. It looked for status=hooked beads instead of the agent bead's hook_bead field

Changes:
- unsling.go: Rewrote to use agent bead's hook_bead field (matches how sling works)
- unsling.go: Now uses target agent's rig beads path, not local
- status.go: Prefer SQLite columns (issue.HookBead, issue.AgentState) over
  parsing description text, with fallback for legacy beads
- beads.go: Added AgentState field to Issue struct for SQLite column access

This fixes the issue where `gt unsling gastown/crew/joe` would say "no work hooked"
even when gt status showed joe had a hook.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/max
2026-01-01 10:58:33 -08:00
committed by Steve Yegge
parent 4bafbfb0f7
commit 121150b49f
3 changed files with 90 additions and 51 deletions

View File

@@ -626,16 +626,22 @@ func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]
// Look up agent bead from preloaded map (O(1))
if issue, ok := allAgentBeads[d.beadID]; ok {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.HookBead = fields.HookBead
agent.State = fields.AgentState
if fields.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[fields.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
// Prefer SQLite columns over description parsing
// HookBead column is authoritative (cleared by unsling)
agent.HookBead = issue.HookBead
agent.State = issue.AgentState
if agent.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
}
// Fallback to description for legacy beads without SQLite columns
if agent.State == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.State = fields.AgentState
}
}
}
@@ -758,16 +764,22 @@ func discoverRigAgents(allSessions map[string]bool, r *rig.Rig, crews []string,
// Look up agent bead from preloaded map (O(1))
if issue, ok := allAgentBeads[d.beadID]; ok {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.HookBead = fields.HookBead
agent.State = fields.AgentState
if fields.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[fields.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
// Prefer SQLite columns over description parsing
// HookBead column is authoritative (cleared by unsling)
agent.HookBead = issue.HookBead
agent.State = issue.AgentState
if agent.HookBead != "" {
agent.HasWork = true
// Get hook title from preloaded map
if pinnedIssue, ok := allHookBeads[agent.HookBead]; ok {
agent.WorkTitle = pinnedIssue.Title
}
}
// Fallback to description for legacy beads without SQLite columns
if agent.State == "" {
fields := beads.ParseAgentFields(issue.Description)
if fields != nil {
agent.State = fields.AgentState
}
}
}

View File

@@ -2,11 +2,14 @@ package cmd
import (
"fmt"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
var unslingCmd = &cobra.Command{
@@ -84,25 +87,39 @@ func runUnsling(cmd *cobra.Command, args []string) error {
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
// Find town root and rig path for agent beads
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
return fmt.Errorf("finding town root: %w", err)
}
b := beads.New(workDir)
// Find hooked bead for this agent
pinnedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: agentID,
Priority: -1,
})
if err != nil {
return fmt.Errorf("checking hooked beads: %w", err)
// Extract rig name from agent ID (e.g., "gastown/crew/joe" -> "gastown")
// For town-level agents like "mayor/", use town root
rigName := strings.Split(agentID, "/")[0]
var beadsPath string
if rigName == "mayor" || rigName == "deacon" {
beadsPath = townRoot
} else {
beadsPath = filepath.Join(townRoot, rigName)
}
if len(pinnedBeads) == 0 {
b := beads.New(beadsPath)
// Convert agent ID to agent bead ID and look up the agent bead
agentBeadID := agentIDToBeadID(agentID)
if agentBeadID == "" {
return fmt.Errorf("could not convert agent ID %s to bead ID", agentID)
}
// Get the agent bead to find current hook
agentBead, err := b.Show(agentBeadID)
if err != nil {
return fmt.Errorf("getting agent bead %s: %w", agentBeadID, err)
}
// Check if agent has work hooked (via hook_bead field)
hookedBeadID := agentBead.HookBead
if hookedBeadID == "" {
if targetAgent != "" {
fmt.Printf("%s No work hooked for %s\n", style.Dim.Render(""), agentID)
} else {
@@ -111,42 +128,51 @@ func runUnsling(cmd *cobra.Command, args []string) error {
return nil
}
pinned := pinnedBeads[0]
// If specific bead requested, verify it matches
if targetBeadID != "" && pinned.ID != targetBeadID {
return fmt.Errorf("bead %s is not hooked (current hook: %s)", targetBeadID, pinned.ID)
if targetBeadID != "" && hookedBeadID != targetBeadID {
return fmt.Errorf("bead %s is not hooked (current hook: %s)", targetBeadID, hookedBeadID)
}
// Get the hooked bead to check completion and show title
hookedBead, err := b.Show(hookedBeadID)
if err != nil {
// Bead might be deleted - still allow unsling with --force
if !unslingForce {
return fmt.Errorf("getting hooked bead %s: %w\n Use --force to unsling anyway", hookedBeadID, err)
}
// Force mode - proceed without the bead details
hookedBead = &beads.Issue{ID: hookedBeadID, Title: "(unknown)"}
}
// Check if work is complete (warn if not, unless --force)
isComplete, _ := checkPinnedBeadComplete(b, pinned)
isComplete := hookedBead.Status == "closed"
if !isComplete && !unslingForce {
return fmt.Errorf("hooked work %s is incomplete (%s)\n Use --force to unsling anyway",
pinned.ID, pinned.Title)
hookedBeadID, hookedBead.Title)
}
if targetAgent != "" {
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), pinned.ID, agentID)
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), hookedBeadID, agentID)
} else {
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), pinned.ID)
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), hookedBeadID)
}
if unslingDryRun {
fmt.Printf("Would run: bd update %s --status=open\n", pinned.ID)
fmt.Printf("Would clear hook_bead from agent bead %s\n", agentBeadID)
return nil
}
// Unpin by setting status back to open
status := "open"
if err := b.Update(pinned.ID, beads.UpdateOptions{Status: &status}); err != nil {
return fmt.Errorf("unhooking bead %s: %w", pinned.ID, err)
// Clear the hook by updating agent bead with empty hook_bead
emptyHook := ""
if err := b.UpdateAgentState(agentBeadID, "running", &emptyHook); err != nil {
return fmt.Errorf("clearing hook from agent bead %s: %w", agentBeadID, err)
}
// Log unhook event
_ = events.LogFeed(events.TypeUnhook, agentID, events.UnhookPayload(pinned.ID))
_ = events.LogFeed(events.TypeUnhook, agentID, events.UnhookPayload(hookedBeadID))
fmt.Printf("%s Work removed from hook\n", style.Bold.Render("✓"))
fmt.Printf(" Bead %s is now status=open\n", pinned.ID)
fmt.Printf(" Agent %s hook cleared (was: %s)\n", agentID, hookedBeadID)
return nil
}