feat: add gt mol attach-from-mail command (gt-h6eq.7)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -197,6 +197,30 @@ Example:
|
||||
RunE: runMoleculeAttachment,
|
||||
}
|
||||
|
||||
var moleculeAttachFromMailCmd = &cobra.Command{
|
||||
Use: "attach-from-mail <mail-id>",
|
||||
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 <mail-id>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
146
internal/cmd/molecule_attach_from_mail.go
Normal file
146
internal/cmd/molecule_attach_from_mail.go
Normal file
@@ -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 <mail-id>" 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: <id>
|
||||
// - molecule_id: <id>
|
||||
// - molecule: <id>
|
||||
//
|
||||
// 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 ""
|
||||
}
|
||||
80
internal/cmd/molecule_attach_from_mail_test.go
Normal file
80
internal/cmd/molecule_attach_from_mail_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user