feat(cmd): Add --collect to handoff and --handoff to resume (gt-1le)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 <id>\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 <id>\n", style.Bold.Render("→"))
|
||||
}
|
||||
fmt.Printf("%s Clear after reading: gt mail close <id>\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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user