refactor: split gt sling into gt hook + gt handoff <bead> (gt-z4bw)

- Add gt hook <bead>: durability primitive, attaches work to hook
- Update gt handoff: accept optional bead arg (detects bead vs role)
- Deprecate gt sling: shows warning, points to new commands
- Update doctor fix hint to reference new 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-24 20:41:51 -08:00
parent c4dda5c055
commit 32cac078e5
5 changed files with 271 additions and 14 deletions

View File

@@ -10,10 +10,11 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp"
)
var handoffCmd = &cobra.Command{
Use: "handoff [role]",
Use: "handoff [bead-or-role]",
Short: "Hand off to a fresh session, work continues from hook",
Long: `End watch. Hand off to a fresh agent session.
@@ -23,10 +24,13 @@ This is the canonical way to end any agent session. It handles all roles:
- Polecats: Calls 'gt done --exit DEFERRED' (Witness handles lifecycle)
When run without arguments, hands off the current session.
When given a bead ID (gt-xxx, hq-xxx), hooks that work first, then restarts.
When given a role name, hands off that role's session (and switches to it).
Examples:
gt handoff # Hand off current session
gt handoff gt-abc # Hook bead, then restart
gt handoff gt-abc -s "Fix it" # Hook with context, then restart
gt handoff -s "Context" -m "Notes" # Hand off with custom message
gt handoff crew # Hand off crew session
gt handoff mayor # Hand off mayor session
@@ -83,13 +87,27 @@ func runHandoff(cmd *cobra.Command, args []string) error {
return fmt.Errorf("getting session name: %w", err)
}
// Determine target session
// Determine target session and check for bead hook
targetSession := currentSession
if len(args) > 0 {
// User specified a role to hand off
targetSession, err = resolveRoleToSession(args[0])
if err != nil {
return fmt.Errorf("resolving role: %w", err)
arg := args[0]
// Check if arg is a bead ID (gt-xxx, hq-xxx, bd-xxx, etc.)
if looksLikeBeadID(arg) {
// Hook the bead first
if err := hookBeadForHandoff(arg); err != nil {
return fmt.Errorf("hooking bead: %w", err)
}
// Update subject if not set
if handoffSubject == "" {
handoffSubject = fmt.Sprintf("🪝 HOOKED: %s", arg)
}
} else {
// User specified a role to hand off
targetSession, err = resolveRoleToSession(arg)
if err != nil {
return fmt.Errorf("resolving role: %w", err)
}
}
}
@@ -395,3 +413,57 @@ func sendHandoffMail(subject, message string) error {
cmd.Stderr = os.Stderr
return cmd.Run()
}
// looksLikeBeadID checks if a string looks like a bead ID.
// Bead IDs have format: prefix-xxxx where prefix is 2+ letters and xxxx is alphanumeric.
func looksLikeBeadID(s string) bool {
// Common bead prefixes
prefixes := []string{"gt-", "hq-", "bd-", "beads-"}
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
// hookBeadForHandoff attaches a bead to the current agent's hook.
func hookBeadForHandoff(beadID string) error {
// Verify the bead exists first
verifyCmd := exec.Command("bd", "show", beadID, "--json")
if err := verifyCmd.Run(); err != nil {
return fmt.Errorf("bead '%s' not found", beadID)
}
// Determine agent identity
agentID, err := detectAgentIdentity()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
// Get clone root for wisp storage
cloneRoot, err := detectCloneRoot()
if err != nil {
return fmt.Errorf("detecting clone root: %w", err)
}
// Create the slung work wisp
sw := wisp.NewSlungWork(beadID, agentID)
sw.Subject = handoffSubject
sw.Context = handoffMessage
fmt.Printf("%s Hooking %s...\n", style.Bold.Render("🪝"), beadID)
if handoffDryRun {
fmt.Printf("Would create wisp: %s\n", wisp.HookPath(cloneRoot, agentID))
return nil
}
// Write the wisp to the hook
if err := wisp.WriteSlungWork(cloneRoot, agentID, sw); err != nil {
return fmt.Errorf("writing wisp: %w", err)
}
fmt.Printf("%s Work attached to hook\n", style.Bold.Render("✓"))
return nil
}

176
internal/cmd/hook.go Normal file
View File

@@ -0,0 +1,176 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/wisp"
)
var hookCmd = &cobra.Command{
Use: "hook <bead-id>",
Short: "Attach work to your hook (durable across restarts)",
Long: `Attach a bead (issue) to your hook for durable work tracking.
The hook is a lightweight, ephemeral attachment point. When you restart
(via gt handoff), your SessionStart hook finds the slung work and you
continue from where you left off.
This is the "durability primitive" - work on your hook survives session
restarts. For the full restart-and-resume flow, use: gt handoff <bead>
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 handoff <bead> # Hook + restart in one step
gt handoff # Restart (uses existing hook if present)`,
Args: cobra.ExactArgs(1),
RunE: runHook,
}
var (
hookSubject string
hookMessage string
hookDryRun 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")
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 := detectAgentIdentity()
if err != nil {
return fmt.Errorf("detecting agent identity: %w", err)
}
// Get cwd for wisp storage (use clone root, not town root)
cloneRoot, err := detectCloneRoot()
if err != nil {
return fmt.Errorf("detecting clone root: %w", err)
}
// Create the slung work wisp
sw := wisp.NewSlungWork(beadID, agentID)
sw.Subject = hookSubject
sw.Context = hookMessage
fmt.Printf("%s Hooking %s...\n", style.Bold.Render("🪝"), beadID)
if hookDryRun {
fmt.Printf("Would create wisp: %s\n", wisp.HookPath(cloneRoot, agentID))
fmt.Printf(" bead_id: %s\n", beadID)
fmt.Printf(" agent: %s\n", agentID)
if hookSubject != "" {
fmt.Printf(" subject: %s\n", hookSubject)
}
if hookMessage != "" {
fmt.Printf(" context: %s\n", hookMessage)
}
return nil
}
// Write the wisp to the hook
if err := wisp.WriteSlungWork(cloneRoot, agentID, sw); err != nil {
return fmt.Errorf("writing wisp: %w", err)
}
fmt.Printf("%s Work attached to hook\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
}
// 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
}
// detectAgentIdentityForHook figures out who we are (crew/joe, witness, etc).
// Duplicated from sling.go - will be consolidated when sling.go is removed.
func detectAgentIdentityForHook() (string, error) {
// Check environment first
if crew := os.Getenv("GT_CREW"); crew != "" {
if rig := os.Getenv("GT_RIG"); rig != "" {
return fmt.Sprintf("%s/crew/%s", rig, crew), nil
}
}
// Check if we're a polecat
if polecat := os.Getenv("GT_POLECAT"); polecat != "" {
if rig := os.Getenv("GT_RIG"); rig != "" {
return fmt.Sprintf("%s/polecats/%s", rig, polecat), nil
}
}
// Try to detect from cwd
detected, err := detectCrewFromCwd()
if err == nil {
return fmt.Sprintf("%s/crew/%s", detected.rigName, detected.crewName), nil
}
// Check for other role markers in session name
if session := os.Getenv("TMUX"); session != "" {
sessionName, err := getCurrentTmuxSession()
if err == nil {
if sessionName == "gt-mayor" {
return "mayor", nil
}
if sessionName == "gt-deacon" {
return "deacon", nil
}
if strings.HasSuffix(sessionName, "-witness") {
rig := strings.TrimSuffix(strings.TrimPrefix(sessionName, "gt-"), "-witness")
return fmt.Sprintf("%s/witness", rig), nil
}
if strings.HasSuffix(sessionName, "-refinery") {
rig := strings.TrimSuffix(strings.TrimPrefix(sessionName, "gt-"), "-refinery")
return fmt.Sprintf("%s/refinery", rig), nil
}
}
}
return "", fmt.Errorf("cannot determine agent identity - set GT_RIG/GT_CREW or run from clone directory")
}
// detectCloneRootForHook finds the root of the current git clone.
// Duplicated from sling.go - will be consolidated when sling.go is removed.
func detectCloneRootForHook() (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not in a git repository")
}
return strings.TrimSpace(string(out)), nil
}

View File

@@ -13,17 +13,20 @@ import (
)
var slingCmd = &cobra.Command{
Use: "sling <bead-id>",
Short: "Attach work to hook and restart agent",
Long: `Sling work onto the agent's hook and restart with that context.
Use: "sling <bead-id>",
Short: "[DEPRECATED] Use 'gt hook' or 'gt handoff <bead>' instead",
Deprecated: "Use 'gt hook <bead>' to attach work, or 'gt handoff <bead>' to attach and restart.",
Long: `DEPRECATED: This command is deprecated. Use instead:
gt hook <bead> # Just attach work to hook (no restart)
gt handoff <bead> # Attach work AND restart (what sling did)
Sling work onto the agent's hook and restart with that context.
This is the "restart-and-resume" mechanism - attach a bead (issue) to your hook,
then restart with a fresh context. The new session wakes up, finds the slung work
on its hook, and begins working on it immediately.
The wisp is ephemeral (stored in .beads-wisp/, not git-tracked). It's burned
after the agent picks it up.
Examples:
gt sling gt-abc # Attach issue and restart
gt sling gt-abc -s "Fix the bug" # With handoff subject