From 1e2a068b2a1eb74017164ef019c2c108ca08ebfb Mon Sep 17 00:00:00 2001 From: valkyrie Date: Thu, 1 Jan 2026 19:07:29 -0800 Subject: [PATCH] feat(cmd): Add --collect to handoff and --handoff to resume (gt-1le) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --collect/-c flag to gt handoff: auto-collects hooked work, inbox summary, ready beads, and in-progress items into the handoff message body - Add --handoff flag to gt resume: checks inbox for messages with "HANDOFF" in subject and displays them formatted for continuation - Supports both JSON and plain-text inbox output formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/handoff.go | 82 +++++++++++++++++++++++++ internal/cmd/resume.go | 132 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 207 insertions(+), 7 deletions(-) diff --git a/internal/cmd/handoff.go b/internal/cmd/handoff.go index 741a70a6..b1597dbf 100644 --- a/internal/cmd/handoff.go +++ b/internal/cmd/handoff.go @@ -36,9 +36,14 @@ Examples: 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 -c # Collect state into handoff message gt handoff crew # Hand off crew session gt handoff mayor # Hand off mayor session +The --collect (-c) flag gathers current state (hooked work, inbox, ready beads, +in-progress items) and includes it in the handoff mail. This provides context +for the next session without manual summarization. + Any molecule on the hook will be auto-continued by the new session. The SessionStart hook runs 'gt prime' to restore context.`, RunE: runHandoff, @@ -49,6 +54,7 @@ var ( handoffDryRun bool handoffSubject string handoffMessage string + handoffCollect bool ) func init() { @@ -56,6 +62,7 @@ func init() { handoffCmd.Flags().BoolVarP(&handoffDryRun, "dry-run", "n", false, "Show what would be done without executing") handoffCmd.Flags().StringVarP(&handoffSubject, "subject", "s", "", "Subject for handoff mail (optional)") handoffCmd.Flags().StringVarP(&handoffMessage, "message", "m", "", "Message body for handoff mail (optional)") + handoffCmd.Flags().BoolVarP(&handoffCollect, "collect", "c", false, "Auto-collect state (status, inbox, beads) into handoff message") rootCmd.AddCommand(handoffCmd) } @@ -73,6 +80,19 @@ func runHandoff(cmd *cobra.Command, args []string) error { return doneCmd.Run() } + // If --collect flag is set, auto-collect state into the message + if handoffCollect { + collected := collectHandoffState() + if handoffMessage == "" { + handoffMessage = collected + } else { + handoffMessage = handoffMessage + "\n\n---\n" + collected + } + if handoffSubject == "" { + handoffSubject = "Session handoff with context" + } + } + t := tmux.NewTmux() // Verify we're in tmux @@ -606,3 +626,65 @@ func hookBeadForHandoff(beadID string) error { fmt.Printf("%s Work attached to hook (pinned bead)\n", style.Bold.Render("✓")) return nil } + +// collectHandoffState gathers current state for handoff context. +// Collects: inbox summary, ready beads, hooked work. +func collectHandoffState() string { + var parts []string + + // Get hooked work + hookOutput, err := exec.Command("gt", "hook").Output() + if err == nil { + hookStr := strings.TrimSpace(string(hookOutput)) + if hookStr != "" && !strings.Contains(hookStr, "Nothing on hook") { + parts = append(parts, "## Hooked Work\n"+hookStr) + } + } + + // Get inbox summary (first few messages) + inboxOutput, err := exec.Command("gt", "mail", "inbox").Output() + if err == nil { + inboxStr := strings.TrimSpace(string(inboxOutput)) + if inboxStr != "" && !strings.Contains(inboxStr, "Inbox empty") { + // Limit to first 10 lines for brevity + lines := strings.Split(inboxStr, "\n") + if len(lines) > 10 { + lines = append(lines[:10], "... (more messages)") + } + parts = append(parts, "## Inbox\n"+strings.Join(lines, "\n")) + } + } + + // Get ready beads + readyOutput, err := exec.Command("bd", "ready").Output() + if err == nil { + readyStr := strings.TrimSpace(string(readyOutput)) + if readyStr != "" && !strings.Contains(readyStr, "No issues ready") { + // Limit to first 10 lines + lines := strings.Split(readyStr, "\n") + if len(lines) > 10 { + lines = append(lines[:10], "... (more issues)") + } + parts = append(parts, "## Ready Work\n"+strings.Join(lines, "\n")) + } + } + + // Get in-progress beads + inProgressOutput, err := exec.Command("bd", "list", "--status=in_progress").Output() + if err == nil { + ipStr := strings.TrimSpace(string(inProgressOutput)) + if ipStr != "" && !strings.Contains(ipStr, "No issues") { + lines := strings.Split(ipStr, "\n") + if len(lines) > 5 { + lines = append(lines[:5], "... (more)") + } + parts = append(parts, "## In Progress\n"+strings.Join(lines, "\n")) + } + } + + if len(parts) == 0 { + return "No active state to report." + } + + return strings.Join(parts, "\n\n") +} diff --git a/internal/cmd/resume.go b/internal/cmd/resume.go index 155bcd98..5ea83f33 100644 --- a/internal/cmd/resume.go +++ b/internal/cmd/resume.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "strings" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/style" @@ -15,32 +16,38 @@ import ( var resumeCmd = &cobra.Command{ Use: "resume", GroupID: GroupWork, - Short: "Resume from parked work when gate clears", - Long: `Resume work that was parked on a gate. + Short: "Resume from parked work or check for handoff messages", + Long: `Resume work that was parked on a gate, or check for handoff messages. -This command checks if you have parked work and whether its gate has cleared. -If the gate is closed, it restores your work context so you can continue. +By default, this command checks for parked work (from 'gt park') and whether +its gate has cleared. If the gate is closed, it restores your work context. + +With --handoff, it checks the inbox for handoff messages (messages with +"HANDOFF" in the subject) and displays them formatted for easy continuation. The resume command: - 1. Checks for parked work state - 2. Verifies the gate has closed + 1. Checks for parked work state (default) or handoff messages (--handoff) + 2. For parked work: verifies gate has closed 3. Restores the hook with your previous work 4. Displays context notes to help you continue Examples: gt resume # Check for and resume parked work - gt resume --status # Just show parked work status without resuming`, + gt resume --status # Just show parked work status without resuming + gt resume --handoff # Check inbox for handoff messages`, RunE: runResume, } var ( resumeStatusOnly bool resumeJSON bool + resumeHandoff bool ) func init() { resumeCmd.Flags().BoolVar(&resumeStatusOnly, "status", false, "Just show parked work status") resumeCmd.Flags().BoolVar(&resumeJSON, "json", false, "Output as JSON") + resumeCmd.Flags().BoolVar(&resumeHandoff, "handoff", false, "Check for handoff messages instead of parked work") rootCmd.AddCommand(resumeCmd) } @@ -54,6 +61,11 @@ type ResumeStatus struct { } func runResume(cmd *cobra.Command, args []string) error { + // If --handoff flag, check for handoff messages instead + if resumeHandoff { + return checkHandoffMessages() + } + // Detect agent identity agentID, _, cloneRoot, err := resolveSelfTarget() if err != nil { @@ -208,3 +220,109 @@ func displayResumeStatus(status ResumeStatus, parked *ParkedWork) error { return nil } + +// checkHandoffMessages checks the inbox for handoff messages and displays them. +func checkHandoffMessages() error { + // Get inbox in JSON format + inboxCmd := exec.Command("gt", "mail", "inbox", "--json") + output, err := inboxCmd.Output() + if err != nil { + // Fallback to non-JSON if --json not supported + inboxCmd = exec.Command("gt", "mail", "inbox") + output, err = inboxCmd.Output() + if err != nil { + return fmt.Errorf("checking inbox: %w", err) + } + // Check for HANDOFF in output + outputStr := string(output) + if !containsHandoff(outputStr) { + fmt.Printf("%s No handoff messages in inbox\n", style.Dim.Render("○")) + fmt.Printf(" Handoff messages have 'HANDOFF' in the subject.\n") + return nil + } + fmt.Printf("%s Found handoff message(s):\n\n", style.Bold.Render("🤝")) + fmt.Println(outputStr) + fmt.Printf("\n%s Read with: gt mail read \n", style.Bold.Render("→")) + return nil + } + + // Parse JSON output to find handoff messages + var messages []struct { + ID string `json:"id"` + Subject string `json:"subject"` + From string `json:"from"` + Date string `json:"date"` + Body string `json:"body"` + } + if err := json.Unmarshal(output, &messages); err != nil { + // JSON parse failed, use plain text output + inboxCmd = exec.Command("gt", "mail", "inbox") + output, _ = inboxCmd.Output() + outputStr := string(output) + if containsHandoff(outputStr) { + fmt.Printf("%s Found handoff message(s):\n\n", style.Bold.Render("🤝")) + fmt.Println(outputStr) + } else { + fmt.Printf("%s No handoff messages in inbox\n", style.Dim.Render("○")) + } + return nil + } + + // Find messages with HANDOFF in subject + type handoffMsg struct { + ID string + Subject string + From string + Date string + Body string + } + var handoffs []handoffMsg + for _, msg := range messages { + if containsHandoff(msg.Subject) { + handoffs = append(handoffs, handoffMsg{ + ID: msg.ID, + Subject: msg.Subject, + From: msg.From, + Date: msg.Date, + Body: msg.Body, + }) + } + } + + if len(handoffs) == 0 { + fmt.Printf("%s No handoff messages in inbox\n", style.Dim.Render("○")) + fmt.Printf(" Handoff messages have 'HANDOFF' in the subject.\n") + fmt.Printf(" Use 'gt handoff -s \"...\"' to create one when handing off.\n") + return nil + } + + fmt.Printf("%s Found %d handoff message(s):\n\n", style.Bold.Render("🤝"), len(handoffs)) + + for i, msg := range handoffs { + fmt.Printf("--- Handoff %d: %s ---\n", i+1, msg.ID) + fmt.Printf("Subject: %s\n", msg.Subject) + fmt.Printf("From: %s\n", msg.From) + if msg.Date != "" { + fmt.Printf("Date: %s\n", msg.Date) + } + if msg.Body != "" { + fmt.Printf("\n%s\n", msg.Body) + } + fmt.Println() + } + + if len(handoffs) == 1 { + fmt.Printf("%s Read full message: gt mail read %s\n", style.Bold.Render("→"), handoffs[0].ID) + } else { + fmt.Printf("%s Read messages: gt mail read \n", style.Bold.Render("→")) + } + fmt.Printf("%s Clear after reading: gt mail close \n", style.Dim.Render("💡")) + + return nil +} + +// containsHandoff checks if a string contains "HANDOFF" (case-insensitive). +func containsHandoff(s string) bool { + upper := strings.ToUpper(s) + return strings.Contains(upper, "HANDOFF") +}