refactor(beads,mail): split large files into focused modules
Break down monolithic beads.go and mail.go into smaller, single-purpose files: beads package: - beads_agent.go: Agent-related bead operations - beads_delegation.go: Delegation bead handling - beads_dog.go: Dog pool operations - beads_merge_slot.go: Merge slot management - beads_mr.go: Merge request operations - beads_redirect.go: Redirect bead handling - beads_rig.go: Rig bead operations - beads_role.go: Role bead management cmd package: - mail_announce.go: Announcement subcommand - mail_check.go: Mail check subcommand - mail_identity.go: Identity management - mail_inbox.go: Inbox operations - mail_queue.go: Queue subcommand - mail_search.go: Search functionality - mail_send.go: Send subcommand - mail_thread.go: Thread operations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
609a4af087
commit
b60f016955
@@ -1,7 +1,10 @@
|
|||||||
// Package beads provides a wrapper for the bd (beads) CLI.
|
// Package beads provides a wrapper for the bd (beads) CLI.
|
||||||
package beads
|
package beads
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// TownBeadsPrefix is the prefix used for town-level agent beads stored in ~/gt/.beads/.
|
// TownBeadsPrefix is the prefix used for town-level agent beads stored in ~/gt/.beads/.
|
||||||
// This distinguishes them from rig-level beads (which use project prefixes like "gt-").
|
// This distinguishes them from rig-level beads (which use project prefixes like "gt-").
|
||||||
@@ -74,3 +77,170 @@ func PolecatRoleBeadIDTown() string {
|
|||||||
func CrewRoleBeadIDTown() string {
|
func CrewRoleBeadIDTown() string {
|
||||||
return RoleBeadIDTown("crew")
|
return RoleBeadIDTown("crew")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Rig-level agent bead ID helpers (gt- prefix) =====
|
||||||
|
|
||||||
|
// Agent bead ID naming convention:
|
||||||
|
// prefix-rig-role-name
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - gt-mayor (town-level, no rig)
|
||||||
|
// - gt-deacon (town-level, no rig)
|
||||||
|
// - gt-gastown-witness (rig-level singleton)
|
||||||
|
// - gt-gastown-refinery (rig-level singleton)
|
||||||
|
// - gt-gastown-crew-max (rig-level named agent)
|
||||||
|
// - gt-gastown-polecat-Toast (rig-level named agent)
|
||||||
|
|
||||||
|
// AgentBeadIDWithPrefix generates an agent bead ID using the specified prefix.
|
||||||
|
// The prefix should NOT include the hyphen (e.g., "gt", "bd", not "gt-", "bd-").
|
||||||
|
// For town-level agents (mayor, deacon), pass empty rig and name.
|
||||||
|
// For rig-level singletons (witness, refinery), pass empty name.
|
||||||
|
// For named agents (crew, polecat), pass all three.
|
||||||
|
func AgentBeadIDWithPrefix(prefix, rig, role, name string) string {
|
||||||
|
if rig == "" {
|
||||||
|
// Town-level agent: prefix-mayor, prefix-deacon
|
||||||
|
return prefix + "-" + role
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
// Rig-level singleton: prefix-rig-witness, prefix-rig-refinery
|
||||||
|
return prefix + "-" + rig + "-" + role
|
||||||
|
}
|
||||||
|
// Rig-level named agent: prefix-rig-role-name
|
||||||
|
return prefix + "-" + rig + "-" + role + "-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentBeadID generates the canonical agent bead ID using "gt" prefix.
|
||||||
|
// For non-gastown rigs, use AgentBeadIDWithPrefix with the rig's configured prefix.
|
||||||
|
func AgentBeadID(rig, role, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix("gt", rig, role, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayorBeadID returns the Mayor agent bead ID.
|
||||||
|
//
|
||||||
|
// Deprecated: Use MayorBeadIDTown() for town-level beads (hq- prefix).
|
||||||
|
// This function returns "gt-mayor" which is for rig-level storage.
|
||||||
|
// Town-level agents like Mayor should use the hq- prefix.
|
||||||
|
func MayorBeadID() string {
|
||||||
|
return "gt-mayor"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeaconBeadID returns the Deacon agent bead ID.
|
||||||
|
//
|
||||||
|
// Deprecated: Use DeaconBeadIDTown() for town-level beads (hq- prefix).
|
||||||
|
// This function returns "gt-deacon" which is for rig-level storage.
|
||||||
|
// Town-level agents like Deacon should use the hq- prefix.
|
||||||
|
func DeaconBeadID() string {
|
||||||
|
return "gt-deacon"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DogBeadID returns a Dog agent bead ID.
|
||||||
|
// Dogs are town-level agents, so they follow the pattern: gt-dog-<name>
|
||||||
|
// Deprecated: Use DogBeadIDTown() for town-level beads with hq- prefix.
|
||||||
|
// Dogs are town-level agents and should use hq-dog-<name>, not gt-dog-<name>.
|
||||||
|
func DogBeadID(name string) string {
|
||||||
|
return "gt-dog-" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessBeadIDWithPrefix returns the Witness agent bead ID for a rig using the specified prefix.
|
||||||
|
func WitnessBeadIDWithPrefix(prefix, rig string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "witness", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessBeadID returns the Witness agent bead ID for a rig using "gt" prefix.
|
||||||
|
func WitnessBeadID(rig string) string {
|
||||||
|
return WitnessBeadIDWithPrefix("gt", rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryBeadIDWithPrefix returns the Refinery agent bead ID for a rig using the specified prefix.
|
||||||
|
func RefineryBeadIDWithPrefix(prefix, rig string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "refinery", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryBeadID returns the Refinery agent bead ID for a rig using "gt" prefix.
|
||||||
|
func RefineryBeadID(rig string) string {
|
||||||
|
return RefineryBeadIDWithPrefix("gt", rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewBeadIDWithPrefix returns a Crew worker agent bead ID using the specified prefix.
|
||||||
|
func CrewBeadIDWithPrefix(prefix, rig, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "crew", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewBeadID returns a Crew worker agent bead ID using "gt" prefix.
|
||||||
|
func CrewBeadID(rig, name string) string {
|
||||||
|
return CrewBeadIDWithPrefix("gt", rig, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatBeadIDWithPrefix returns a Polecat agent bead ID using the specified prefix.
|
||||||
|
func PolecatBeadIDWithPrefix(prefix, rig, name string) string {
|
||||||
|
return AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatBeadID returns a Polecat agent bead ID using "gt" prefix.
|
||||||
|
func PolecatBeadID(rig, name string) string {
|
||||||
|
return PolecatBeadIDWithPrefix("gt", rig, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAgentBeadID parses an agent bead ID into its components.
|
||||||
|
// Returns rig, role, name, and whether parsing succeeded.
|
||||||
|
// For town-level agents, rig will be empty.
|
||||||
|
// For singletons, name will be empty.
|
||||||
|
// Accepts any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||||
|
func ParseAgentBeadID(id string) (rig, role, name string, ok bool) {
|
||||||
|
// Find the prefix (everything before the first hyphen)
|
||||||
|
// Valid prefixes are 2-3 characters (e.g., "gt", "bd", "hq")
|
||||||
|
hyphenIdx := strings.Index(id, "-")
|
||||||
|
if hyphenIdx < 2 || hyphenIdx > 3 {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := id[hyphenIdx+1:]
|
||||||
|
parts := strings.Split(rest, "-")
|
||||||
|
|
||||||
|
switch len(parts) {
|
||||||
|
case 1:
|
||||||
|
// Town-level: gt-mayor, bd-deacon
|
||||||
|
return "", parts[0], "", true
|
||||||
|
case 2:
|
||||||
|
// Could be rig-level singleton (gt-gastown-witness) or
|
||||||
|
// town-level named (gt-dog-alpha for dogs)
|
||||||
|
if parts[0] == "dog" {
|
||||||
|
// Dogs are town-level named agents: gt-dog-<name>
|
||||||
|
return "", "dog", parts[1], true
|
||||||
|
}
|
||||||
|
// Rig-level singleton: gt-gastown-witness
|
||||||
|
return parts[0], parts[1], "", true
|
||||||
|
case 3:
|
||||||
|
// Rig-level named: gt-gastown-crew-max, bd-beads-polecat-pearl
|
||||||
|
return parts[0], parts[1], parts[2], true
|
||||||
|
default:
|
||||||
|
// Handle names with hyphens: gt-gastown-polecat-my-agent-name
|
||||||
|
// or gt-dog-my-agent-name
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
if parts[0] == "dog" {
|
||||||
|
// Dog with hyphenated name: gt-dog-my-dog-name
|
||||||
|
return "", "dog", strings.Join(parts[1:], "-"), true
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], strings.Join(parts[2:], "-"), true
|
||||||
|
}
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAgentSessionBead returns true if the bead ID represents an agent session molecule.
|
||||||
|
// Agent session beads follow patterns like gt-mayor, bd-beads-witness, gt-gastown-crew-joe.
|
||||||
|
// Supports any valid prefix (e.g., "gt-", "bd-"), not just "gt-".
|
||||||
|
// These are used to track agent state and update frequently, which can create noise.
|
||||||
|
func IsAgentSessionBead(beadID string) bool {
|
||||||
|
_, role, _, ok := ParseAgentBeadID(beadID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Known agent roles
|
||||||
|
switch role {
|
||||||
|
case "mayor", "deacon", "witness", "refinery", "crew", "polecat", "dog":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
384
internal/beads/beads_agent.go
Normal file
384
internal/beads/beads_agent.go
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
// Package beads provides agent bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentFields holds structured fields for agent beads.
|
||||||
|
// These are stored as "key: value" lines in the description.
|
||||||
|
type AgentFields struct {
|
||||||
|
RoleType string // polecat, witness, refinery, deacon, mayor
|
||||||
|
Rig string // Rig name (empty for global agents like mayor/deacon)
|
||||||
|
AgentState string // spawning, working, done, stuck
|
||||||
|
HookBead string // Currently pinned work bead ID
|
||||||
|
RoleBead string // Role definition bead ID (canonical location; may not exist yet)
|
||||||
|
CleanupStatus string // ZFC: polecat self-reports git state (clean, has_uncommitted, has_stash, has_unpushed)
|
||||||
|
ActiveMR string // Currently active merge request bead ID (for traceability)
|
||||||
|
NotificationLevel string // DND mode: verbose, normal, muted (default: normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification level constants
|
||||||
|
const (
|
||||||
|
NotifyVerbose = "verbose" // All notifications (mail, convoy events, etc.)
|
||||||
|
NotifyNormal = "normal" // Important events only (default)
|
||||||
|
NotifyMuted = "muted" // Silent/DND mode - batch for later
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatAgentDescription creates a description string from agent fields.
|
||||||
|
func FormatAgentDescription(title string, fields *AgentFields) string {
|
||||||
|
if fields == nil {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, title)
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, fmt.Sprintf("role_type: %s", fields.RoleType))
|
||||||
|
|
||||||
|
if fields.Rig != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("rig: %s", fields.Rig))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "rig: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, fmt.Sprintf("agent_state: %s", fields.AgentState))
|
||||||
|
|
||||||
|
if fields.HookBead != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("hook_bead: %s", fields.HookBead))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "hook_bead: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.RoleBead != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("role_bead: %s", fields.RoleBead))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "role_bead: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.CleanupStatus != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("cleanup_status: %s", fields.CleanupStatus))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "cleanup_status: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.ActiveMR != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("active_mr: %s", fields.ActiveMR))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "active_mr: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields.NotificationLevel != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("notification_level: %s", fields.NotificationLevel))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "notification_level: null")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAgentFields extracts agent fields from an issue's description.
|
||||||
|
func ParseAgentFields(description string) *AgentFields {
|
||||||
|
fields := &AgentFields{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(description, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
colonIdx := strings.Index(line, ":")
|
||||||
|
if colonIdx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:colonIdx])
|
||||||
|
value := strings.TrimSpace(line[colonIdx+1:])
|
||||||
|
if value == "null" || value == "" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "role_type":
|
||||||
|
fields.RoleType = value
|
||||||
|
case "rig":
|
||||||
|
fields.Rig = value
|
||||||
|
case "agent_state":
|
||||||
|
fields.AgentState = value
|
||||||
|
case "hook_bead":
|
||||||
|
fields.HookBead = value
|
||||||
|
case "role_bead":
|
||||||
|
fields.RoleBead = value
|
||||||
|
case "cleanup_status":
|
||||||
|
fields.CleanupStatus = value
|
||||||
|
case "active_mr":
|
||||||
|
fields.ActiveMR = value
|
||||||
|
case "notification_level":
|
||||||
|
fields.NotificationLevel = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAgentBead creates an agent bead for tracking agent lifecycle.
|
||||||
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||||
|
// Use AgentBeadID() helper to generate correct IDs.
|
||||||
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||||
|
description := FormatAgentDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--id=" + id,
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
"--labels=gt:agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the role slot if specified (this is the authoritative storage)
|
||||||
|
if fields != nil && fields.RoleBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "role", fields.RoleBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue
|
||||||
|
fmt.Printf("Warning: could not set role slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the hook slot if specified (this is the authoritative storage)
|
||||||
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||||
|
// agent's hook slot is empty. See mi-619.
|
||||||
|
if fields != nil && fields.HookBead != "" {
|
||||||
|
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||||
|
// Non-fatal: warn but continue - description text has the backup
|
||||||
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentState updates the agent_state field in an agent bead.
|
||||||
|
// Optionally updates hook_bead if provided.
|
||||||
|
//
|
||||||
|
// IMPORTANT: This function uses the proper bd commands to update agent fields:
|
||||||
|
// - `bd agent state` for agent_state (uses SQLite column directly)
|
||||||
|
// - `bd slot set/clear` for hook_bead (uses SQLite column directly)
|
||||||
|
//
|
||||||
|
// This ensures consistency with `bd slot show` and other beads commands.
|
||||||
|
// Previously, this function embedded these fields in the description text,
|
||||||
|
// which caused inconsistencies with bd slot commands (see GH #gt-9v52).
|
||||||
|
func (b *Beads) UpdateAgentState(id string, state string, hookBead *string) error {
|
||||||
|
// Update agent state using bd agent state command
|
||||||
|
// This updates the agent_state column directly in SQLite
|
||||||
|
_, err := b.run("agent", "state", id, state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating agent state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hook_bead if provided
|
||||||
|
if hookBead != nil {
|
||||||
|
if *hookBead != "" {
|
||||||
|
// Set the hook using bd slot set
|
||||||
|
// This updates the hook_bead column directly in SQLite
|
||||||
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||||
|
if err != nil {
|
||||||
|
// If slot is already occupied, clear it first then retry
|
||||||
|
// This handles re-slinging scenarios where we're updating the hook
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "already occupied") {
|
||||||
|
_, _ = b.run("slot", "clear", id, "hook")
|
||||||
|
_, err = b.run("slot", "set", id, "hook", *hookBead)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear the hook
|
||||||
|
_, err = b.run("slot", "clear", id, "hook")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHookBead sets the hook_bead slot on an agent bead.
|
||||||
|
// This is a convenience wrapper that only sets the hook without changing agent_state.
|
||||||
|
// Per gt-zecmc: agent_state ("running", "dead", "idle") is observable from tmux
|
||||||
|
// and should not be recorded in beads ("discover, don't track" principle).
|
||||||
|
func (b *Beads) SetHookBead(agentBeadID, hookBeadID string) error {
|
||||||
|
// Set the hook using bd slot set
|
||||||
|
// This updates the hook_bead column directly in SQLite
|
||||||
|
_, err := b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||||
|
if err != nil {
|
||||||
|
// If slot is already occupied, clear it first then retry
|
||||||
|
errStr := err.Error()
|
||||||
|
if strings.Contains(errStr, "already occupied") {
|
||||||
|
_, _ = b.run("slot", "clear", agentBeadID, "hook")
|
||||||
|
_, err = b.run("slot", "set", agentBeadID, "hook", hookBeadID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting hook: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHookBead clears the hook_bead slot on an agent bead.
|
||||||
|
// Used when work is complete or unslung.
|
||||||
|
func (b *Beads) ClearHookBead(agentBeadID string) error {
|
||||||
|
_, err := b.run("slot", "clear", agentBeadID, "hook")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing hook: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentCleanupStatus updates the cleanup_status field in an agent bead.
|
||||||
|
// This is called by the polecat to self-report its git state (ZFC compliance).
|
||||||
|
// Valid statuses: clean, has_uncommitted, has_stash, has_unpushed
|
||||||
|
func (b *Beads) UpdateAgentCleanupStatus(id string, cleanupStatus string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.CleanupStatus = cleanupStatus
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentActiveMR updates the active_mr field in an agent bead.
|
||||||
|
// This links the agent to their current merge request for traceability.
|
||||||
|
// Pass empty string to clear the field (e.g., after merge completes).
|
||||||
|
func (b *Beads) UpdateAgentActiveMR(id string, activeMR string) error {
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.ActiveMR = activeMR
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentNotificationLevel updates the notification_level field in an agent bead.
|
||||||
|
// Valid levels: verbose, normal, muted (DND mode).
|
||||||
|
// Pass empty string to reset to default (normal).
|
||||||
|
func (b *Beads) UpdateAgentNotificationLevel(id string, level string) error {
|
||||||
|
// Validate level
|
||||||
|
if level != "" && level != NotifyVerbose && level != NotifyNormal && level != NotifyMuted {
|
||||||
|
return fmt.Errorf("invalid notification level %q: must be verbose, normal, or muted", level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First get current issue to preserve other fields
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse existing fields
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
fields.NotificationLevel = level
|
||||||
|
|
||||||
|
// Format new description
|
||||||
|
description := FormatAgentDescription(issue.Title, fields)
|
||||||
|
|
||||||
|
return b.Update(id, UpdateOptions{Description: &description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentNotificationLevel returns the notification level for an agent.
|
||||||
|
// Returns "normal" if not set (the default).
|
||||||
|
func (b *Beads) GetAgentNotificationLevel(id string) (string, error) {
|
||||||
|
_, fields, err := b.GetAgentBead(id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return NotifyNormal, nil
|
||||||
|
}
|
||||||
|
if fields.NotificationLevel == "" {
|
||||||
|
return NotifyNormal, nil
|
||||||
|
}
|
||||||
|
return fields.NotificationLevel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAgentBead permanently deletes an agent bead.
|
||||||
|
// Uses --hard --force for immediate permanent deletion (no tombstone).
|
||||||
|
func (b *Beads) DeleteAgentBead(id string) error {
|
||||||
|
_, err := b.run("delete", id, "--hard", "--force")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentBead retrieves an agent bead by ID.
|
||||||
|
// Returns nil if not found.
|
||||||
|
func (b *Beads) GetAgentBead(id string) (*Issue, *AgentFields, error) {
|
||||||
|
issue, err := b.Show(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !HasLabel(issue, "gt:agent") {
|
||||||
|
return nil, nil, fmt.Errorf("issue %s is not an agent bead (missing gt:agent label)", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := ParseAgentFields(issue.Description)
|
||||||
|
return issue, fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAgentBeads returns all agent beads in a single query.
|
||||||
|
// Returns a map of agent bead ID to Issue.
|
||||||
|
func (b *Beads) ListAgentBeads() (map[string]*Issue, error) {
|
||||||
|
out, err := b.run("list", "--label=gt:agent", "--json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []*Issue
|
||||||
|
if err := json.Unmarshal(out, &issues); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd list output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]*Issue, len(issues))
|
||||||
|
for _, issue := range issues {
|
||||||
|
result[issue.ID] = issue
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
155
internal/beads/beads_delegation.go
Normal file
155
internal/beads/beads_delegation.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
// Package beads provides delegation tracking for work units.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delegation represents a work delegation relationship between work units.
|
||||||
|
// Delegation links a parent work unit to a child work unit, tracking who
|
||||||
|
// delegated the work and to whom, along with any terms of the delegation.
|
||||||
|
// This enables work distribution with credit cascade - work flows down,
|
||||||
|
// validation and credit flow up.
|
||||||
|
type Delegation struct {
|
||||||
|
// Parent is the work unit ID that delegated the work
|
||||||
|
Parent string `json:"parent"`
|
||||||
|
|
||||||
|
// Child is the work unit ID that received the delegated work
|
||||||
|
Child string `json:"child"`
|
||||||
|
|
||||||
|
// DelegatedBy is the entity (hop:// URI or actor string) that delegated
|
||||||
|
DelegatedBy string `json:"delegated_by"`
|
||||||
|
|
||||||
|
// DelegatedTo is the entity (hop:// URI or actor string) receiving delegation
|
||||||
|
DelegatedTo string `json:"delegated_to"`
|
||||||
|
|
||||||
|
// Terms contains optional conditions of the delegation
|
||||||
|
Terms *DelegationTerms `json:"terms,omitempty"`
|
||||||
|
|
||||||
|
// CreatedAt is when the delegation was created
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelegationTerms holds optional terms/conditions for a delegation.
|
||||||
|
type DelegationTerms struct {
|
||||||
|
// Portion describes what part of the parent work is delegated
|
||||||
|
Portion string `json:"portion,omitempty"`
|
||||||
|
|
||||||
|
// Deadline is the expected completion date
|
||||||
|
Deadline string `json:"deadline,omitempty"`
|
||||||
|
|
||||||
|
// AcceptanceCriteria describes what constitutes completion
|
||||||
|
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
|
||||||
|
|
||||||
|
// CreditShare is the percentage of credit that flows to the delegate (0-100)
|
||||||
|
CreditShare int `json:"credit_share,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDelegation creates a delegation relationship from parent to child work unit.
|
||||||
|
// The delegation tracks who delegated (delegatedBy) and who received (delegatedTo),
|
||||||
|
// along with optional terms. Delegations enable credit cascade - when child work
|
||||||
|
// is completed, credit flows up to the parent work unit and its delegator.
|
||||||
|
//
|
||||||
|
// Note: This is stored as metadata on the child issue until bd CLI has native
|
||||||
|
// delegation support. Once bd supports `bd delegate add`, this will be updated.
|
||||||
|
func (b *Beads) AddDelegation(d *Delegation) error {
|
||||||
|
if d.Parent == "" || d.Child == "" {
|
||||||
|
return fmt.Errorf("delegation requires both parent and child work unit IDs")
|
||||||
|
}
|
||||||
|
if d.DelegatedBy == "" || d.DelegatedTo == "" {
|
||||||
|
return fmt.Errorf("delegation requires both delegated_by and delegated_to entities")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store delegation as JSON in the child issue's delegated_from slot
|
||||||
|
delegationJSON, err := json.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling delegation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the delegated_from slot on the child issue
|
||||||
|
_, err = b.run("slot", "set", d.Child, "delegated_from", string(delegationJSON))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also add a dependency so child blocks parent (work must complete before parent can close)
|
||||||
|
if err := b.AddDependency(d.Parent, d.Child); err != nil {
|
||||||
|
// Log but don't fail - the delegation is still recorded
|
||||||
|
fmt.Printf("Warning: could not add blocking dependency for delegation: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDelegation removes a delegation relationship.
|
||||||
|
func (b *Beads) RemoveDelegation(parent, child string) error {
|
||||||
|
// Clear the delegated_from slot on the child
|
||||||
|
_, err := b.run("slot", "clear", child, "delegated_from")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("clearing delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also remove the blocking dependency
|
||||||
|
if err := b.RemoveDependency(parent, child); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
fmt.Printf("Warning: could not remove blocking dependency: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDelegation retrieves the delegation information for a child work unit.
|
||||||
|
// Returns nil if the issue has no delegation.
|
||||||
|
func (b *Beads) GetDelegation(child string) (*Delegation, error) {
|
||||||
|
// Verify the issue exists first
|
||||||
|
if _, err := b.Show(child); err != nil {
|
||||||
|
return nil, fmt.Errorf("getting issue: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get delegation from the slot
|
||||||
|
out, err := b.run("slot", "get", child, "delegated_from")
|
||||||
|
if err != nil {
|
||||||
|
// No delegation slot means no delegation
|
||||||
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no slot") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("getting delegation slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slotValue := strings.TrimSpace(string(out))
|
||||||
|
if slotValue == "" || slotValue == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegation Delegation
|
||||||
|
if err := json.Unmarshal([]byte(slotValue), &delegation); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing delegation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &delegation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDelegationsFrom returns all delegations from a parent work unit.
|
||||||
|
// This searches for issues that have delegated_from pointing to the parent.
|
||||||
|
func (b *Beads) ListDelegationsFrom(parent string) ([]*Delegation, error) {
|
||||||
|
// List all issues that depend on this parent (delegated work blocks parent)
|
||||||
|
issues, err := b.List(ListOptions{Status: "all"})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var delegations []*Delegation
|
||||||
|
for _, issue := range issues {
|
||||||
|
d, err := b.GetDelegation(issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip issues with errors
|
||||||
|
}
|
||||||
|
if d != nil && d.Parent == parent {
|
||||||
|
delegations = append(delegations, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return delegations, nil
|
||||||
|
}
|
||||||
93
internal/beads/beads_dog.go
Normal file
93
internal/beads/beads_dog.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Package beads provides dog agent bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateDogAgentBead creates an agent bead for a dog.
|
||||||
|
// Dogs use a different schema than other agents - they use labels for metadata.
|
||||||
|
// Returns the created issue or an error.
|
||||||
|
func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
||||||
|
title := fmt.Sprintf("Dog: %s", name)
|
||||||
|
labels := []string{
|
||||||
|
"gt:agent",
|
||||||
|
"role_type:dog",
|
||||||
|
"rig:town",
|
||||||
|
"location:" + location,
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"create", "--json",
|
||||||
|
"--role-type=dog",
|
||||||
|
"--title=" + title,
|
||||||
|
"--labels=" + strings.Join(labels, ","),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindDogAgentBead finds the agent bead for a dog by name.
|
||||||
|
// Searches for agent beads with role_type:dog and matching title.
|
||||||
|
// Returns nil if not found.
|
||||||
|
func (b *Beads) FindDogAgentBead(name string) (*Issue, error) {
|
||||||
|
// List all agent beads and filter by role_type:dog label
|
||||||
|
issues, err := b.List(ListOptions{
|
||||||
|
Label: "gt:agent",
|
||||||
|
Status: "all",
|
||||||
|
Priority: -1, // No priority filter
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing agents: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedTitle := fmt.Sprintf("Dog: %s", name)
|
||||||
|
for _, issue := range issues {
|
||||||
|
// Check title match and role_type:dog label
|
||||||
|
if issue.Title == expectedTitle {
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
if label == "role_type:dog" {
|
||||||
|
return issue, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDogAgentBead finds and deletes the agent bead for a dog.
|
||||||
|
// Returns nil if the bead doesn't exist (idempotent).
|
||||||
|
func (b *Beads) DeleteDogAgentBead(name string) error {
|
||||||
|
issue, err := b.FindDogAgentBead(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finding dog bead: %w", err)
|
||||||
|
}
|
||||||
|
if issue == nil {
|
||||||
|
return nil // Already doesn't exist - idempotent
|
||||||
|
}
|
||||||
|
|
||||||
|
err = b.DeleteAgentBead(issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting bead %s: %w", issue.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
133
internal/beads/beads_merge_slot.go
Normal file
133
internal/beads/beads_merge_slot.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package beads provides merge slot management for serialized conflict resolution.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MergeSlotStatus represents the result of checking a merge slot.
|
||||||
|
type MergeSlotStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Holder string `json:"holder,omitempty"`
|
||||||
|
Waiters []string `json:"waiters,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotCreate creates the merge slot bead for the current rig.
|
||||||
|
// The slot is used for serialized conflict resolution in the merge queue.
|
||||||
|
// Returns the slot ID if successful.
|
||||||
|
func (b *Beads) MergeSlotCreate() (string, error) {
|
||||||
|
out, err := b.run("merge-slot", "create", "--json")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parsing merge-slot create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotCheck checks the availability of the merge slot.
|
||||||
|
// Returns the current status including holder and waiters if held.
|
||||||
|
func (b *Beads) MergeSlotCheck() (*MergeSlotStatus, error) {
|
||||||
|
out, err := b.run("merge-slot", "check", "--json")
|
||||||
|
if err != nil {
|
||||||
|
// Check if slot doesn't exist
|
||||||
|
if strings.Contains(err.Error(), "not found") {
|
||||||
|
return &MergeSlotStatus{Error: "not found"}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("checking merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if err := json.Unmarshal(out, &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing merge-slot check output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotAcquire attempts to acquire the merge slot for exclusive access.
|
||||||
|
// If holder is empty, defaults to BD_ACTOR environment variable.
|
||||||
|
// If addWaiter is true and the slot is held, the requester is added to the waiters queue.
|
||||||
|
// Returns the acquisition result.
|
||||||
|
func (b *Beads) MergeSlotAcquire(holder string, addWaiter bool) (*MergeSlotStatus, error) {
|
||||||
|
args := []string{"merge-slot", "acquire", "--json"}
|
||||||
|
if holder != "" {
|
||||||
|
args = append(args, "--holder="+holder)
|
||||||
|
}
|
||||||
|
if addWaiter {
|
||||||
|
args = append(args, "--wait")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
// Parse the output even on error - it may contain useful info
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if jsonErr := json.Unmarshal(out, &status); jsonErr == nil {
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("acquiring merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status MergeSlotStatus
|
||||||
|
if err := json.Unmarshal(out, &status); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing merge-slot acquire output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotRelease releases the merge slot after conflict resolution completes.
|
||||||
|
// If holder is provided, it verifies the slot is held by that holder before releasing.
|
||||||
|
func (b *Beads) MergeSlotRelease(holder string) error {
|
||||||
|
args := []string{"merge-slot", "release", "--json"}
|
||||||
|
if holder != "" {
|
||||||
|
args = append(args, "--holder="+holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("releasing merge slot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Released bool `json:"released"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &result); err != nil {
|
||||||
|
return fmt.Errorf("parsing merge-slot release output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Released && result.Error != "" {
|
||||||
|
return fmt.Errorf("slot release failed: %s", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeSlotEnsureExists creates the merge slot if it doesn't exist.
|
||||||
|
// This is idempotent - safe to call multiple times.
|
||||||
|
func (b *Beads) MergeSlotEnsureExists() (string, error) {
|
||||||
|
// Check if slot exists first
|
||||||
|
status, err := b.MergeSlotCheck()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if status.Error == "not found" {
|
||||||
|
// Create it
|
||||||
|
return b.MergeSlotCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.ID, nil
|
||||||
|
}
|
||||||
45
internal/beads/beads_mr.go
Normal file
45
internal/beads/beads_mr.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Package beads provides merge request and gate utilities.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindMRForBranch searches for an existing merge-request bead for the given branch.
|
||||||
|
// Returns the MR bead if found, nil if not found.
|
||||||
|
// This enables idempotent `gt done` - if an MR already exists, we skip creation.
|
||||||
|
func (b *Beads) FindMRForBranch(branch string) (*Issue, error) {
|
||||||
|
// List all merge-request beads (open status only - closed MRs are already processed)
|
||||||
|
issues, err := b.List(ListOptions{
|
||||||
|
Status: "open",
|
||||||
|
Label: "gt:merge-request",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for one matching this branch
|
||||||
|
// MR description format: "branch: <branch>\ntarget: ..."
|
||||||
|
branchPrefix := "branch: " + branch + "\n"
|
||||||
|
for _, issue := range issues {
|
||||||
|
if strings.HasPrefix(issue.Description, branchPrefix) {
|
||||||
|
return issue, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddGateWaiter registers an agent as a waiter on a gate bead.
|
||||||
|
// When the gate closes, the waiter will receive a wake notification via gt gate wake.
|
||||||
|
// The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast").
|
||||||
|
func (b *Beads) AddGateWaiter(gateID, waiter string) error {
|
||||||
|
// Use bd gate add-waiter to register the waiter on the gate
|
||||||
|
// This adds the waiter to the gate's native waiters field
|
||||||
|
_, err := b.run("gate", "add-waiter", gateID, waiter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("adding gate waiter: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
242
internal/beads/beads_redirect.go
Normal file
242
internal/beads/beads_redirect.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Package beads provides redirect resolution for beads databases.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveBeadsDir returns the actual beads directory, following any redirect.
|
||||||
|
// If workDir/.beads/redirect exists, it reads the redirect path and resolves it
|
||||||
|
// relative to workDir (not the .beads directory). Otherwise, returns workDir/.beads.
|
||||||
|
//
|
||||||
|
// This is essential for crew workers and polecats that use shared beads via redirect.
|
||||||
|
// The redirect file contains a relative path like "../../mayor/rig/.beads".
|
||||||
|
//
|
||||||
|
// Example: if we're at crew/max/ and .beads/redirect contains "../../mayor/rig/.beads",
|
||||||
|
// the redirect is resolved from crew/max/ (not crew/max/.beads/), giving us
|
||||||
|
// mayor/rig/.beads at the rig root level.
|
||||||
|
//
|
||||||
|
// Circular redirect detection: If the resolved path equals the original beads directory,
|
||||||
|
// this indicates an errant redirect file that should be removed. The function logs a
|
||||||
|
// warning and returns the original beads directory.
|
||||||
|
func ResolveBeadsDir(workDir string) string {
|
||||||
|
beadsDir := filepath.Join(workDir, ".beads")
|
||||||
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||||
|
|
||||||
|
// Check for redirect file
|
||||||
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||||
|
if err != nil {
|
||||||
|
// No redirect, use local .beads
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and clean the redirect path
|
||||||
|
redirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if redirectTarget == "" {
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative to workDir (the redirect is written from the perspective
|
||||||
|
// of being inside workDir, not inside workDir/.beads)
|
||||||
|
// e.g., redirect contains "../../mayor/rig/.beads"
|
||||||
|
// from crew/max/, this resolves to mayor/rig/.beads
|
||||||
|
resolved := filepath.Join(workDir, redirectTarget)
|
||||||
|
|
||||||
|
// Clean the path to resolve .. components
|
||||||
|
resolved = filepath.Clean(resolved)
|
||||||
|
|
||||||
|
// Detect circular redirects: if resolved path equals original beads dir,
|
||||||
|
// this is an errant redirect file (e.g., redirect in mayor/rig/.beads pointing to itself)
|
||||||
|
if resolved == beadsDir {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s (points to itself), ignoring redirect\n", redirectPath)
|
||||||
|
// Remove the errant redirect file to prevent future warnings
|
||||||
|
if err := os.Remove(redirectPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: could not remove errant redirect file: %v\n", err)
|
||||||
|
}
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow redirect chains (e.g., crew/.beads -> rig/.beads -> mayor/rig/.beads)
|
||||||
|
// This is intentional for the rig-level redirect architecture.
|
||||||
|
// Limit depth to prevent infinite loops from misconfigured redirects.
|
||||||
|
return resolveBeadsDirWithDepth(resolved, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBeadsDirWithDepth follows redirect chains with a depth limit.
|
||||||
|
func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
||||||
|
if maxDepth <= 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: redirect chain too deep at %s, stopping\n", beadsDir)
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
||||||
|
data, err := os.ReadFile(redirectPath) //nolint:gosec // G304: path is constructed internally
|
||||||
|
if err != nil {
|
||||||
|
// No redirect, this is the final destination
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if redirectTarget == "" {
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve relative to parent of beadsDir (the workDir)
|
||||||
|
workDir := filepath.Dir(beadsDir)
|
||||||
|
resolved := filepath.Clean(filepath.Join(workDir, redirectTarget))
|
||||||
|
|
||||||
|
// Detect circular redirect
|
||||||
|
if resolved == beadsDir {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: circular redirect detected in %s, stopping\n", redirectPath)
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively follow
|
||||||
|
return resolveBeadsDirWithDepth(resolved, maxDepth-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
||||||
|
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
||||||
|
// This is safe to call even if the directory doesn't exist.
|
||||||
|
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
return nil // Nothing to clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runtime files/patterns that are gitignored and safe to remove
|
||||||
|
runtimePatterns := []string{
|
||||||
|
// SQLite databases
|
||||||
|
"*.db", "*.db-*", "*.db?*",
|
||||||
|
// Daemon runtime
|
||||||
|
"daemon.lock", "daemon.log", "daemon.pid", "bd.sock",
|
||||||
|
// Sync state
|
||||||
|
"sync-state.json", "last-touched", "metadata.json",
|
||||||
|
// Version tracking
|
||||||
|
".local_version",
|
||||||
|
// Redirect file (we're about to recreate it)
|
||||||
|
"redirect",
|
||||||
|
// Merge artifacts
|
||||||
|
"beads.base.*", "beads.left.*", "beads.right.*",
|
||||||
|
// JSONL files (tracked but will be redirected, safe to remove in worktrees)
|
||||||
|
"issues.jsonl", "interactions.jsonl",
|
||||||
|
// Runtime directories
|
||||||
|
"mq",
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
|
for _, pattern := range runtimePatterns {
|
||||||
|
matches, err := filepath.Glob(filepath.Join(beadsDir, pattern))
|
||||||
|
if err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, match := range matches {
|
||||||
|
if err := os.RemoveAll(match); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||||
|
// This is used by crew, polecats, and refinery worktrees to share the rig's beads database.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - townRoot: the town root directory (e.g., ~/gt)
|
||||||
|
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
||||||
|
//
|
||||||
|
// The function:
|
||||||
|
// 1. Computes the relative path from worktree to rig-level .beads
|
||||||
|
// 2. Cleans up runtime files (preserving tracked files like formulas/)
|
||||||
|
// 3. Creates the redirect file
|
||||||
|
//
|
||||||
|
// Safety: This function refuses to create redirects in the canonical beads location
|
||||||
|
// (mayor/rig) to prevent circular redirect chains.
|
||||||
|
func SetupRedirect(townRoot, worktreePath string) error {
|
||||||
|
// Get rig root from worktree path
|
||||||
|
// worktreePath = <town>/<rig>/crew/<name> or <town>/<rig>/refinery/rig etc.
|
||||||
|
relPath, err := filepath.Rel(townRoot, worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("computing relative path: %w", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid worktree path: must be at least 2 levels deep from town root")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety check: prevent creating redirect in canonical beads location (mayor/rig)
|
||||||
|
// This would create a circular redirect chain since rig/.beads redirects to mayor/rig/.beads
|
||||||
|
if len(parts) >= 2 && parts[1] == "mayor" {
|
||||||
|
return fmt.Errorf("cannot create redirect in canonical beads location (mayor/rig)")
|
||||||
|
}
|
||||||
|
|
||||||
|
rigRoot := filepath.Join(townRoot, parts[0])
|
||||||
|
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
||||||
|
mayorBeadsPath := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||||
|
|
||||||
|
// Check rig-level .beads first, fall back to mayor/rig/.beads (tracked beads architecture)
|
||||||
|
usesMayorFallback := false
|
||||||
|
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||||
|
// No rig/.beads - check for mayor/rig/.beads (tracked beads architecture)
|
||||||
|
if _, err := os.Stat(mayorBeadsPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("no beads found at %s or %s", rigBeadsPath, mayorBeadsPath)
|
||||||
|
}
|
||||||
|
// Using mayor fallback - warn user to run bd doctor
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: rig .beads not found at %s, using %s\n", rigBeadsPath, mayorBeadsPath)
|
||||||
|
fmt.Fprintf(os.Stderr, " Run 'bd doctor' to fix rig beads configuration\n")
|
||||||
|
usesMayorFallback = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
||||||
|
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||||
|
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
||||||
|
return fmt.Errorf("cleaning runtime files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .beads directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating .beads dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute relative path from worktree to rig root
|
||||||
|
// e.g., crew/<name> (depth 2) -> ../../.beads
|
||||||
|
// refinery/rig (depth 2) -> ../../.beads
|
||||||
|
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||||
|
upPath := strings.Repeat("../", depth)
|
||||||
|
|
||||||
|
var redirectPath string
|
||||||
|
if usesMayorFallback {
|
||||||
|
// Direct redirect to mayor/rig/.beads since rig/.beads doesn't exist
|
||||||
|
redirectPath = upPath + "mayor/rig/.beads"
|
||||||
|
} else {
|
||||||
|
redirectPath = upPath + ".beads"
|
||||||
|
|
||||||
|
// Check if rig-level beads has a redirect (tracked beads case).
|
||||||
|
// If so, redirect directly to the final destination to avoid chains.
|
||||||
|
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
||||||
|
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||||
|
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||||
|
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||||
|
if rigRedirectTarget != "" {
|
||||||
|
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||||
|
// Redirect worktree directly to the final destination.
|
||||||
|
redirectPath = upPath + rigRedirectTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create redirect file
|
||||||
|
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
||||||
|
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
||||||
|
return fmt.Errorf("creating redirect file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
117
internal/beads/beads_rig.go
Normal file
117
internal/beads/beads_rig.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Package beads provides rig identity bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RigFields contains the fields specific to rig identity beads.
|
||||||
|
type RigFields struct {
|
||||||
|
Repo string // Git URL for the rig's repository
|
||||||
|
Prefix string // Beads prefix for this rig (e.g., "gt", "bd")
|
||||||
|
State string // Operational state: active, archived, maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRigDescription formats the description field for a rig identity bead.
|
||||||
|
func FormatRigDescription(name string, fields *RigFields) string {
|
||||||
|
if fields == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Rig identity bead for %s.", name))
|
||||||
|
lines = append(lines, "")
|
||||||
|
|
||||||
|
if fields.Repo != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("repo: %s", fields.Repo))
|
||||||
|
}
|
||||||
|
if fields.Prefix != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("prefix: %s", fields.Prefix))
|
||||||
|
}
|
||||||
|
if fields.State != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("state: %s", fields.State))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRigFields extracts rig fields from an issue's description.
|
||||||
|
func ParseRigFields(description string) *RigFields {
|
||||||
|
fields := &RigFields{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(description, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
colonIdx := strings.Index(line, ":")
|
||||||
|
if colonIdx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimSpace(line[:colonIdx])
|
||||||
|
value := strings.TrimSpace(line[colonIdx+1:])
|
||||||
|
if value == "null" || value == "" {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "repo":
|
||||||
|
fields.Repo = value
|
||||||
|
case "prefix":
|
||||||
|
fields.Prefix = value
|
||||||
|
case "state":
|
||||||
|
fields.State = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRigBead creates a rig identity bead for tracking rig metadata.
|
||||||
|
// The ID format is: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||||
|
// Use RigBeadID() helper to generate correct IDs.
|
||||||
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, error) {
|
||||||
|
description := FormatRigDescription(title, fields)
|
||||||
|
|
||||||
|
args := []string{"create", "--json",
|
||||||
|
"--id=" + id,
|
||||||
|
"--title=" + title,
|
||||||
|
"--description=" + description,
|
||||||
|
"--labels=gt:rig",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default actor from BD_ACTOR env var for provenance tracking
|
||||||
|
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||||
|
args = append(args, "--actor="+actor)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := b.run(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var issue Issue
|
||||||
|
if err := json.Unmarshal(out, &issue); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd create output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &issue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigBeadIDWithPrefix generates a rig identity bead ID using the specified prefix.
|
||||||
|
// Format: <prefix>-rig-<name> (e.g., gt-rig-gastown)
|
||||||
|
func RigBeadIDWithPrefix(prefix, name string) string {
|
||||||
|
return fmt.Sprintf("%s-rig-%s", prefix, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RigBeadID generates a rig identity bead ID using "gt" prefix.
|
||||||
|
// For non-gastown rigs, use RigBeadIDWithPrefix with the rig's configured prefix.
|
||||||
|
func RigBeadID(name string) string {
|
||||||
|
return RigBeadIDWithPrefix("gt", name)
|
||||||
|
}
|
||||||
94
internal/beads/beads_role.go
Normal file
94
internal/beads/beads_role.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Package beads provides role bead management.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Role bead ID naming convention:
|
||||||
|
// Role beads are stored in town beads (~/.beads/) with hq- prefix.
|
||||||
|
//
|
||||||
|
// Canonical format: hq-<role>-role
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - hq-mayor-role
|
||||||
|
// - hq-deacon-role
|
||||||
|
// - hq-witness-role
|
||||||
|
// - hq-refinery-role
|
||||||
|
// - hq-crew-role
|
||||||
|
// - hq-polecat-role
|
||||||
|
//
|
||||||
|
// Use RoleBeadIDTown() to get canonical role bead IDs.
|
||||||
|
// The legacy RoleBeadID() function returns gt-<role>-role for backward compatibility.
|
||||||
|
|
||||||
|
// RoleBeadID returns the role bead ID for a given role type.
|
||||||
|
// Role beads define lifecycle configuration for each agent type.
|
||||||
|
// Deprecated: Use RoleBeadIDTown() for town-level beads with hq- prefix.
|
||||||
|
// Role beads are global templates and should use hq-<role>-role, not gt-<role>-role.
|
||||||
|
func RoleBeadID(roleType string) string {
|
||||||
|
return "gt-" + roleType + "-role"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DogRoleBeadID returns the Dog role bead ID.
|
||||||
|
func DogRoleBeadID() string {
|
||||||
|
return RoleBeadID("dog")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayorRoleBeadID returns the Mayor role bead ID.
|
||||||
|
func MayorRoleBeadID() string {
|
||||||
|
return RoleBeadID("mayor")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeaconRoleBeadID returns the Deacon role bead ID.
|
||||||
|
func DeaconRoleBeadID() string {
|
||||||
|
return RoleBeadID("deacon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WitnessRoleBeadID returns the Witness role bead ID.
|
||||||
|
func WitnessRoleBeadID() string {
|
||||||
|
return RoleBeadID("witness")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefineryRoleBeadID returns the Refinery role bead ID.
|
||||||
|
func RefineryRoleBeadID() string {
|
||||||
|
return RoleBeadID("refinery")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrewRoleBeadID returns the Crew role bead ID.
|
||||||
|
func CrewRoleBeadID() string {
|
||||||
|
return RoleBeadID("crew")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolecatRoleBeadID returns the Polecat role bead ID.
|
||||||
|
func PolecatRoleBeadID() string {
|
||||||
|
return RoleBeadID("polecat")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleConfig looks up a role bead and returns its parsed RoleConfig.
|
||||||
|
// Returns nil, nil if the role bead doesn't exist or has no config.
|
||||||
|
func (b *Beads) GetRoleConfig(roleBeadID string) (*RoleConfig, error) {
|
||||||
|
issue, err := b.Show(roleBeadID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !HasLabel(issue, "gt:role") {
|
||||||
|
return nil, fmt.Errorf("bead %s is not a role bead (missing gt:role label)", roleBeadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseRoleConfig(issue.Description), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLabel checks if an issue has a specific label.
|
||||||
|
func HasLabel(issue *Issue, label string) bool {
|
||||||
|
for _, l := range issue.Labels {
|
||||||
|
if l == label {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
1568
internal/cmd/mail.go
1568
internal/cmd/mail.go
File diff suppressed because it is too large
Load Diff
248
internal/cmd/mail_announce.go
Normal file
248
internal/cmd/mail_announce.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runMailAnnounces lists announce channels or reads messages from a channel.
|
||||||
|
func runMailAnnounces(cmd *cobra.Command, args []string) error {
|
||||||
|
// Find workspace
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load messaging config
|
||||||
|
configPath := config.MessagingConfigPath(townRoot)
|
||||||
|
cfg, err := config.LoadMessagingConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading messaging config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no channel specified, list all channels
|
||||||
|
if len(args) == 0 {
|
||||||
|
return listAnnounceChannels(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read messages from specified channel
|
||||||
|
channelName := args[0]
|
||||||
|
return readAnnounceChannel(townRoot, cfg, channelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAnnounceChannels lists all announce channels and their configuration.
|
||||||
|
func listAnnounceChannels(cfg *config.MessagingConfig) error {
|
||||||
|
if cfg.Announces == nil || len(cfg.Announces) == 0 {
|
||||||
|
if mailAnnouncesJSON {
|
||||||
|
fmt.Println("[]")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%s No announce channels configured\n", style.Dim.Render("○"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailAnnouncesJSON {
|
||||||
|
type channelInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Readers []string `json:"readers"`
|
||||||
|
RetainCount int `json:"retain_count"`
|
||||||
|
}
|
||||||
|
var channels []channelInfo
|
||||||
|
for name, annCfg := range cfg.Announces {
|
||||||
|
channels = append(channels, channelInfo{
|
||||||
|
Name: name,
|
||||||
|
Readers: annCfg.Readers,
|
||||||
|
RetainCount: annCfg.RetainCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Sort by name for consistent output
|
||||||
|
sort.Slice(channels, func(i, j int) bool {
|
||||||
|
return channels[i].Name < channels[j].Name
|
||||||
|
})
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("%s Announce Channels (%d)\n\n", style.Bold.Render("📢"), len(cfg.Announces))
|
||||||
|
|
||||||
|
// Sort channel names for consistent output
|
||||||
|
var names []string
|
||||||
|
for name := range cfg.Announces {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
annCfg := cfg.Announces[name]
|
||||||
|
retainStr := "unlimited"
|
||||||
|
if annCfg.RetainCount > 0 {
|
||||||
|
retainStr = fmt.Sprintf("%d messages", annCfg.RetainCount)
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s\n", style.Bold.Render("●"), name)
|
||||||
|
fmt.Printf(" Readers: %s\n", strings.Join(annCfg.Readers, ", "))
|
||||||
|
fmt.Printf(" Retain: %s\n", style.Dim.Render(retainStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAnnounceChannel reads messages from an announce channel.
|
||||||
|
func readAnnounceChannel(townRoot string, cfg *config.MessagingConfig, channelName string) error {
|
||||||
|
// Validate channel exists
|
||||||
|
if cfg.Announces == nil {
|
||||||
|
return fmt.Errorf("no announce channels configured")
|
||||||
|
}
|
||||||
|
_, ok := cfg.Announces[channelName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown announce channel: %s", channelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query beads for messages with announce_channel=<channel>
|
||||||
|
messages, err := listAnnounceMessages(townRoot, channelName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing announce messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailAnnouncesJSON {
|
||||||
|
// Ensure empty array instead of null for JSON
|
||||||
|
if messages == nil {
|
||||||
|
messages = []announceMessage{}
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("%s Channel: %s (%d messages)\n\n",
|
||||||
|
style.Bold.Render("📢"), channelName, len(messages))
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
priorityMarker := ""
|
||||||
|
if msg.Priority <= 1 {
|
||||||
|
priorityMarker = " " + style.Bold.Render("!")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s %s%s\n", style.Bold.Render("●"), msg.Title, priorityMarker)
|
||||||
|
fmt.Printf(" %s from %s\n",
|
||||||
|
style.Dim.Render(msg.ID),
|
||||||
|
msg.From)
|
||||||
|
fmt.Printf(" %s\n",
|
||||||
|
style.Dim.Render(msg.Created.Format("2006-01-02 15:04")))
|
||||||
|
if msg.Description != "" {
|
||||||
|
// Show first line of description as preview
|
||||||
|
lines := strings.SplitN(msg.Description, "\n", 2)
|
||||||
|
preview := lines[0]
|
||||||
|
if len(preview) > 80 {
|
||||||
|
preview = preview[:77] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render(preview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceMessage represents a message in an announce channel.
|
||||||
|
type announceMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAnnounceMessages lists messages from an announce channel.
|
||||||
|
func listAnnounceMessages(townRoot, channelName string) ([]announceMessage, error) {
|
||||||
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
|
|
||||||
|
// Query for messages with label announce_channel:<channel>
|
||||||
|
// Messages are stored with this label when sent via sendToAnnounce()
|
||||||
|
args := []string{"list",
|
||||||
|
"--type", "message",
|
||||||
|
"--label", "announce_channel:" + channelName,
|
||||||
|
"--sort", "-created", // Newest first
|
||||||
|
"--limit", "0", // No limit
|
||||||
|
"--json",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg != "" {
|
||||||
|
return nil, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON output
|
||||||
|
var issues []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
output := strings.TrimSpace(stdout.String())
|
||||||
|
if output == "" || output == "[]" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to announceMessage, extracting 'from' from labels
|
||||||
|
var messages []announceMessage
|
||||||
|
for _, issue := range issues {
|
||||||
|
msg := announceMessage{
|
||||||
|
ID: issue.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Description: issue.Description,
|
||||||
|
Created: issue.CreatedAt,
|
||||||
|
Priority: issue.Priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract 'from' from labels (format: "from:address")
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
if strings.HasPrefix(label, "from:") {
|
||||||
|
msg.From = strings.TrimPrefix(label, "from:")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
92
internal/cmd/mail_check.go
Normal file
92
internal/cmd/mail_check.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMailCheck(cmd *cobra.Command, args []string) error {
|
||||||
|
// Determine which inbox (priority: --identity flag, auto-detect)
|
||||||
|
address := ""
|
||||||
|
if mailCheckIdentity != "" {
|
||||||
|
address = mailCheckIdentity
|
||||||
|
} else {
|
||||||
|
address = detectSender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
if mailCheckInject {
|
||||||
|
// Inject mode: always exit 0, silent on error
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
if mailCheckInject {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count unread
|
||||||
|
_, unread, err := mailbox.Count()
|
||||||
|
if err != nil {
|
||||||
|
if mailCheckInject {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("counting messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailCheckJSON {
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"address": address,
|
||||||
|
"unread": unread,
|
||||||
|
"has_new": unread > 0,
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject mode: output system-reminder if mail exists
|
||||||
|
if mailCheckInject {
|
||||||
|
if unread > 0 {
|
||||||
|
// Get subjects for context
|
||||||
|
messages, _ := mailbox.ListUnread()
|
||||||
|
var subjects []string
|
||||||
|
for _, msg := range messages {
|
||||||
|
subjects = append(subjects, fmt.Sprintf("- %s from %s: %s", msg.ID, msg.From, msg.Subject))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("<system-reminder>")
|
||||||
|
fmt.Printf("You have %d unread message(s) in your inbox.\n\n", unread)
|
||||||
|
for _, s := range subjects {
|
||||||
|
fmt.Println(s)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Run 'gt mail inbox' to see your messages, or 'gt mail read <id>' for a specific message.")
|
||||||
|
fmt.Println("</system-reminder>")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode
|
||||||
|
if unread > 0 {
|
||||||
|
fmt.Printf("%s %d unread message(s)\n", style.Bold.Render("📬"), unread)
|
||||||
|
return NewSilentExit(0)
|
||||||
|
}
|
||||||
|
fmt.Println("No new mail")
|
||||||
|
return NewSilentExit(1)
|
||||||
|
}
|
||||||
187
internal/cmd/mail_identity.go
Normal file
187
internal/cmd/mail_identity.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findMailWorkDir returns the town root for all mail operations.
|
||||||
|
//
|
||||||
|
// Two-level beads architecture:
|
||||||
|
// - Town beads (~/gt/.beads/): ALL mail and coordination
|
||||||
|
// - Clone beads (<rig>/crew/*/.beads/): Project issues only
|
||||||
|
//
|
||||||
|
// Mail ALWAYS uses town beads, regardless of sender or recipient address.
|
||||||
|
// This ensures messages are visible to all agents in the town.
|
||||||
|
func findMailWorkDir() (string, error) {
|
||||||
|
return workspace.FindFromCwdOrError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLocalBeadsDir finds the nearest .beads directory by walking up from CWD.
|
||||||
|
// Used for project work (molecules, issue creation) that uses clone beads.
|
||||||
|
//
|
||||||
|
// Priority:
|
||||||
|
// 1. BEADS_DIR environment variable (set by session manager for polecats)
|
||||||
|
// 2. Walk up from CWD looking for .beads directory
|
||||||
|
//
|
||||||
|
// Polecats use redirect-based beads access, so their worktree doesn't have a full
|
||||||
|
// .beads directory. The session manager sets BEADS_DIR to the correct location.
|
||||||
|
func findLocalBeadsDir() (string, error) {
|
||||||
|
// Check BEADS_DIR environment variable first (set by session manager for polecats).
|
||||||
|
// This is important for polecats that use redirect-based beads access.
|
||||||
|
if beadsDir := os.Getenv("BEADS_DIR"); beadsDir != "" {
|
||||||
|
// BEADS_DIR points directly to the .beads directory, return its parent
|
||||||
|
if _, err := os.Stat(beadsDir); err == nil {
|
||||||
|
return filepath.Dir(beadsDir), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: walk up from CWD
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
path := cwd
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(path, ".beads")); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(path)
|
||||||
|
if parent == path {
|
||||||
|
break // Reached root
|
||||||
|
}
|
||||||
|
path = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no .beads directory found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSender determines the current context's address.
|
||||||
|
// Priority:
|
||||||
|
// 1. GT_ROLE env var → use the role-based identity (agent session)
|
||||||
|
// 2. No GT_ROLE → try cwd-based detection (witness/refinery/polecat/crew directories)
|
||||||
|
// 3. No match → return "overseer" (human at terminal)
|
||||||
|
//
|
||||||
|
// All Gas Town agents run in tmux sessions with GT_ROLE set at spawn.
|
||||||
|
// However, cwd-based detection is also tried to support running commands
|
||||||
|
// from agent directories without GT_ROLE set (e.g., debugging sessions).
|
||||||
|
func detectSender() string {
|
||||||
|
// Check GT_ROLE first (authoritative for agent sessions)
|
||||||
|
role := os.Getenv("GT_ROLE")
|
||||||
|
if role != "" {
|
||||||
|
// Agent session - build address from role and context
|
||||||
|
return detectSenderFromRole(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No GT_ROLE - try cwd-based detection, defaults to overseer if not in agent directory
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSenderFromRole builds an address from the GT_ROLE and related env vars.
|
||||||
|
// GT_ROLE can be either a simple role name ("crew", "polecat") or a full address
|
||||||
|
// ("greenplace/crew/joe") depending on how the session was started.
|
||||||
|
//
|
||||||
|
// If GT_ROLE is a simple name but required env vars (GT_RIG, GT_POLECAT, etc.)
|
||||||
|
// are missing, falls back to cwd-based detection. This could return "overseer"
|
||||||
|
// if cwd doesn't match any known agent path - a misconfigured agent session.
|
||||||
|
func detectSenderFromRole(role string) string {
|
||||||
|
rig := os.Getenv("GT_RIG")
|
||||||
|
|
||||||
|
// Check if role is already a full address (contains /)
|
||||||
|
if strings.Contains(role, "/") {
|
||||||
|
// GT_ROLE is already a full address, use it directly
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// GT_ROLE is a simple role name, build the full address
|
||||||
|
switch role {
|
||||||
|
case "mayor":
|
||||||
|
return "mayor/"
|
||||||
|
case "deacon":
|
||||||
|
return "deacon/"
|
||||||
|
case "polecat":
|
||||||
|
polecat := os.Getenv("GT_POLECAT")
|
||||||
|
if rig != "" && polecat != "" {
|
||||||
|
return fmt.Sprintf("%s/%s", rig, polecat)
|
||||||
|
}
|
||||||
|
// Fallback to cwd detection for polecats
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
case "crew":
|
||||||
|
crew := os.Getenv("GT_CREW")
|
||||||
|
if rig != "" && crew != "" {
|
||||||
|
return fmt.Sprintf("%s/crew/%s", rig, crew)
|
||||||
|
}
|
||||||
|
// Fallback to cwd detection for crew
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
case "witness":
|
||||||
|
if rig != "" {
|
||||||
|
return fmt.Sprintf("%s/witness", rig)
|
||||||
|
}
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
case "refinery":
|
||||||
|
if rig != "" {
|
||||||
|
return fmt.Sprintf("%s/refinery", rig)
|
||||||
|
}
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
default:
|
||||||
|
// Unknown role, try cwd detection
|
||||||
|
return detectSenderFromCwd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSenderFromCwd is the legacy cwd-based detection for edge cases.
|
||||||
|
func detectSenderFromCwd() string {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "overseer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in a rig's polecats directory, extract address (format: rig/polecats/name)
|
||||||
|
if strings.Contains(cwd, "/polecats/") {
|
||||||
|
parts := strings.Split(cwd, "/polecats/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
rigPath := parts[0]
|
||||||
|
polecatPath := strings.Split(parts[1], "/")[0]
|
||||||
|
rigName := filepath.Base(rigPath)
|
||||||
|
return fmt.Sprintf("%s/polecats/%s", rigName, polecatPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in a rig's crew directory, extract address (format: rig/crew/name)
|
||||||
|
if strings.Contains(cwd, "/crew/") {
|
||||||
|
parts := strings.Split(cwd, "/crew/")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
rigPath := parts[0]
|
||||||
|
crewName := strings.Split(parts[1], "/")[0]
|
||||||
|
rigName := filepath.Base(rigPath)
|
||||||
|
return fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in a rig's refinery directory, extract address (format: rig/refinery)
|
||||||
|
if strings.Contains(cwd, "/refinery") {
|
||||||
|
parts := strings.Split(cwd, "/refinery")
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
rigName := filepath.Base(parts[0])
|
||||||
|
return fmt.Sprintf("%s/refinery", rigName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in a rig's witness directory, extract address (format: rig/witness)
|
||||||
|
if strings.Contains(cwd, "/witness") {
|
||||||
|
parts := strings.Split(cwd, "/witness")
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
rigName := filepath.Base(parts[0])
|
||||||
|
return fmt.Sprintf("%s/witness", rigName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to overseer (human)
|
||||||
|
return "overseer"
|
||||||
|
}
|
||||||
352
internal/cmd/mail_inbox.go
Normal file
352
internal/cmd/mail_inbox.go
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMailInbox(cmd *cobra.Command, args []string) error {
|
||||||
|
// Determine which inbox to check (priority: --identity flag, positional arg, auto-detect)
|
||||||
|
address := ""
|
||||||
|
if mailInboxIdentity != "" {
|
||||||
|
address = mailInboxIdentity
|
||||||
|
} else if len(args) > 0 {
|
||||||
|
address = args[0]
|
||||||
|
} else {
|
||||||
|
address = detectSender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get messages
|
||||||
|
var messages []*mail.Message
|
||||||
|
if mailInboxUnread {
|
||||||
|
messages, err = mailbox.ListUnread()
|
||||||
|
} else {
|
||||||
|
messages, err = mailbox.List()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailInboxJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
total, unread, _ := mailbox.Count()
|
||||||
|
fmt.Printf("%s Inbox: %s (%d messages, %d unread)\n\n",
|
||||||
|
style.Bold.Render("📬"), address, total, unread)
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(no messages)"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
readMarker := "●"
|
||||||
|
if msg.Read {
|
||||||
|
readMarker = "○"
|
||||||
|
}
|
||||||
|
typeMarker := ""
|
||||||
|
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||||
|
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||||
|
}
|
||||||
|
priorityMarker := ""
|
||||||
|
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||||
|
priorityMarker = " " + style.Bold.Render("!")
|
||||||
|
}
|
||||||
|
wispMarker := ""
|
||||||
|
if msg.Wisp {
|
||||||
|
wispMarker = " " + style.Dim.Render("(wisp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
||||||
|
fmt.Printf(" %s from %s\n",
|
||||||
|
style.Dim.Render(msg.ID),
|
||||||
|
msg.From)
|
||||||
|
fmt.Printf(" %s\n",
|
||||||
|
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailRead(cmd *cobra.Command, args []string) error {
|
||||||
|
msgID := args[0]
|
||||||
|
|
||||||
|
// Determine which inbox
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox and message
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := mailbox.Get(msgID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We intentionally do NOT mark as read/ack on read.
|
||||||
|
// User must explicitly delete/ack the message.
|
||||||
|
// This preserves handoff messages for reference.
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailReadJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
priorityStr := ""
|
||||||
|
if msg.Priority == mail.PriorityUrgent {
|
||||||
|
priorityStr = " " + style.Bold.Render("[URGENT]")
|
||||||
|
} else if msg.Priority == mail.PriorityHigh {
|
||||||
|
priorityStr = " " + style.Bold.Render("[HIGH PRIORITY]")
|
||||||
|
}
|
||||||
|
|
||||||
|
typeStr := ""
|
||||||
|
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||||
|
typeStr = fmt.Sprintf(" [%s]", msg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s %s%s%s\n\n", style.Bold.Render("Subject:"), msg.Subject, typeStr, priorityStr)
|
||||||
|
fmt.Printf("From: %s\n", msg.From)
|
||||||
|
fmt.Printf("To: %s\n", msg.To)
|
||||||
|
fmt.Printf("Date: %s\n", msg.Timestamp.Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Printf("ID: %s\n", style.Dim.Render(msg.ID))
|
||||||
|
|
||||||
|
if msg.ThreadID != "" {
|
||||||
|
fmt.Printf("Thread: %s\n", style.Dim.Render(msg.ThreadID))
|
||||||
|
}
|
||||||
|
if msg.ReplyTo != "" {
|
||||||
|
fmt.Printf("Reply-To: %s\n", style.Dim.Render(msg.ReplyTo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Body != "" {
|
||||||
|
fmt.Printf("\n%s\n", msg.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailPeek(cmd *cobra.Command, args []string) error {
|
||||||
|
// Determine which inbox
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return NewSilentExit(1) // Silent exit - no workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return NewSilentExit(1) // Silent exit - can't access mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unread messages
|
||||||
|
messages, err := mailbox.ListUnread()
|
||||||
|
if err != nil || len(messages) == 0 {
|
||||||
|
return NewSilentExit(1) // Silent exit - no unread
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show first unread message
|
||||||
|
msg := messages[0]
|
||||||
|
|
||||||
|
// Header with priority indicator
|
||||||
|
priorityStr := ""
|
||||||
|
if msg.Priority == mail.PriorityUrgent {
|
||||||
|
priorityStr = " [URGENT]"
|
||||||
|
} else if msg.Priority == mail.PriorityHigh {
|
||||||
|
priorityStr = " [!]"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📬 %s%s\n", msg.Subject, priorityStr)
|
||||||
|
fmt.Printf("From: %s\n", msg.From)
|
||||||
|
fmt.Printf("ID: %s\n\n", msg.ID)
|
||||||
|
|
||||||
|
// Body preview (truncate long bodies)
|
||||||
|
if msg.Body != "" {
|
||||||
|
body := msg.Body
|
||||||
|
// Truncate to ~500 chars for popup display
|
||||||
|
if len(body) > 500 {
|
||||||
|
body = body[:500] + "\n..."
|
||||||
|
}
|
||||||
|
fmt.Print(body)
|
||||||
|
if !strings.HasSuffix(body, "\n") {
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show count if more messages
|
||||||
|
if len(messages) > 1 {
|
||||||
|
fmt.Printf("\n%s\n", style.Dim.Render(fmt.Sprintf("(+%d more unread)", len(messages)-1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailDelete(cmd *cobra.Command, args []string) error {
|
||||||
|
msgID := args[0]
|
||||||
|
|
||||||
|
// Determine which inbox
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mailbox.Delete(msgID); err != nil {
|
||||||
|
return fmt.Errorf("deleting message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Message deleted\n", style.Bold.Render("✓"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailArchive(cmd *cobra.Command, args []string) error {
|
||||||
|
// Determine which inbox
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive all specified messages
|
||||||
|
archived := 0
|
||||||
|
var errors []string
|
||||||
|
for _, msgID := range args {
|
||||||
|
if err := mailbox.Delete(msgID); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", msgID, err))
|
||||||
|
} else {
|
||||||
|
archived++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report results
|
||||||
|
if len(errors) > 0 {
|
||||||
|
fmt.Printf("%s Archived %d/%d messages\n",
|
||||||
|
style.Bold.Render("⚠"), archived, len(args))
|
||||||
|
for _, e := range errors {
|
||||||
|
fmt.Printf(" Error: %s\n", e)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to archive %d messages", len(errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 1 {
|
||||||
|
fmt.Printf("%s Message archived\n", style.Bold.Render("✓"))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s Archived %d messages\n", style.Bold.Render("✓"), archived)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailClear(cmd *cobra.Command, args []string) error {
|
||||||
|
// Determine which inbox to clear (target arg or auto-detect)
|
||||||
|
address := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
address = args[0]
|
||||||
|
} else {
|
||||||
|
address = detectSender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all messages
|
||||||
|
messages, err := mailbox.List()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf("%s Inbox %s is already empty\n", style.Dim.Render("○"), address)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete each message
|
||||||
|
deleted := 0
|
||||||
|
var errors []string
|
||||||
|
for _, msg := range messages {
|
||||||
|
if err := mailbox.Delete(msg.ID); err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %v", msg.ID, err))
|
||||||
|
} else {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report results
|
||||||
|
if len(errors) > 0 {
|
||||||
|
fmt.Printf("%s Cleared %d/%d messages from %s\n",
|
||||||
|
style.Bold.Render("⚠"), deleted, len(messages), address)
|
||||||
|
for _, e := range errors {
|
||||||
|
fmt.Printf(" Error: %s\n", e)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to clear %d messages", len(errors))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Cleared %d messages from %s\n",
|
||||||
|
style.Bold.Render("✓"), deleted, address)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
389
internal/cmd/mail_queue.go
Normal file
389
internal/cmd/mail_queue.go
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runMailClaim claims the oldest unclaimed message from a work queue.
|
||||||
|
func runMailClaim(cmd *cobra.Command, args []string) error {
|
||||||
|
queueName := args[0]
|
||||||
|
|
||||||
|
// Find workspace
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load queue config from messaging.json
|
||||||
|
configPath := config.MessagingConfigPath(townRoot)
|
||||||
|
cfg, err := config.LoadMessagingConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("loading messaging config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queueCfg, ok := cfg.Queues[queueName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown queue: %s", queueName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get caller identity
|
||||||
|
caller := detectSender()
|
||||||
|
|
||||||
|
// Check if caller is eligible (matches any pattern in workers list)
|
||||||
|
if !isEligibleWorker(caller, queueCfg.Workers) {
|
||||||
|
return fmt.Errorf("not eligible to claim from queue %s (caller: %s, workers: %v)",
|
||||||
|
queueName, caller, queueCfg.Workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List unclaimed messages in the queue
|
||||||
|
// Queue messages have assignee=queue:<name> and status=open
|
||||||
|
queueAssignee := "queue:" + queueName
|
||||||
|
messages, err := listQueueMessages(townRoot, queueAssignee)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing queue messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf("%s No messages to claim in queue %s\n", style.Dim.Render("○"), queueName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the oldest unclaimed message (first in list, sorted by created)
|
||||||
|
oldest := messages[0]
|
||||||
|
|
||||||
|
// Claim the message: set assignee to caller and status to in_progress
|
||||||
|
if err := claimMessage(townRoot, oldest.ID, caller); err != nil {
|
||||||
|
return fmt.Errorf("claiming message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print claimed message details
|
||||||
|
fmt.Printf("%s Claimed message from queue %s\n", style.Bold.Render("✓"), queueName)
|
||||||
|
fmt.Printf(" ID: %s\n", oldest.ID)
|
||||||
|
fmt.Printf(" Subject: %s\n", oldest.Title)
|
||||||
|
if oldest.Description != "" {
|
||||||
|
// Show first line of description
|
||||||
|
lines := strings.SplitN(oldest.Description, "\n", 2)
|
||||||
|
preview := lines[0]
|
||||||
|
if len(preview) > 80 {
|
||||||
|
preview = preview[:77] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf(" Preview: %s\n", style.Dim.Render(preview))
|
||||||
|
}
|
||||||
|
fmt.Printf(" From: %s\n", oldest.From)
|
||||||
|
fmt.Printf(" Created: %s\n", oldest.Created.Format("2006-01-02 15:04"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueMessage represents a message in a queue.
|
||||||
|
type queueMessage struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
From string
|
||||||
|
Created time.Time
|
||||||
|
Priority int
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEligibleWorker checks if the caller matches any pattern in the workers list.
|
||||||
|
// Patterns support wildcards: "gastown/polecats/*" matches "gastown/polecats/capable".
|
||||||
|
func isEligibleWorker(caller string, patterns []string) bool {
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if matchWorkerPattern(pattern, caller) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchWorkerPattern checks if caller matches the pattern.
|
||||||
|
// Supports simple wildcards: * matches a single path segment (no slashes).
|
||||||
|
func matchWorkerPattern(pattern, caller string) bool {
|
||||||
|
// Handle exact match
|
||||||
|
if pattern == caller {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wildcard patterns
|
||||||
|
if strings.Contains(pattern, "*") {
|
||||||
|
// Convert to simple glob matching
|
||||||
|
// "gastown/polecats/*" should match "gastown/polecats/capable"
|
||||||
|
// but NOT "gastown/polecats/sub/capable"
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
prefix := parts[0]
|
||||||
|
suffix := parts[1]
|
||||||
|
if strings.HasPrefix(caller, prefix) && strings.HasSuffix(caller, suffix) {
|
||||||
|
// Check that the middle part doesn't contain path separators
|
||||||
|
middle := caller[len(prefix) : len(caller)-len(suffix)]
|
||||||
|
if !strings.Contains(middle, "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// listQueueMessages lists unclaimed messages in a queue.
|
||||||
|
func listQueueMessages(townRoot, queueAssignee string) ([]queueMessage, error) {
|
||||||
|
// Use bd list to find messages with assignee=queue:<name> and status=open
|
||||||
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
|
|
||||||
|
args := []string{"list",
|
||||||
|
"--assignee", queueAssignee,
|
||||||
|
"--status", "open",
|
||||||
|
"--type", "message",
|
||||||
|
"--sort", "created",
|
||||||
|
"--limit", "0", // No limit
|
||||||
|
"--json",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg != "" {
|
||||||
|
return nil, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON output
|
||||||
|
var issues []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||||
|
// If no messages, bd might output empty or error
|
||||||
|
if strings.TrimSpace(stdout.String()) == "" || strings.TrimSpace(stdout.String()) == "[]" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("parsing bd output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to queueMessage, extracting 'from' from labels
|
||||||
|
var messages []queueMessage
|
||||||
|
for _, issue := range issues {
|
||||||
|
msg := queueMessage{
|
||||||
|
ID: issue.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Description: issue.Description,
|
||||||
|
Created: issue.CreatedAt,
|
||||||
|
Priority: issue.Priority,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract 'from' from labels (format: "from:address")
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
if strings.HasPrefix(label, "from:") {
|
||||||
|
msg.From = strings.TrimPrefix(label, "from:")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by created time (oldest first)
|
||||||
|
sort.Slice(messages, func(i, j int) bool {
|
||||||
|
return messages[i].Created.Before(messages[j].Created)
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimMessage claims a message by setting assignee and status.
|
||||||
|
func claimMessage(townRoot, messageID, claimant string) error {
|
||||||
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
|
|
||||||
|
args := []string{"update", messageID,
|
||||||
|
"--assignee", claimant,
|
||||||
|
"--status", "in_progress",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BEADS_DIR="+beadsDir,
|
||||||
|
"BD_ACTOR="+claimant,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg != "" {
|
||||||
|
return fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMailRelease releases a claimed queue message back to its queue.
|
||||||
|
func runMailRelease(cmd *cobra.Command, args []string) error {
|
||||||
|
messageID := args[0]
|
||||||
|
|
||||||
|
// Find workspace
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get caller identity
|
||||||
|
caller := detectSender()
|
||||||
|
|
||||||
|
// Get message details to verify ownership and find queue
|
||||||
|
msgInfo, err := getMessageInfo(townRoot, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message exists and is a queue message
|
||||||
|
if msgInfo.QueueName == "" {
|
||||||
|
return fmt.Errorf("message %s is not a queue message (no queue label)", messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify caller is the one who claimed it
|
||||||
|
if msgInfo.Assignee != caller {
|
||||||
|
if strings.HasPrefix(msgInfo.Assignee, "queue:") {
|
||||||
|
return fmt.Errorf("message %s is not claimed (still in queue)", messageID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("message %s was claimed by %s, not %s", messageID, msgInfo.Assignee, caller)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release the message: set assignee back to queue and status to open
|
||||||
|
queueAssignee := "queue:" + msgInfo.QueueName
|
||||||
|
if err := releaseMessage(townRoot, messageID, queueAssignee, caller); err != nil {
|
||||||
|
return fmt.Errorf("releasing message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Released message back to queue %s\n", style.Bold.Render("✓"), msgInfo.QueueName)
|
||||||
|
fmt.Printf(" ID: %s\n", messageID)
|
||||||
|
fmt.Printf(" Subject: %s\n", msgInfo.Title)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageInfo holds details about a queue message.
|
||||||
|
type messageInfo struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Assignee string
|
||||||
|
QueueName string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMessageInfo retrieves information about a message.
|
||||||
|
func getMessageInfo(townRoot, messageID string) (*messageInfo, error) {
|
||||||
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
|
|
||||||
|
args := []string{"show", messageID, "--json"}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if strings.Contains(errMsg, "not found") {
|
||||||
|
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||||
|
}
|
||||||
|
if errMsg != "" {
|
||||||
|
return nil, fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON output - bd show --json returns an array
|
||||||
|
var issues []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Assignee string `json:"assignee"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return nil, fmt.Errorf("message not found: %s", messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := issues[0]
|
||||||
|
info := &messageInfo{
|
||||||
|
ID: issue.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Assignee: issue.Assignee,
|
||||||
|
Status: issue.Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract queue name from labels (format: "queue:<name>")
|
||||||
|
for _, label := range issue.Labels {
|
||||||
|
if strings.HasPrefix(label, "queue:") {
|
||||||
|
info.QueueName = strings.TrimPrefix(label, "queue:")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseMessage releases a claimed message back to its queue.
|
||||||
|
func releaseMessage(townRoot, messageID, queueAssignee, actor string) error {
|
||||||
|
beadsDir := filepath.Join(townRoot, ".beads")
|
||||||
|
|
||||||
|
args := []string{"update", messageID,
|
||||||
|
"--assignee", queueAssignee,
|
||||||
|
"--status", "open",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"BEADS_DIR="+beadsDir,
|
||||||
|
"BD_ACTOR="+actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
errMsg := strings.TrimSpace(stderr.String())
|
||||||
|
if errMsg != "" {
|
||||||
|
return fmt.Errorf("%s", errMsg)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
90
internal/cmd/mail_search.go
Normal file
90
internal/cmd/mail_search.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runMailSearch searches for messages matching a pattern.
|
||||||
|
func runMailSearch(cmd *cobra.Command, args []string) error {
|
||||||
|
query := args[0]
|
||||||
|
|
||||||
|
// Determine which inbox to search
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// Get workspace for mail operations
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mailbox
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build search options
|
||||||
|
opts := mail.SearchOptions{
|
||||||
|
Query: query,
|
||||||
|
FromFilter: mailSearchFrom,
|
||||||
|
SubjectOnly: mailSearchSubject,
|
||||||
|
BodyOnly: mailSearchBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute search
|
||||||
|
messages, err := mailbox.Search(opts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("searching messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailSearchJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("%s Search results for %s: %d message(s)\n\n",
|
||||||
|
style.Bold.Render("🔍"), address, len(messages))
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(no matches)"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
readMarker := "●"
|
||||||
|
if msg.Read {
|
||||||
|
readMarker = "○"
|
||||||
|
}
|
||||||
|
typeMarker := ""
|
||||||
|
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||||
|
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||||
|
}
|
||||||
|
priorityMarker := ""
|
||||||
|
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||||
|
priorityMarker = " " + style.Bold.Render("!")
|
||||||
|
}
|
||||||
|
wispMarker := ""
|
||||||
|
if msg.Wisp {
|
||||||
|
wispMarker = " " + style.Dim.Render("(wisp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s %s%s%s%s\n", readMarker, msg.Subject, typeMarker, priorityMarker, wispMarker)
|
||||||
|
fmt.Printf(" %s from %s\n",
|
||||||
|
style.Dim.Render(msg.ID),
|
||||||
|
msg.From)
|
||||||
|
fmt.Printf(" %s\n",
|
||||||
|
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
155
internal/cmd/mail_send.go
Normal file
155
internal/cmd/mail_send.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMailSend(cmd *cobra.Command, args []string) error {
|
||||||
|
var to string
|
||||||
|
|
||||||
|
if mailSendSelf {
|
||||||
|
// Auto-detect identity from cwd
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("detecting role: %w", err)
|
||||||
|
}
|
||||||
|
ctx := RoleContext{
|
||||||
|
Role: roleInfo.Role,
|
||||||
|
Rig: roleInfo.Rig,
|
||||||
|
Polecat: roleInfo.Polecat,
|
||||||
|
TownRoot: townRoot,
|
||||||
|
WorkDir: cwd,
|
||||||
|
}
|
||||||
|
to = buildAgentIdentity(ctx)
|
||||||
|
if to == "" {
|
||||||
|
return fmt.Errorf("cannot determine identity (role: %s)", ctx.Role)
|
||||||
|
}
|
||||||
|
} else if len(args) > 0 {
|
||||||
|
to = args[0]
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("address required (or use --self)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine sender
|
||||||
|
from := detectSender()
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
msg := &mail.Message{
|
||||||
|
From: from,
|
||||||
|
To: to,
|
||||||
|
Subject: mailSubject,
|
||||||
|
Body: mailBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set priority (--urgent overrides --priority)
|
||||||
|
if mailUrgent {
|
||||||
|
msg.Priority = mail.PriorityUrgent
|
||||||
|
} else {
|
||||||
|
msg.Priority = mail.PriorityFromInt(mailPriority)
|
||||||
|
}
|
||||||
|
if mailNotify && msg.Priority == mail.PriorityNormal {
|
||||||
|
msg.Priority = mail.PriorityHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set message type
|
||||||
|
msg.Type = mail.ParseMessageType(mailType)
|
||||||
|
|
||||||
|
// Set pinned flag
|
||||||
|
msg.Pinned = mailPinned
|
||||||
|
|
||||||
|
// Set wisp flag (ephemeral message) - default true, --permanent overrides
|
||||||
|
msg.Wisp = mailWisp && !mailPermanent
|
||||||
|
|
||||||
|
// Set CC recipients
|
||||||
|
msg.CC = mailCC
|
||||||
|
|
||||||
|
// Handle reply-to: auto-set type to reply and look up thread
|
||||||
|
if mailReplyTo != "" {
|
||||||
|
msg.ReplyTo = mailReplyTo
|
||||||
|
if msg.Type == mail.TypeNotification {
|
||||||
|
msg.Type = mail.TypeReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up original message to get thread ID
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(from)
|
||||||
|
if err == nil {
|
||||||
|
if original, err := mailbox.Get(mailReplyTo); err == nil {
|
||||||
|
msg.ThreadID = original.ThreadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thread ID for new threads
|
||||||
|
if msg.ThreadID == "" {
|
||||||
|
msg.ThreadID = generateThreadID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send via router
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
|
||||||
|
// Check if this is a list address to show fan-out details
|
||||||
|
var listRecipients []string
|
||||||
|
if strings.HasPrefix(to, "list:") {
|
||||||
|
var err error
|
||||||
|
listRecipients, err = router.ExpandListAddress(to)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending message: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := router.Send(msg); err != nil {
|
||||||
|
return fmt.Errorf("sending message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log mail event to activity feed
|
||||||
|
_ = events.LogFeed(events.TypeMail, from, events.MailPayload(to, mailSubject))
|
||||||
|
|
||||||
|
fmt.Printf("%s Message sent to %s\n", style.Bold.Render("✓"), to)
|
||||||
|
fmt.Printf(" Subject: %s\n", mailSubject)
|
||||||
|
|
||||||
|
// Show fan-out recipients for list addresses
|
||||||
|
if len(listRecipients) > 0 {
|
||||||
|
fmt.Printf(" Recipients: %s\n", strings.Join(listRecipients, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.CC) > 0 {
|
||||||
|
fmt.Printf(" CC: %s\n", strings.Join(msg.CC, ", "))
|
||||||
|
}
|
||||||
|
if msg.Type != mail.TypeNotification {
|
||||||
|
fmt.Printf(" Type: %s\n", msg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateThreadID creates a random thread ID for new message threads.
|
||||||
|
func generateThreadID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
_, _ = rand.Read(b) // crypto/rand.Read only fails on broken system
|
||||||
|
return "thread-" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
145
internal/cmd/mail_thread.go
Normal file
145
internal/cmd/mail_thread.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runMailThread(cmd *cobra.Command, args []string) error {
|
||||||
|
threadID := args[0]
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which inbox
|
||||||
|
address := detectSender()
|
||||||
|
|
||||||
|
// Get mailbox and thread messages
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(address)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := mailbox.ListByThread(threadID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting thread: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output
|
||||||
|
if mailThreadJSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable output
|
||||||
|
fmt.Printf("%s Thread: %s (%d messages)\n\n",
|
||||||
|
style.Bold.Render("🧵"), threadID, len(messages))
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("(no messages in thread)"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, msg := range messages {
|
||||||
|
typeMarker := ""
|
||||||
|
if msg.Type != "" && msg.Type != mail.TypeNotification {
|
||||||
|
typeMarker = fmt.Sprintf(" [%s]", msg.Type)
|
||||||
|
}
|
||||||
|
priorityMarker := ""
|
||||||
|
if msg.Priority == mail.PriorityHigh || msg.Priority == mail.PriorityUrgent {
|
||||||
|
priorityMarker = " " + style.Bold.Render("!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Printf(" %s\n", style.Dim.Render("│"))
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s%s%s\n", style.Bold.Render("●"), msg.Subject, typeMarker, priorityMarker)
|
||||||
|
fmt.Printf(" %s from %s to %s\n",
|
||||||
|
style.Dim.Render(msg.ID),
|
||||||
|
msg.From, msg.To)
|
||||||
|
fmt.Printf(" %s\n",
|
||||||
|
style.Dim.Render(msg.Timestamp.Format("2006-01-02 15:04")))
|
||||||
|
|
||||||
|
if msg.Body != "" {
|
||||||
|
fmt.Printf(" %s\n", msg.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMailReply(cmd *cobra.Command, args []string) error {
|
||||||
|
msgID := args[0]
|
||||||
|
|
||||||
|
// All mail uses town beads (two-level architecture)
|
||||||
|
workDir, err := findMailWorkDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current address
|
||||||
|
from := detectSender()
|
||||||
|
|
||||||
|
// Get the original message
|
||||||
|
router := mail.NewRouter(workDir)
|
||||||
|
mailbox, err := router.GetMailbox(from)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting mailbox: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
original, err := mailbox.Get(msgID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build reply subject
|
||||||
|
subject := mailReplySubject
|
||||||
|
if subject == "" {
|
||||||
|
if strings.HasPrefix(original.Subject, "Re: ") {
|
||||||
|
subject = original.Subject
|
||||||
|
} else {
|
||||||
|
subject = "Re: " + original.Subject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reply message
|
||||||
|
reply := &mail.Message{
|
||||||
|
From: from,
|
||||||
|
To: original.From, // Reply to sender
|
||||||
|
Subject: subject,
|
||||||
|
Body: mailReplyMessage,
|
||||||
|
Type: mail.TypeReply,
|
||||||
|
Priority: mail.PriorityNormal,
|
||||||
|
ReplyTo: msgID,
|
||||||
|
ThreadID: original.ThreadID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If original has no thread ID, create one
|
||||||
|
if reply.ThreadID == "" {
|
||||||
|
reply.ThreadID = generateThreadID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the reply
|
||||||
|
if err := router.Send(reply); err != nil {
|
||||||
|
return fmt.Errorf("sending reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Reply sent to %s\n", style.Bold.Render("✓"), original.From)
|
||||||
|
fmt.Printf(" Subject: %s\n", subject)
|
||||||
|
if original.ThreadID != "" {
|
||||||
|
fmt.Printf(" Thread: %s\n", style.Dim.Render(original.ThreadID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user