From 9f14db8f76ab1a3c70c4b9b0f41979abd2ca5a3e Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 23:53:17 -0800 Subject: [PATCH] Add gt park/resume commands for async gate coordination (gt-twjr5.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements agent parking and resumption on gates: - gt park : Parks work on a gate, saves context - gt resume: Checks for cleared gates and restores work - gt gate wake: Sends wake mail to waiters when gate closes These commands enable agents to safely suspend work while waiting for external conditions (timers, CI, human approval) and resume when gates clear. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/gate.go | 186 ++++++++++++++++++++++++++++++ internal/cmd/park.go | 249 +++++++++++++++++++++++++++++++++++++++++ internal/cmd/resume.go | 204 +++++++++++++++++++++++++++++++++ 3 files changed, 639 insertions(+) create mode 100644 internal/cmd/gate.go create mode 100644 internal/cmd/park.go create mode 100644 internal/cmd/resume.go diff --git a/internal/cmd/gate.go b/internal/cmd/gate.go new file mode 100644 index 00000000..6f45dd65 --- /dev/null +++ b/internal/cmd/gate.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Gate command provides gt wrappers for gate operations. +// Most gate commands are in beads (bd gate ...), but gt provides +// integration with the Gas Town mail system for wake notifications. + +var gateCmd = &cobra.Command{ + Use: "gate", + GroupID: GroupWork, + Short: "Gate coordination commands", + Long: `Gate commands for async coordination. + +Most gate commands are in beads: + bd gate create - Create a gate (timer, gh:run, human, mail) + bd gate show - Show gate details + bd gate list - List open gates + bd gate close - Close a gate + bd gate approve - Approve a human gate + bd gate eval - Evaluate and close elapsed gates + +The gt gate command provides Gas Town integration: + gt gate wake - Send wake mail to gate waiters after close`, +} + +var gateWakeCmd = &cobra.Command{ + Use: "wake ", + Short: "Send wake mail to gate waiters", + Long: `Send wake mail to all waiters on a gate. + +This command should be called after a gate closes to notify waiting agents. +Typically called by Deacon after 'bd gate eval' or after manual gate close. + +The wake mail includes: + - Gate ID and close reason + - Instructions to run 'gt resume' + +Examples: + # After manual gate close + bd gate close gt-xxx --reason "Approved" + gt gate wake gt-xxx + + # In Deacon patrol after gate eval + for gate in $(bd gate eval --json | jq -r '.closed[]'); do + gt gate wake $gate + done`, + Args: cobra.ExactArgs(1), + RunE: runGateWake, +} + +var ( + gateWakeJSON bool + gateWakeDryRun bool +) + +func init() { + gateWakeCmd.Flags().BoolVar(&gateWakeJSON, "json", false, "Output as JSON") + gateWakeCmd.Flags().BoolVarP(&gateWakeDryRun, "dry-run", "n", false, "Show what would be done") + + gateCmd.AddCommand(gateWakeCmd) + rootCmd.AddCommand(gateCmd) +} + +// GateWakeResult represents the result of sending wake mail. +type GateWakeResult struct { + GateID string `json:"gate_id"` + CloseReason string `json:"close_reason"` + Waiters []string `json:"waiters"` + Notified []string `json:"notified"` + Failed []string `json:"failed,omitempty"` +} + +func runGateWake(cmd *cobra.Command, args []string) error { + gateID := args[0] + + // Get gate info + gateCheck := exec.Command("bd", "gate", "show", gateID, "--json") + gateOutput, err := gateCheck.Output() + if err != nil { + return fmt.Errorf("gate '%s' not found or not accessible", gateID) + } + + var gateInfo struct { + ID string `json:"id"` + Status string `json:"status"` + CloseReason string `json:"close_reason"` + Waiters []string `json:"waiters"` + } + if err := json.Unmarshal(gateOutput, &gateInfo); err != nil { + return fmt.Errorf("parsing gate info: %w", err) + } + + if gateInfo.Status != "closed" { + return fmt.Errorf("gate '%s' is not closed (status: %s) - wake mail only sent for closed gates", gateID, gateInfo.Status) + } + + if len(gateInfo.Waiters) == 0 { + if gateWakeJSON { + result := GateWakeResult{ + GateID: gateID, + CloseReason: gateInfo.CloseReason, + Waiters: []string{}, + Notified: []string{}, + } + return outputGateWakeResult(result) + } + fmt.Printf("%s Gate %s has no waiters to notify\n", style.Dim.Render("○"), gateID) + return nil + } + + if gateWakeDryRun { + fmt.Printf("Would send wake mail for gate %s to:\n", gateID) + for _, w := range gateInfo.Waiters { + fmt.Printf(" - %s\n", w) + } + return nil + } + + // Find town root for mail routing + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding town root: %w", err) + } + + router := mail.NewRouter(townRoot) + + result := GateWakeResult{ + GateID: gateID, + CloseReason: gateInfo.CloseReason, + Waiters: gateInfo.Waiters, + Notified: []string{}, + Failed: []string{}, + } + + subject := fmt.Sprintf("🚦 GATE CLEARED: %s", gateID) + body := fmt.Sprintf("Gate %s has closed.\n\nReason: %s\n\nRun 'gt resume' to continue your parked work.", + gateID, gateInfo.CloseReason) + + for _, waiter := range gateInfo.Waiters { + msg := &mail.Message{ + From: "deacon/", + To: waiter, + Subject: subject, + Body: body, + Type: mail.TypeNotification, + Priority: mail.PriorityHigh, + Wisp: true, + } + if err := router.Send(msg); err != nil { + result.Failed = append(result.Failed, waiter) + } else { + result.Notified = append(result.Notified, waiter) + } + } + + if gateWakeJSON { + return outputGateWakeResult(result) + } + + fmt.Printf("%s Sent wake mail for gate %s\n", style.Bold.Render("🚦"), gateID) + if len(result.Notified) > 0 { + fmt.Printf(" Notified: %v\n", result.Notified) + } + if len(result.Failed) > 0 { + fmt.Printf(" Failed: %v\n", result.Failed) + } + + return nil +} + +func outputGateWakeResult(result GateWakeResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} diff --git a/internal/cmd/park.go b/internal/cmd/park.go new file mode 100644 index 00000000..56e6ca68 --- /dev/null +++ b/internal/cmd/park.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/wisp" + "github.com/steveyegge/gastown/internal/workspace" +) + +// Park command parks work on a gate, allowing agent to exit safely. +// When the gate closes, waiters are notified and can resume. + +var parkCmd = &cobra.Command{ + Use: "park ", + GroupID: GroupWork, + Short: "Park work on a gate for async resumption", + Long: `Park current work on a gate, allowing the agent to exit safely. + +When you need to wait for an external condition (timer, CI, human approval), +park your work on a gate. When the gate closes, you'll receive wake mail. + +The park command: + 1. Saves your current hook state (molecule/bead you're working on) + 2. Adds you as a waiter on the gate + 3. Stores context notes in the parked state + +After parking, you can exit the session safely. Use 'gt resume' to check +for cleared gates and continue work. + +Examples: + # Create a timer gate and park work on it + bd gate create --await timer:30m --title "Coffee break" + gt park -m "Taking a break, will resume auth work" + + # Park on a human approval gate + bd gate create --await human:deploy-approval --notify ops/ + gt park -m "Deploy staged, awaiting approval" + + # Park on a GitHub Actions gate + bd gate create --await gh:run:123456789 + gt park -m "Waiting for CI to complete"`, + Args: cobra.ExactArgs(1), + RunE: runPark, +} + +var ( + parkMessage string + parkDryRun bool +) + +func init() { + parkCmd.Flags().StringVarP(&parkMessage, "message", "m", "", "Context notes for resumption") + parkCmd.Flags().BoolVarP(&parkDryRun, "dry-run", "n", false, "Show what would be done without executing") + rootCmd.AddCommand(parkCmd) +} + +// ParkedWork represents work state saved when parking on a gate. +type ParkedWork struct { + // AgentID is the agent that parked (e.g., "gastown/crew/max") + AgentID string `json:"agent_id"` + + // GateID is the gate we're parked on + GateID string `json:"gate_id"` + + // BeadID is the bead/molecule we were working on + BeadID string `json:"bead_id,omitempty"` + + // Formula is the formula attached to the work (if any) + Formula string `json:"formula,omitempty"` + + // Context is additional context notes from the agent + Context string `json:"context,omitempty"` + + // ParkedAt is when the work was parked + ParkedAt time.Time `json:"parked_at"` +} + +func runPark(cmd *cobra.Command, args []string) error { + gateID := args[0] + + // Verify gate exists and is open + gateCheck := exec.Command("bd", "gate", "show", gateID, "--json") + gateOutput, err := gateCheck.Output() + if err != nil { + return fmt.Errorf("gate '%s' not found or not accessible", gateID) + } + + // Parse gate info to verify it's open + var gateInfo struct { + ID string `json:"id"` + Status string `json:"status"` + } + if err := json.Unmarshal(gateOutput, &gateInfo); err != nil { + return fmt.Errorf("parsing gate info: %w", err) + } + if gateInfo.Status == "closed" { + return fmt.Errorf("gate '%s' is already closed - nothing to park on", gateID) + } + + // Detect agent identity + agentID, _, cloneRoot, err := resolveSelfTarget() + if err != nil { + return fmt.Errorf("detecting agent identity: %w", err) + } + + // Read current hook state (if any) + var beadID, formula, hookContext string + if hook, err := wisp.ReadHook(cloneRoot, agentID); err == nil { + beadID = hook.BeadID + formula = hook.Formula + hookContext = hook.Context + } + + // Build context combining hook context and new message + context := "" + if hookContext != "" && parkMessage != "" { + context = hookContext + "\n---\n" + parkMessage + } else if hookContext != "" { + context = hookContext + } else if parkMessage != "" { + context = parkMessage + } + + // Create parked work record + parked := &ParkedWork{ + AgentID: agentID, + GateID: gateID, + BeadID: beadID, + Formula: formula, + Context: context, + ParkedAt: time.Now(), + } + + if parkDryRun { + fmt.Printf("Would park on gate %s\n", gateID) + fmt.Printf(" Agent: %s\n", agentID) + if beadID != "" { + fmt.Printf(" Bead: %s\n", beadID) + } + if formula != "" { + fmt.Printf(" Formula: %s\n", formula) + } + if context != "" { + fmt.Printf(" Context: %s\n", context) + } + fmt.Printf("Would add %s as waiter on gate\n", agentID) + return nil + } + + // Add agent as waiter on the gate + waitCmd := exec.Command("bd", "gate", "wait", gateID, "--notify", agentID) + if err := waitCmd.Run(); err != nil { + // Not fatal - might already be a waiter + fmt.Printf("%s Note: could not add as waiter (may already be registered)\n", style.Dim.Render("⚠")) + } + + // Store parked work in a file (alongside hook files) + parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_"))) + parkedJSON, err := json.MarshalIndent(parked, "", " ") + if err != nil { + return fmt.Errorf("marshaling parked work: %w", err) + } + if err := os.WriteFile(parkedPath, parkedJSON, 0644); err != nil { + return fmt.Errorf("writing parked state: %w", err) + } + + fmt.Printf("%s Parked work on gate %s\n", style.Bold.Render("🅿️"), gateID) + if beadID != "" { + fmt.Printf(" Working on: %s\n", beadID) + } + if context != "" { + // Truncate for display + displayContext := context + if len(displayContext) > 80 { + displayContext = displayContext[:77] + "..." + } + fmt.Printf(" Context: %s\n", displayContext) + } + fmt.Printf("\n%s You can now safely exit. Run 'gt resume' to check for cleared gates.\n", + style.Dim.Render("→")) + + return nil +} + +// readParkedWork reads the parked work state for an agent. +func readParkedWork(cloneRoot, agentID string) (*ParkedWork, error) { + parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_"))) + data, err := os.ReadFile(parkedPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var parked ParkedWork + if err := json.Unmarshal(data, &parked); err != nil { + return nil, err + } + return &parked, nil +} + +// clearParkedWork removes the parked work state for an agent. +func clearParkedWork(cloneRoot, agentID string) error { + parkedPath := wisp.WispPath(cloneRoot, fmt.Sprintf("parked-%s.json", strings.ReplaceAll(agentID, "/", "_"))) + err := os.Remove(parkedPath) + if os.IsNotExist(err) { + return nil + } + return err +} + +// sendGateWakeMail sends wake mail to all waiters when a gate closes. +// This should be called after gate close (from Deacon or gate eval). +func sendGateWakeMail(gateID, closeReason string, waiters []string) error { + // Find town root for mail routing + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding town root: %w", err) + } + + router := mail.NewRouter(townRoot) + + for _, waiter := range waiters { + msg := &mail.Message{ + From: "deacon/", + To: waiter, + Subject: fmt.Sprintf("🚦 GATE CLEARED: %s", gateID), + Body: fmt.Sprintf("Gate %s has closed.\n\nReason: %s\n\nRun 'gt resume' to continue your parked work.", gateID, closeReason), + Type: mail.TypeNotification, + Priority: mail.PriorityHigh, + Wisp: true, + } + if err := router.Send(msg); err != nil { + // Log but don't fail on individual send errors + fmt.Fprintf(os.Stderr, "Warning: failed to send wake mail to %s: %v\n", waiter, err) + } + } + + return nil +} diff --git a/internal/cmd/resume.go b/internal/cmd/resume.go new file mode 100644 index 00000000..526ae45c --- /dev/null +++ b/internal/cmd/resume.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/wisp" +) + +// Resume command checks for cleared gates and resumes parked work. + +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. + +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. + +The resume command: + 1. Checks for parked work state + 2. Verifies the 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`, + RunE: runResume, +} + +var ( + resumeStatusOnly bool + resumeJSON bool +) + +func init() { + resumeCmd.Flags().BoolVar(&resumeStatusOnly, "status", false, "Just show parked work status") + resumeCmd.Flags().BoolVar(&resumeJSON, "json", false, "Output as JSON") + rootCmd.AddCommand(resumeCmd) +} + +// ResumeStatus represents the current resume state. +type ResumeStatus struct { + HasParkedWork bool `json:"has_parked_work"` + ParkedWork *ParkedWork `json:"parked_work,omitempty"` + GateClosed bool `json:"gate_closed"` + CloseReason string `json:"close_reason,omitempty"` + CanResume bool `json:"can_resume"` +} + +func runResume(cmd *cobra.Command, args []string) error { + // Detect agent identity + agentID, _, cloneRoot, err := resolveSelfTarget() + if err != nil { + return fmt.Errorf("detecting agent identity: %w", err) + } + + // Check for parked work + parked, err := readParkedWork(cloneRoot, agentID) + if err != nil { + return fmt.Errorf("reading parked work: %w", err) + } + + status := ResumeStatus{ + HasParkedWork: parked != nil, + ParkedWork: parked, + } + + if parked == nil { + if resumeJSON { + return outputResumeStatus(status) + } + fmt.Printf("%s No parked work found\n", style.Dim.Render("○")) + fmt.Printf(" Use 'gt park ' to park work on a gate\n") + return nil + } + + // Check gate status + gateCheck := exec.Command("bd", "gate", "show", parked.GateID, "--json") + gateOutput, err := gateCheck.Output() + if err != nil { + // Gate might have been deleted or is inaccessible + status.GateClosed = false + status.CloseReason = "Gate not accessible" + } else { + var gateInfo struct { + ID string `json:"id"` + Status string `json:"status"` + CloseReason string `json:"close_reason"` + } + if err := json.Unmarshal(gateOutput, &gateInfo); err == nil { + status.GateClosed = gateInfo.Status == "closed" + status.CloseReason = gateInfo.CloseReason + } + } + + status.CanResume = status.GateClosed + + // Status-only mode + if resumeStatusOnly { + if resumeJSON { + return outputResumeStatus(status) + } + return displayResumeStatus(status, parked) + } + + // JSON output + if resumeJSON { + return outputResumeStatus(status) + } + + // If gate not closed yet, show status and exit + if !status.GateClosed { + fmt.Printf("%s Work parked on gate %s (still open)\n", + style.Bold.Render("🅿️"), parked.GateID) + if parked.BeadID != "" { + fmt.Printf(" Working on: %s\n", parked.BeadID) + } + fmt.Printf(" Parked at: %s\n", parked.ParkedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("\n%s Gate still open. Check back later or run 'bd gate show %s'\n", + style.Dim.Render("⏳"), parked.GateID) + return nil + } + + // Gate closed - resume work! + fmt.Printf("%s Gate %s has cleared!\n", style.Bold.Render("🚦"), parked.GateID) + if status.CloseReason != "" { + fmt.Printf(" Reason: %s\n", status.CloseReason) + } + + // Restore hook if we have a bead + if parked.BeadID != "" { + hook := wisp.NewSlungWork(parked.BeadID, agentID) + hook.Formula = parked.Formula + hook.Context = parked.Context + + if err := wisp.WriteSlungWork(cloneRoot, agentID, hook); err != nil { + return fmt.Errorf("restoring hook: %w", err) + } + + fmt.Printf("\n%s Restored work: %s\n", style.Bold.Render("🪝"), parked.BeadID) + if parked.Formula != "" { + fmt.Printf(" Formula: %s\n", parked.Formula) + } + } + + // Show context + if parked.Context != "" { + fmt.Printf("\n%s Context:\n", style.Bold.Render("📝")) + fmt.Println(parked.Context) + } + + // Clear parked work state + if err := clearParkedWork(cloneRoot, agentID); err != nil { + // Non-fatal + fmt.Printf("%s Warning: could not clear parked state: %v\n", style.Dim.Render("⚠"), err) + } + + fmt.Printf("\n%s Ready to continue!\n", style.Bold.Render("✓")) + return nil +} + +func outputResumeStatus(status ResumeStatus) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(status) +} + +func displayResumeStatus(status ResumeStatus, parked *ParkedWork) error { + if !status.HasParkedWork { + fmt.Printf("%s No parked work\n", style.Dim.Render("○")) + return nil + } + + gateStatus := "open" + gateIcon := "⏳" + if status.GateClosed { + gateStatus = "closed" + gateIcon = "✓" + } + + fmt.Printf("%s Parked work status:\n", style.Bold.Render("🅿️")) + fmt.Printf(" Gate: %s %s (%s)\n", gateIcon, parked.GateID, gateStatus) + if parked.BeadID != "" { + fmt.Printf(" Bead: %s\n", parked.BeadID) + } + if parked.Formula != "" { + fmt.Printf(" Formula: %s\n", parked.Formula) + } + fmt.Printf(" Parked: %s\n", parked.ParkedAt.Format("2006-01-02 15:04:05")) + + if status.GateClosed { + fmt.Printf("\n%s Gate cleared! Run 'gt resume' (without --status) to restore work.\n", + style.Bold.Render("→")) + } + + return nil +}