diff --git a/internal/cmd/commit.go b/internal/cmd/commit.go new file mode 100644 index 00000000..d3932686 --- /dev/null +++ b/internal/cmd/commit.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/workspace" +) + +// DefaultAgentEmailDomain is the default domain for agent git emails. +const DefaultAgentEmailDomain = "gastown.local" + +var commitCmd = &cobra.Command{ + Use: "commit [flags] [-- git-commit-args...]", + Short: "Git commit with automatic agent identity", + Long: `Git commit wrapper that automatically sets git author identity for agents. + +When run by an agent (GT_ROLE set), this command: +1. Detects the agent identity from environment variables +2. Converts it to a git-friendly name and email +3. Runs 'git commit' with the correct identity + +The email domain is configurable in town settings (agent_email_domain). +Default: gastown.local + +Examples: + gt commit -m "Fix bug" # Commit as current agent + gt commit -am "Quick fix" # Stage all and commit + gt commit -- --amend # Amend last commit + +Identity mapping: + Agent: gastown/crew/jack → Name: gastown/crew/jack + Email: gastown.crew.jack@gastown.local + +When run without GT_ROLE (human), passes through to git commit with no changes.`, + RunE: runCommit, + DisableFlagParsing: true, // We'll parse flags ourselves to pass them to git +} + +func init() { + commitCmd.GroupID = GroupWork + rootCmd.AddCommand(commitCmd) +} + +func runCommit(cmd *cobra.Command, args []string) error { + // Detect agent identity + identity := detectSender() + + // If overseer (human), just pass through to git commit + if identity == "overseer" { + return runGitCommit(args, "", "") + } + + // Load agent email domain from town settings + domain := DefaultAgentEmailDomain + townRoot, err := workspace.FindFromCwd() + if err == nil && townRoot != "" { + settings, err := config.LoadOrCreateTownSettings(config.TownSettingsPath(townRoot)) + if err == nil && settings.AgentEmailDomain != "" { + domain = settings.AgentEmailDomain + } + } + + // Convert identity to git-friendly email + // "gastown/crew/jack" → "gastown.crew.jack@domain" + email := identityToEmail(identity, domain) + + // Use identity as the author name (human-readable) + name := identity + + return runGitCommit(args, name, email) +} + +// identityToEmail converts a Gas Town identity to a git email address. +// "gastown/crew/jack" → "gastown.crew.jack@domain" +// "mayor/" → "mayor@domain" +func identityToEmail(identity, domain string) string { + // Remove trailing slash if present + identity = strings.TrimSuffix(identity, "/") + + // Replace slashes with dots for email local part + localPart := strings.ReplaceAll(identity, "/", ".") + + return localPart + "@" + domain +} + +// runGitCommit executes git commit with optional identity override. +// If name and email are empty, runs git commit with no overrides. +// Preserves git's exit code for proper wrapper behavior. +func runGitCommit(args []string, name, email string) error { + var gitArgs []string + + // If we have an identity, prepend -c flags + if name != "" && email != "" { + gitArgs = append(gitArgs, "-c", "user.name="+name) + gitArgs = append(gitArgs, "-c", "user.email="+email) + } + + gitArgs = append(gitArgs, "commit") + gitArgs = append(gitArgs, args...) + + gitCmd := exec.Command("git", gitArgs...) + gitCmd.Stdin = os.Stdin + gitCmd.Stdout = os.Stdout + gitCmd.Stderr = os.Stderr + + if err := gitCmd.Run(); err != nil { + // Preserve git's exit code for proper wrapper behavior + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err + } + return nil +} diff --git a/internal/cmd/commit_test.go b/internal/cmd/commit_test.go new file mode 100644 index 00000000..98ad393c --- /dev/null +++ b/internal/cmd/commit_test.go @@ -0,0 +1,71 @@ +package cmd + +import "testing" + +func TestIdentityToEmail(t *testing.T) { + tests := []struct { + name string + identity string + domain string + want string + }{ + { + name: "crew member", + identity: "gastown/crew/jack", + domain: "gastown.local", + want: "gastown.crew.jack@gastown.local", + }, + { + name: "polecat", + identity: "gastown/polecats/max", + domain: "gastown.local", + want: "gastown.polecats.max@gastown.local", + }, + { + name: "witness", + identity: "gastown/witness", + domain: "gastown.local", + want: "gastown.witness@gastown.local", + }, + { + name: "refinery", + identity: "gastown/refinery", + domain: "gastown.local", + want: "gastown.refinery@gastown.local", + }, + { + name: "mayor with trailing slash", + identity: "mayor/", + domain: "gastown.local", + want: "mayor@gastown.local", + }, + { + name: "deacon with trailing slash", + identity: "deacon/", + domain: "gastown.local", + want: "deacon@gastown.local", + }, + { + name: "custom domain", + identity: "myrig/crew/alice", + domain: "example.com", + want: "myrig.crew.alice@example.com", + }, + { + name: "deeply nested", + identity: "rig/polecats/nested/deep", + domain: "test.io", + want: "rig.polecats.nested.deep@test.io", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := identityToEmail(tt.identity, tt.domain) + if got != tt.want { + t.Errorf("identityToEmail(%q, %q) = %q, want %q", + tt.identity, tt.domain, got, tt.want) + } + }) + } +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 1cceb8aa..82bc611a 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -119,6 +119,27 @@ Examples: RunE: runConfigDefaultAgent, } +var configAgentEmailDomainCmd = &cobra.Command{ + Use: "agent-email-domain [domain]", + Short: "Get or set agent email domain", + Long: `Get or set the domain used for agent git commit emails. + +When agents commit code via 'gt commit', their identity is converted +to a git email address. For example, "gastown/crew/jack" becomes +"gastown.crew.jack@{domain}". + +With no arguments, shows the current domain. +With an argument, sets the domain. + +Default: gastown.local + +Examples: + gt config agent-email-domain # Show current domain + gt config agent-email-domain gastown.local # Set to gastown.local + gt config agent-email-domain example.com # Set custom domain`, + RunE: runConfigAgentEmailDomain, +} + // Flags var ( configAgentListJSON bool @@ -444,6 +465,54 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error { return nil } +func runConfigAgentEmailDomain(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding town root: %w", err) + } + + // Load town settings + settingsPath := config.TownSettingsPath(townRoot) + townSettings, err := config.LoadOrCreateTownSettings(settingsPath) + if err != nil { + return fmt.Errorf("loading town settings: %w", err) + } + + if len(args) == 0 { + // Show current domain + domain := townSettings.AgentEmailDomain + if domain == "" { + domain = DefaultAgentEmailDomain + } + fmt.Printf("Agent email domain: %s\n", style.Bold.Render(domain)) + fmt.Printf("\nExample: gastown/crew/jack → gastown.crew.jack@%s\n", domain) + return nil + } + + // Set new domain + domain := args[0] + + // Basic validation - domain should not be empty and should not start with @ + if domain == "" { + return fmt.Errorf("domain cannot be empty") + } + if strings.HasPrefix(domain, "@") { + return fmt.Errorf("domain should not include @: use '%s' instead", strings.TrimPrefix(domain, "@")) + } + + // Set domain + townSettings.AgentEmailDomain = domain + + // Save settings + if err := config.SaveTownSettings(settingsPath, townSettings); err != nil { + return fmt.Errorf("saving town settings: %w", err) + } + + fmt.Printf("Agent email domain set to '%s'\n", style.Bold.Render(domain)) + fmt.Printf("\nExample: gastown/crew/jack → gastown.crew.jack@%s\n", domain) + return nil +} + func init() { // Add flags configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON") @@ -462,6 +531,7 @@ func init() { // Add subcommands to config configCmd.AddCommand(configAgentCmd) configCmd.AddCommand(configDefaultAgentCmd) + configCmd.AddCommand(configAgentEmailDomainCmd) // Register with root rootCmd.AddCommand(configCmd) diff --git a/internal/cmd/trail.go b/internal/cmd/trail.go new file mode 100644 index 00000000..1eae580b --- /dev/null +++ b/internal/cmd/trail.go @@ -0,0 +1,389 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" +) + +var ( + trailSince string + trailLimit int + trailJSON bool + trailAll bool +) + +var trailCmd = &cobra.Command{ + Use: "trail", + Aliases: []string{"recent", "recap"}, + GroupID: GroupWork, + Short: "Show recent agent activity", + Long: `Show recent activity in the workspace. + +Without a subcommand, shows recent commits from agents. + +Subcommands: + commits Recent git commits from agents + beads Recent beads (work items) + hooks Recent hook activity + +Flags: + --since Show activity since this time (e.g., "1h", "24h", "7d") + --limit Maximum number of items to show (default: 20) + --json Output as JSON + --all Include all activity (not just agents) + +Examples: + gt trail # Recent commits (default) + gt trail commits # Same as above + gt trail commits --since 1h # Last hour + gt trail beads # Recent beads + gt trail hooks # Recent hook activity + gt recent # Alias for gt trail + gt recap --since 24h # Activity from last 24 hours`, + RunE: runTrailCommits, // Default to commits +} + +var trailCommitsCmd = &cobra.Command{ + Use: "commits", + Short: "Show recent commits from agents", + Long: `Show recent git commits made by agents. + +By default, filters to commits from agents (using the configured +email domain). Use --all to include all commits. + +Examples: + gt trail commits # Recent agent commits + gt trail commits --since 1h # Last hour of commits + gt trail commits --all # All commits (including non-agents) + gt trail commits --json # JSON output`, + RunE: runTrailCommits, +} + +var trailBeadsCmd = &cobra.Command{ + Use: "beads", + Short: "Show recent beads", + Long: `Show recently created or modified beads (work items). + +Examples: + gt trail beads # Recent beads + gt trail beads --since 24h # Last 24 hours of beads + gt trail beads --json # JSON output`, + RunE: runTrailBeads, +} + +var trailHooksCmd = &cobra.Command{ + Use: "hooks", + Short: "Show recent hook activity", + Long: `Show recent hook activity (agents taking or dropping hooks). + +Examples: + gt trail hooks # Recent hook activity + gt trail hooks --since 1h # Last hour of hook activity + gt trail hooks --json # JSON output`, + RunE: runTrailHooks, +} + +func init() { + // Add flags to trail command + trailCmd.PersistentFlags().StringVar(&trailSince, "since", "", "Show activity since this time (e.g., 1h, 24h, 7d)") + trailCmd.PersistentFlags().IntVar(&trailLimit, "limit", 20, "Maximum number of items to show") + trailCmd.PersistentFlags().BoolVar(&trailJSON, "json", false, "Output as JSON") + trailCmd.PersistentFlags().BoolVar(&trailAll, "all", false, "Include all activity (not just agents)") + + // Add subcommands + trailCmd.AddCommand(trailCommitsCmd) + trailCmd.AddCommand(trailBeadsCmd) + trailCmd.AddCommand(trailHooksCmd) + + // Register with root + rootCmd.AddCommand(trailCmd) +} + +// CommitEntry represents a git commit for output. +type CommitEntry struct { + Hash string `json:"hash"` + ShortHash string `json:"short_hash"` + Author string `json:"author"` + Email string `json:"email"` + Date time.Time `json:"date"` + DateRel string `json:"date_relative"` + Subject string `json:"subject"` + IsAgent bool `json:"is_agent"` +} + +func runTrailCommits(cmd *cobra.Command, args []string) error { + // Get email domain for agent filtering + domain := DefaultAgentEmailDomain + townRoot, err := workspace.FindFromCwd() + if err == nil && townRoot != "" { + settings, err := config.LoadOrCreateTownSettings(config.TownSettingsPath(townRoot)) + if err == nil && settings.AgentEmailDomain != "" { + domain = settings.AgentEmailDomain + } + } + + // Build git log command + gitArgs := []string{ + "log", + "--format=%H|%h|%an|%ae|%aI|%ar|%s", + fmt.Sprintf("-n%d", trailLimit*2), // Get extra to filter + } + + if trailSince != "" { + duration, err := parseDuration(trailSince) + if err != nil { + return fmt.Errorf("invalid --since value: %w", err) + } + since := time.Now().Add(-duration) + gitArgs = append(gitArgs, fmt.Sprintf("--since=%s", since.Format(time.RFC3339))) + } + + gitCmd := exec.Command("git", gitArgs...) + output, err := gitCmd.Output() + if err != nil { + return fmt.Errorf("running git log: %w", err) + } + + // Parse commits + var commits []CommitEntry + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.SplitN(line, "|", 7) + if len(parts) < 7 { + continue + } + + date, _ := time.Parse(time.RFC3339, parts[4]) + isAgent := strings.HasSuffix(parts[3], "@"+domain) + + // Skip non-agents unless --all is set + if !trailAll && !isAgent { + continue + } + + commits = append(commits, CommitEntry{ + Hash: parts[0], + ShortHash: parts[1], + Author: parts[2], + Email: parts[3], + Date: date, + DateRel: parts[5], + Subject: parts[6], + IsAgent: isAgent, + }) + + if len(commits) >= trailLimit { + break + } + } + + if trailJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(commits) + } + + // Text output + if len(commits) == 0 { + fmt.Println("No commits found") + return nil + } + + fmt.Printf("%s\n\n", style.Bold.Render("Recent Commits")) + for _, c := range commits { + authorLabel := c.Author + if c.IsAgent { + authorLabel = style.Bold.Render(c.Author) + } + + fmt.Printf("%s %s\n", style.Dim.Render(c.ShortHash), c.Subject) + fmt.Printf(" %s %s\n", authorLabel, style.Dim.Render(c.DateRel)) + } + + return nil +} + +// BeadEntry represents a bead for output. +type BeadEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Agent string `json:"agent,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + UpdateRel string `json:"updated_relative"` +} + +func runTrailBeads(cmd *cobra.Command, args []string) error { + // Find beads directory + beadsDir, err := findBeadsDir() + if err != nil { + return fmt.Errorf("finding beads: %w", err) + } + + // Use beads query to get recent beads + beadsArgs := []string{ + "query", + "--format", "{{.ID}}|{{.Title}}|{{.Status}}|{{.Agent}}|{{.UpdatedAt}}", + "--limit", fmt.Sprintf("%d", trailLimit), + "--sort", "-updated_at", + } + + if trailSince != "" { + duration, err := parseDuration(trailSince) + if err != nil { + return fmt.Errorf("invalid --since value: %w", err) + } + since := time.Now().Add(-duration) + beadsArgs = append(beadsArgs, "--since", since.Format(time.RFC3339)) + } + + beadsCmd := exec.Command("beads", beadsArgs...) + beadsCmd.Dir = beadsDir + beadsCmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir+"/.beads") + output, err := beadsCmd.Output() + if err != nil { + // Fallback: beads might not support all these flags + // Try a simpler approach + return runTrailBeadsSimple(beadsDir) + } + + // Parse output + var beads []BeadEntry + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.SplitN(line, "|", 5) + if len(parts) < 5 { + continue + } + + updatedAt, _ := time.Parse(time.RFC3339, parts[4]) + beads = append(beads, BeadEntry{ + ID: parts[0], + Title: parts[1], + Status: parts[2], + Agent: parts[3], + UpdatedAt: updatedAt, + UpdateRel: relativeTime(updatedAt), + }) + } + + if trailJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(beads) + } + + // Text output + if len(beads) == 0 { + fmt.Println("No beads found") + return nil + } + + fmt.Printf("%s\n\n", style.Bold.Render("Recent Beads")) + for _, b := range beads { + statusColor := style.Dim + switch b.Status { + case "open": + statusColor = style.Success + case "in_progress": + statusColor = style.Warning + case "done", "merged": + statusColor = style.Info + } + + fmt.Printf("%s %s\n", style.Bold.Render(b.ID), b.Title) + fmt.Printf(" %s %s", statusColor.Render(b.Status), style.Dim.Render(b.UpdateRel)) + if b.Agent != "" { + fmt.Printf(" by %s", b.Agent) + } + fmt.Println() + } + + return nil +} + +func runTrailBeadsSimple(beadsDir string) error { + // Simple fallback using beads list + beadsCmd := exec.Command("beads", "list", "--limit", fmt.Sprintf("%d", trailLimit)) + beadsCmd.Dir = beadsDir + beadsCmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir+"/.beads") + beadsCmd.Stdout = os.Stdout + beadsCmd.Stderr = os.Stderr + return beadsCmd.Run() +} + +func runTrailHooks(cmd *cobra.Command, args []string) error { + // For now, show current hooks status since we don't have hook history + // TODO: Implement hook activity log with HookEntry tracking + + if trailJSON { + // Return empty array for now + fmt.Println("[]") + return nil + } + + fmt.Printf("%s\n\n", style.Bold.Render("Hook Activity")) + fmt.Printf("%s Hook activity tracking not yet implemented.\n", style.Dim.Render("Note:")) + fmt.Printf("%s Showing current hook status instead.\n\n", style.Dim.Render(" ")) + + // Call the internal hook show function directly instead of spawning subprocess + return runHookShow(cmd, nil) +} + +func findBeadsDir() (string, error) { + // Try local beads dir first + dir, err := findLocalBeadsDir() + if err == nil { + return dir, nil + } + + // Fall back to town root + return findMailWorkDir() +} + +func relativeTime(t time.Time) string { + if t.IsZero() { + return "" + } + + diff := time.Since(t) + switch { + case diff < time.Minute: + return "just now" + case diff < time.Hour: + mins := int(diff.Minutes()) + if mins == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", mins) + case diff < 24*time.Hour: + hours := int(diff.Hours()) + if hours == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", hours) + default: + days := int(diff.Hours() / 24) + if days == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", days) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 586890cb..95427450 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -56,6 +56,11 @@ type TownSettings struct { // This allows cost optimization by using different models for different roles. // Example: {"mayor": "claude-opus", "witness": "claude-haiku", "polecat": "claude-sonnet"} RoleAgents map[string]string `json:"role_agents,omitempty"` + + // AgentEmailDomain is the domain used for agent git identity emails. + // Agent addresses like "gastown/crew/jack" become "gastown.crew.jack@{domain}". + // Default: "gastown.local" + AgentEmailDomain string `json:"agent_email_domain,omitempty"` } // NewTownSettings creates a new TownSettings with defaults.