The gt hook command was using --status=pinned while gt sling and the rest of the codebase use --status=hooked. This inconsistency meant gt hook would not find beads hooked by gt sling, and vice versa. Changes: - hook.go: Use beads.StatusHooked instead of beads.StatusPinned - hook.go: Update bd update command to use --status=hooked - unsling.go: Use beads.StatusHooked when finding hooked beads - Update error messages and comments to say "hooked" instead of "pinned" This fix ensures gt hook works uniformly for all bead types (molecules, issues, mail beads) since they all use the same beads system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
235 lines
7.5 KiB
Go
235 lines
7.5 KiB
Go
package cmd
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
|
||
"github.com/spf13/cobra"
|
||
"github.com/steveyegge/gastown/internal/beads"
|
||
"github.com/steveyegge/gastown/internal/events"
|
||
"github.com/steveyegge/gastown/internal/style"
|
||
)
|
||
|
||
var hookCmd = &cobra.Command{
|
||
Use: "hook [bead-id]",
|
||
GroupID: GroupWork,
|
||
Short: "Show or attach work on your hook",
|
||
Long: `Show what's on your hook, or attach new work.
|
||
|
||
With no arguments, shows your current hook status (alias for 'gt mol status').
|
||
With a bead ID, attaches that work to your hook.
|
||
|
||
The hook is the "durability primitive" - work on your hook survives session
|
||
restarts, context compaction, and handoffs. When you restart (via gt handoff),
|
||
your SessionStart hook finds the attached work and you continue from where
|
||
you left off.
|
||
|
||
Examples:
|
||
gt hook # Show what's on my hook
|
||
gt hook status # Same as above
|
||
gt hook gt-abc # Attach issue gt-abc to your hook
|
||
gt hook gt-abc -s "Fix the bug" # With subject for handoff mail
|
||
|
||
Related commands:
|
||
gt sling <bead> # Hook + start now (keep context)
|
||
gt handoff <bead> # Hook + restart (fresh context)
|
||
gt unsling # Remove work from hook`,
|
||
Args: cobra.MaximumNArgs(1),
|
||
RunE: runHookOrStatus,
|
||
}
|
||
|
||
// hookStatusCmd shows hook status (alias for mol status)
|
||
var hookStatusCmd = &cobra.Command{
|
||
Use: "status [target]",
|
||
Short: "Show what's on your hook",
|
||
Long: `Show what's slung on your hook.
|
||
|
||
This is an alias for 'gt mol status'. Shows what work is currently
|
||
attached to your hook, along with progress information.
|
||
|
||
Examples:
|
||
gt hook status # Show my hook
|
||
gt hook status greenplace/nux # Show nux's hook`,
|
||
Args: cobra.MaximumNArgs(1),
|
||
RunE: runMoleculeStatus,
|
||
}
|
||
|
||
var (
|
||
hookSubject string
|
||
hookMessage string
|
||
hookDryRun bool
|
||
hookForce bool
|
||
)
|
||
|
||
func init() {
|
||
// Flags for attaching work (gt hook <bead-id>)
|
||
hookCmd.Flags().StringVarP(&hookSubject, "subject", "s", "", "Subject for handoff mail (optional)")
|
||
hookCmd.Flags().StringVarP(&hookMessage, "message", "m", "", "Message for handoff mail (optional)")
|
||
hookCmd.Flags().BoolVarP(&hookDryRun, "dry-run", "n", false, "Show what would be done")
|
||
hookCmd.Flags().BoolVarP(&hookForce, "force", "f", false, "Replace existing incomplete hooked bead")
|
||
|
||
// --json flag for status output (used when no args, i.e., gt hook --json)
|
||
hookCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON (for status)")
|
||
hookStatusCmd.Flags().BoolVar(&moleculeJSON, "json", false, "Output as JSON")
|
||
hookCmd.AddCommand(hookStatusCmd)
|
||
|
||
rootCmd.AddCommand(hookCmd)
|
||
}
|
||
|
||
// runHookOrStatus dispatches to status or hook based on args
|
||
func runHookOrStatus(cmd *cobra.Command, args []string) error {
|
||
if len(args) == 0 {
|
||
// No args - show status
|
||
return runMoleculeStatus(cmd, args)
|
||
}
|
||
// Has arg - attach work
|
||
return runHook(cmd, args)
|
||
}
|
||
|
||
func runHook(cmd *cobra.Command, args []string) error {
|
||
beadID := args[0]
|
||
|
||
// Polecats cannot hook - they use gt done for lifecycle
|
||
if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" {
|
||
return fmt.Errorf("polecats cannot hook work (use gt done for handoff)")
|
||
}
|
||
|
||
// Verify the bead exists
|
||
if err := verifyBeadExists(beadID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Determine agent identity
|
||
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)
|
||
|
||
// Check for existing hooked bead for this agent
|
||
existingPinned, err := b.List(beads.ListOptions{
|
||
Status: beads.StatusHooked,
|
||
Assignee: agentID,
|
||
Priority: -1,
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("checking existing hooked beads: %w", err)
|
||
}
|
||
|
||
// If there's an existing hooked bead, check if we can auto-replace
|
||
if len(existingPinned) > 0 {
|
||
existing := existingPinned[0]
|
||
|
||
// Skip if it's the same bead we're trying to pin
|
||
if existing.ID == beadID {
|
||
fmt.Printf("%s Already hooked: %s\n", style.Bold.Render("✓"), beadID)
|
||
return nil
|
||
}
|
||
|
||
// Check if existing bead is complete
|
||
isComplete, hasAttachment := checkPinnedBeadComplete(b, existing)
|
||
|
||
if isComplete {
|
||
// Auto-replace completed bead
|
||
fmt.Printf("%s Replacing completed bead %s...\n", style.Dim.Render("ℹ"), existing.ID)
|
||
if !hookDryRun {
|
||
if hasAttachment {
|
||
// Close completed molecule bead (use bd close --force for pinned)
|
||
closeCmd := exec.Command("bd", "close", existing.ID, "--force",
|
||
"--reason=Auto-replaced by gt hook (molecule complete)")
|
||
closeCmd.Stderr = os.Stderr
|
||
if err := closeCmd.Run(); err != nil {
|
||
return fmt.Errorf("closing completed bead %s: %w", existing.ID, err)
|
||
}
|
||
} else {
|
||
// Naked bead - just unpin, don't close (might have value)
|
||
status := "open"
|
||
if err := b.Update(existing.ID, beads.UpdateOptions{Status: &status}); err != nil {
|
||
return fmt.Errorf("unpinning bead %s: %w", existing.ID, err)
|
||
}
|
||
}
|
||
}
|
||
} else if hookForce {
|
||
// Force replace incomplete bead
|
||
fmt.Printf("%s Force-replacing incomplete bead %s...\n", style.Dim.Render("⚠"), existing.ID)
|
||
if !hookDryRun {
|
||
// Unpin by setting status back to open
|
||
status := "open"
|
||
if err := b.Update(existing.ID, beads.UpdateOptions{Status: &status}); err != nil {
|
||
return fmt.Errorf("unpinning bead %s: %w", existing.ID, err)
|
||
}
|
||
}
|
||
} else {
|
||
// Existing incomplete bead blocks new hook
|
||
return fmt.Errorf("existing hooked bead %s is incomplete (%s)\n Use --force to replace, or complete the existing work first",
|
||
existing.ID, existing.Title)
|
||
}
|
||
}
|
||
|
||
fmt.Printf("%s Hooking %s...\n", style.Bold.Render("🪝"), beadID)
|
||
|
||
if hookDryRun {
|
||
fmt.Printf("Would run: bd update %s --status=hooked --assignee=%s\n", beadID, agentID)
|
||
if hookSubject != "" {
|
||
fmt.Printf(" subject (for handoff mail): %s\n", hookSubject)
|
||
}
|
||
if hookMessage != "" {
|
||
fmt.Printf(" context (for handoff mail): %s\n", hookMessage)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Hook the bead using bd update (discovery-based approach)
|
||
hookCmd := exec.Command("bd", "update", beadID, "--status=hooked", "--assignee="+agentID)
|
||
hookCmd.Stderr = os.Stderr
|
||
if err := hookCmd.Run(); err != nil {
|
||
return fmt.Errorf("hooking bead: %w", err)
|
||
}
|
||
|
||
fmt.Printf("%s Work attached to hook (hooked bead)\n", style.Bold.Render("✓"))
|
||
fmt.Printf(" Use 'gt handoff' to restart with this work\n")
|
||
fmt.Printf(" Use 'gt hook' to see hook status\n")
|
||
|
||
// Log hook event to activity feed
|
||
_ = events.LogFeed(events.TypeHook, agentID, events.HookPayload(beadID))
|
||
|
||
return nil
|
||
}
|
||
|
||
// checkPinnedBeadComplete checks if a pinned bead's attached molecule is 100% complete.
|
||
// Returns (isComplete, hasAttachment):
|
||
// - isComplete=true if no molecule attached OR all molecule steps are closed
|
||
// - hasAttachment=true if there's an attached molecule
|
||
func checkPinnedBeadComplete(b *beads.Beads, issue *beads.Issue) (isComplete bool, hasAttachment bool) {
|
||
// Check for attached molecule
|
||
attachment := beads.ParseAttachmentFields(issue)
|
||
if attachment == nil || attachment.AttachedMolecule == "" {
|
||
// No molecule attached - consider complete (naked bead)
|
||
return true, false
|
||
}
|
||
|
||
// Get progress of attached molecule
|
||
progress, err := getMoleculeProgressInfo(b, attachment.AttachedMolecule)
|
||
if err != nil {
|
||
// Can't determine progress - be conservative, treat as incomplete
|
||
return false, true
|
||
}
|
||
|
||
if progress == nil {
|
||
// No steps found - might be a simple issue, treat as complete
|
||
return true, true
|
||
}
|
||
|
||
return progress.Complete, true
|
||
}
|
||
|
||
|