feat: Add gt unsling command to clear work from hook

- gt unsling: clear your own hook
- gt unsling <bead>: only unsling if that bead is hooked
- gt unsling <target>: clear another agent's hook
- gt unsling <bead> <target>: unsling specific bead from agent
- gt unhook: alias for gt unsling

Symmetric with gt sling/hook commands.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 23:52:27 -08:00
parent 5d774b7d14
commit 43b53cbbbd

166
internal/cmd/unsling.go Normal file
View File

@@ -0,0 +1,166 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
)
var unslingCmd = &cobra.Command{
Use: "unsling [bead-id] [target]",
Aliases: []string{"unhook"},
GroupID: GroupWork,
Short: "Remove work from an agent's hook",
Long: `Remove work from an agent's hook (the inverse of sling/hook).
With no arguments, clears your own hook. With a bead ID, only unslings
if that specific bead is currently hooked. With a target, operates on
another agent's hook.
Examples:
gt unsling # Clear my hook (whatever's there)
gt unsling gt-abc # Only unsling if gt-abc is hooked
gt unsling gastown/joe # Clear joe's hook
gt unsling gt-abc gastown/joe # Unsling gt-abc from joe
The bead's status changes from 'pinned' back to 'open'.
Related commands:
gt sling <bead> # Hook + start (inverse of unsling)
gt hook <bead> # Hook without starting
gt mol status # See what's on your hook`,
Args: cobra.MaximumNArgs(2),
RunE: runUnsling,
}
var (
unslingDryRun bool
unslingForce bool
)
func init() {
unslingCmd.Flags().BoolVarP(&unslingDryRun, "dry-run", "n", false, "Show what would be done")
unslingCmd.Flags().BoolVarP(&unslingForce, "force", "f", false, "Unsling even if work is incomplete")
rootCmd.AddCommand(unslingCmd)
}
func runUnsling(cmd *cobra.Command, args []string) error {
var targetBeadID string
var targetAgent string
// Parse args: [bead-id] [target]
switch len(args) {
case 0:
// No args - unsling self, whatever is hooked
case 1:
// Could be bead ID or target agent
// If it contains "/" or is a known role, treat as target
if isAgentTarget(args[0]) {
targetAgent = args[0]
} else {
targetBeadID = args[0]
}
case 2:
targetBeadID = args[0]
targetAgent = args[1]
}
// Resolve target agent (default: self)
var agentID string
var err error
if targetAgent != "" {
agentID, _, _, err = resolveTargetAgent(targetAgent)
if err != nil {
return fmt.Errorf("resolving target agent: %w", err)
}
} else {
agentID, _, _, err = resolveSelfTarget()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Find pinned bead for this agent
pinnedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusPinned,
Assignee: agentID,
Priority: -1,
})
if err != nil {
return fmt.Errorf("checking pinned beads: %w", err)
}
if len(pinnedBeads) == 0 {
if targetAgent != "" {
fmt.Printf("%s No work hooked for %s\n", style.Dim.Render(""), agentID)
} else {
fmt.Printf("%s Nothing on your hook\n", style.Dim.Render(""))
}
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)
}
// Check if work is complete (warn if not, unless --force)
isComplete, _ := checkPinnedBeadComplete(b, pinned)
if !isComplete && !unslingForce {
return fmt.Errorf("hooked work %s is incomplete (%s)\n Use --force to unsling anyway",
pinned.ID, pinned.Title)
}
if targetAgent != "" {
fmt.Printf("%s Unslinging %s from %s...\n", style.Bold.Render("🪝"), pinned.ID, agentID)
} else {
fmt.Printf("%s Unslinging %s...\n", style.Bold.Render("🪝"), pinned.ID)
}
if unslingDryRun {
fmt.Printf("Would run: bd update %s --status=open\n", pinned.ID)
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("unpinning bead %s: %w", pinned.ID, err)
}
fmt.Printf("%s Work removed from hook\n", style.Bold.Render("✓"))
fmt.Printf(" Bead %s is now status=open\n", pinned.ID)
return nil
}
// isAgentTarget checks if a string looks like an agent target rather than a bead ID.
// Agent targets contain "/" or are known role names.
func isAgentTarget(s string) bool {
// Contains "/" means it's a path like "gastown/joe"
for _, c := range s {
if c == '/' {
return true
}
}
// Known role names
switch s {
case "mayor", "deacon", "witness", "refinery", "crew":
return true
}
return false
}