From 00c64a075c010c8a486d80e3352ed9aa35c77d23 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 23 Dec 2025 11:34:22 -0800 Subject: [PATCH] feat: add gt mol attach-from-mail command (gt-h6eq.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows agents to self-pin work from mail messages. The command: 1. Reads a mail message by ID 2. Extracts molecule ID from the body (attached_molecule:, molecule_id:, etc.) 3. Attaches the molecule to the agent's pinned bead (hook) 4. Marks the mail as read Includes unit tests for the molecule ID extraction logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/molecule.go | 25 +++ internal/cmd/molecule_attach_from_mail.go | 146 ++++++++++++++++++ .../cmd/molecule_attach_from_mail_test.go | 80 ++++++++++ 3 files changed, 251 insertions(+) create mode 100644 internal/cmd/molecule_attach_from_mail.go create mode 100644 internal/cmd/molecule_attach_from_mail_test.go diff --git a/internal/cmd/molecule.go b/internal/cmd/molecule.go index d2ad0e1d..20c1a45b 100644 --- a/internal/cmd/molecule.go +++ b/internal/cmd/molecule.go @@ -197,6 +197,30 @@ Example: RunE: runMoleculeAttachment, } +var moleculeAttachFromMailCmd = &cobra.Command{ + Use: "attach-from-mail ", + Short: "Attach a molecule from a mail message", + Long: `Attach a molecule to the current agent's hook from a mail message. + +This command reads a mail message, extracts the molecule ID from the body, +and attaches it to the agent's pinned bead (hook). + +The mail body should contain an "attached_molecule:" field with the molecule ID. + +Usage: gt mol attach-from-mail + +Behavior: +1. Read mail body for attached_molecule field +2. Attach molecule to agent's hook +3. Mark mail as read +4. Return control for execution + +Example: + gt mol attach-from-mail msg-abc123`, + Args: cobra.ExactArgs(1), + RunE: runMoleculeAttachFromMail, +} + var moleculeStatusCmd = &cobra.Command{ Use: "status [target]", Short: "Show what's on an agent's hook", @@ -350,6 +374,7 @@ func init() { moleculeCmd.AddCommand(moleculeAttachCmd) moleculeCmd.AddCommand(moleculeDetachCmd) moleculeCmd.AddCommand(moleculeAttachmentCmd) + moleculeCmd.AddCommand(moleculeAttachFromMailCmd) rootCmd.AddCommand(moleculeCmd) } diff --git a/internal/cmd/molecule_attach_from_mail.go b/internal/cmd/molecule_attach_from_mail.go new file mode 100644 index 00000000..645f0e2d --- /dev/null +++ b/internal/cmd/molecule_attach_from_mail.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +// runMoleculeAttachFromMail handles the "gt mol attach-from-mail " command. +// It reads a mail message, extracts the molecule ID from the body, and attaches +// it to the current agent's hook (pinned bead). +func runMoleculeAttachFromMail(cmd *cobra.Command, args []string) error { + mailID := args[0] + + // Get current working directory and town root + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + townRoot, err := workspace.FindFromCwd() + if err != nil || townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + // Detect agent role and identity + roleCtx := detectRole(cwd, townRoot) + agentIdentity := buildAgentIdentity(roleCtx) + if agentIdentity == "" { + return fmt.Errorf("cannot determine agent identity from current directory (role: %s)", roleCtx.Role) + } + + // Get the agent's mailbox + mailWorkDir, err := findMailWorkDir() + if err != nil { + return fmt.Errorf("finding mail workspace: %w", err) + } + + router := mail.NewRouter(mailWorkDir) + mailbox, err := router.GetMailbox(agentIdentity) + if err != nil { + return fmt.Errorf("getting mailbox: %w", err) + } + + // Read the mail message + msg, err := mailbox.Get(mailID) + if err != nil { + return fmt.Errorf("reading mail message: %w", err) + } + + // Extract molecule ID from mail body + moleculeID := extractMoleculeIDFromMail(msg.Body) + if moleculeID == "" { + return fmt.Errorf("no attached_molecule field found in mail body") + } + + // Find local beads directory + workDir, err := findLocalBeadsDir() + if err != nil { + return fmt.Errorf("not in a beads workspace: %w", err) + } + + b := beads.New(workDir) + + // Find the agent's pinned bead (hook) + pinnedBeads, err := b.List(beads.ListOptions{ + Status: beads.StatusPinned, + Assignee: agentIdentity, + Priority: -1, + }) + if err != nil { + return fmt.Errorf("listing pinned beads: %w", err) + } + + if len(pinnedBeads) == 0 { + return fmt.Errorf("no pinned bead found for agent %s - create one first", agentIdentity) + } + + // Use the first pinned bead as the hook + hookBead := pinnedBeads[0] + + // Check if molecule exists + _, err = b.Show(moleculeID) + if err != nil { + return fmt.Errorf("molecule %s not found: %w", moleculeID, err) + } + + // Attach the molecule to the hook + issue, err := b.AttachMolecule(hookBead.ID, moleculeID) + if err != nil { + return fmt.Errorf("attaching molecule: %w", err) + } + + // Mark mail as read + if err := mailbox.MarkRead(mailID); err != nil { + // Non-fatal: log warning but don't fail + fmt.Fprintf(os.Stderr, "Warning: could not mark mail as read: %v\n", err) + } + + // Output success + attachment := beads.ParseAttachmentFields(issue) + fmt.Printf("%s Attached molecule from mail\n", style.Bold.Render("✓")) + fmt.Printf(" Mail: %s\n", mailID) + fmt.Printf(" Hook: %s\n", hookBead.ID) + fmt.Printf(" Molecule: %s\n", moleculeID) + if attachment != nil && attachment.AttachedAt != "" { + fmt.Printf(" Attached at: %s\n", attachment.AttachedAt) + } + fmt.Printf("\n%s Run 'gt mol status' to see progress\n", style.Dim.Render("Hint:")) + + return nil +} + +// extractMoleculeIDFromMail extracts a molecule ID from a mail message body. +// It looks for patterns like: +// - attached_molecule: +// - molecule_id: +// - molecule: +// +// The ID is expected to be on the same line after the colon. +func extractMoleculeIDFromMail(body string) string { + // Try various patterns for molecule ID in mail body (case-insensitive) + patterns := []string{ + `(?i)attached_molecule:\s*(\S+)`, + `(?i)molecule_id:\s*(\S+)`, + `(?i)molecule:\s*(\S+)`, + `(?i)mol:\s*(\S+)`, + } + + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(body) + if len(matches) >= 2 { + return strings.TrimSpace(matches[1]) + } + } + + return "" +} diff --git a/internal/cmd/molecule_attach_from_mail_test.go b/internal/cmd/molecule_attach_from_mail_test.go new file mode 100644 index 00000000..ce5b9c93 --- /dev/null +++ b/internal/cmd/molecule_attach_from_mail_test.go @@ -0,0 +1,80 @@ +package cmd + +import "testing" + +func TestExtractMoleculeIDFromMail(t *testing.T) { + tests := []struct { + name string + body string + expected string + }{ + { + name: "attached_molecule field", + body: "Hello agent,\n\nattached_molecule: gt-abc123\n\nPlease work on this.", + expected: "gt-abc123", + }, + { + name: "molecule_id field", + body: "Work assignment:\nmolecule_id: mol-xyz789", + expected: "mol-xyz789", + }, + { + name: "molecule field", + body: "molecule: gt-task-42", + expected: "gt-task-42", + }, + { + name: "mol field", + body: "Quick task:\nmol: gt-quick\nDo this now.", + expected: "gt-quick", + }, + { + name: "no molecule field", + body: "This is just a regular message without any molecule.", + expected: "", + }, + { + name: "empty body", + body: "", + expected: "", + }, + { + name: "molecule with extra whitespace", + body: "attached_molecule: gt-whitespace \n\nmore text", + expected: "gt-whitespace", + }, + { + name: "multiple fields - first wins", + body: "attached_molecule: first\nmolecule: second", + expected: "first", + }, + { + name: "case insensitive line matching", + body: "Attached_Molecule: gt-case", + expected: "gt-case", + }, + { + name: "molecule in multiline context", + body: `Subject: Work Assignment + +This is your next task. + +attached_molecule: gt-multiline + +Please complete by EOD. + +Thanks, +Mayor`, + expected: "gt-multiline", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractMoleculeIDFromMail(tt.body) + if result != tt.expected { + t.Errorf("extractMoleculeIDFromMail(%q) = %q, want %q", tt.body, result, tt.expected) + } + }) + } +}