Files
gastown/internal/cmd/hook.go
Steve Yegge 6e68e12c2a feat: Auto-replace completed pins in gt hook (gt-lsfqq)
When hooking a new bead, if the existing pinned bead is complete
(no attached molecule, or all molecule steps closed), auto-replace it.

If the existing bead is incomplete, require --force flag to replace.

This reduces friction when switching between work items, especially
for patrol loops creating new wisps after completing old ones.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:47:26 -08:00

197 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
)
var hookCmd = &cobra.Command{
Use: "hook <bead-id>",
GroupID: GroupWork,
Short: "Attach work to your hook (durable across restarts)",
Long: `Attach a bead (issue) to your hook for durable work tracking.
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.
This is "assign without action" - use gt sling to also start immediately,
or gt handoff to hook and restart with fresh context.
Examples:
gt hook gt-abc # Attach issue gt-abc to your hook
gt hook gt-abc -s "Fix the bug" # With subject for handoff mail
gt hook gt-abc -m "Check tests" # With context message
Related commands:
gt mol status # See what's on your hook
gt sling <bead> # Hook + start now (keep context)
gt handoff <bead> # Hook + restart (fresh context)
gt nudge <agent> # Send message to trigger execution`,
Args: cobra.ExactArgs(1),
RunE: runHook,
}
var (
hookSubject string
hookMessage string
hookDryRun bool
hookForce bool
)
func init() {
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 pinned bead")
rootCmd.AddCommand(hookCmd)
}
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 pinned bead for this agent
existingPinned, err := b.List(beads.ListOptions{
Status: beads.StatusPinned,
Assignee: agentID,
Priority: -1,
})
if err != nil {
return fmt.Errorf("checking existing pinned beads: %w", err)
}
// If there's an existing pinned 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 := checkPinnedBeadComplete(b, existing)
if isComplete {
// Auto-replace completed bead
fmt.Printf("%s Replacing completed bead %s...\n", style.Dim.Render(""), existing.ID)
if !hookDryRun {
// Close the old bead
if err := b.Close(existing.ID); err != nil {
return fmt.Errorf("closing completed 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 pinned 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=pinned --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
}
// Pin the bead using bd update (discovery-based approach)
pinCmd := exec.Command("bd", "update", beadID, "--status=pinned", "--assignee="+agentID)
pinCmd.Stderr = os.Stderr
if err := pinCmd.Run(); err != nil {
return fmt.Errorf("pinning bead: %w", err)
}
fmt.Printf("%s Work attached to hook (pinned bead)\n", style.Bold.Render("✓"))
fmt.Printf(" Use 'gt handoff' to restart with this work\n")
fmt.Printf(" Use 'gt mol status' to see hook status\n")
return nil
}
// checkPinnedBeadComplete checks if a pinned bead's attached molecule is 100% complete.
// Returns true if:
// - No molecule attached (naked bead = complete for hook purposes)
// - Molecule has all steps closed
func checkPinnedBeadComplete(b *beads.Beads, issue *beads.Issue) bool {
// Check for attached molecule
attachment := beads.ParseAttachmentFields(issue)
if attachment == nil || attachment.AttachedMolecule == "" {
// No molecule attached - consider complete (naked bead)
return true
}
// 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
}
if progress == nil {
// No steps found - might be a simple issue, treat as complete
return true
}
return progress.Complete
}
// verifyBeadExists checks that the bead exists using bd show.
// Defined in sling.go but duplicated here for clarity. Will be consolidated
// when sling.go is removed.
func verifyBeadExistsForHook(beadID string) error {
cmd := exec.Command("bd", "show", beadID, "--json")
if err := cmd.Run(); err != nil {
return fmt.Errorf("bead '%s' not found (bd show failed)", beadID)
}
return nil
}