feat(identity): add gt commit wrapper and gt trail command
gt-f6mkz: Agent git identity - Add `gt commit` wrapper that sets git author from agent identity - Identity mapping: gastown/crew/jack → gastown.crew.jack@gastown.local - Add `agent_email_domain` to TownSettings (default: gastown.local) - Add `gt config agent-email-domain` command to manage domain gt-j1m5v: gt trail command - Add `gt trail` with aliases `gt recent` and `gt recap` - Subcommands: commits, beads, hooks - Flags: --since, --limit, --json, --all - Filter commits by agent email domain Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
0a6fa457f6
commit
60da5de104
118
internal/cmd/commit.go
Normal file
118
internal/cmd/commit.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
internal/cmd/commit_test.go
Normal file
71
internal/cmd/commit_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,27 @@ Examples:
|
|||||||
RunE: runConfigDefaultAgent,
|
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
|
// Flags
|
||||||
var (
|
var (
|
||||||
configAgentListJSON bool
|
configAgentListJSON bool
|
||||||
@@ -444,6 +465,54 @@ func runConfigDefaultAgent(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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() {
|
func init() {
|
||||||
// Add flags
|
// Add flags
|
||||||
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
|
configAgentListCmd.Flags().BoolVar(&configAgentListJSON, "json", false, "Output as JSON")
|
||||||
@@ -462,6 +531,7 @@ func init() {
|
|||||||
// Add subcommands to config
|
// Add subcommands to config
|
||||||
configCmd.AddCommand(configAgentCmd)
|
configCmd.AddCommand(configAgentCmd)
|
||||||
configCmd.AddCommand(configDefaultAgentCmd)
|
configCmd.AddCommand(configDefaultAgentCmd)
|
||||||
|
configCmd.AddCommand(configAgentEmailDomainCmd)
|
||||||
|
|
||||||
// Register with root
|
// Register with root
|
||||||
rootCmd.AddCommand(configCmd)
|
rootCmd.AddCommand(configCmd)
|
||||||
|
|||||||
389
internal/cmd/trail.go
Normal file
389
internal/cmd/trail.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@ type TownSettings struct {
|
|||||||
// This allows cost optimization by using different models for different roles.
|
// This allows cost optimization by using different models for different roles.
|
||||||
// Example: {"mayor": "claude-opus", "witness": "claude-haiku", "polecat": "claude-sonnet"}
|
// Example: {"mayor": "claude-opus", "witness": "claude-haiku", "polecat": "claude-sonnet"}
|
||||||
RoleAgents map[string]string `json:"role_agents,omitempty"`
|
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.
|
// NewTownSettings creates a new TownSettings with defaults.
|
||||||
|
|||||||
Reference in New Issue
Block a user