package cmd import ( "bufio" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/townlog" "github.com/steveyegge/gastown/internal/workspace" ) // Audit command flags var ( auditActor string auditSince string auditLimit int auditJSON bool ) var auditCmd = &cobra.Command{ Use: "audit", GroupID: GroupDiag, Short: "Query work history by actor", Long: `Query provenance data across git commits, beads, and events. Shows a unified timeline of work performed by an actor including: - Git commits authored by the actor - Beads (issues) created by the actor - Beads closed by the actor (via assignee) - Town log events (spawn, done, handoff, etc.) - Activity feed events Examples: gt audit --actor=greenplace/crew/joe # Show all work by joe gt audit --actor=greenplace/polecats/toast # Show polecat toast's work gt audit --actor=mayor # Show mayor's activity gt audit --since=24h # Show all activity in last 24h gt audit --actor=joe --since=1h # Combined filters gt audit --json # Output as JSON`, RunE: runAudit, } func init() { auditCmd.Flags().StringVar(&auditActor, "actor", "", "Filter by actor (agent address or partial match)") auditCmd.Flags().StringVar(&auditSince, "since", "", "Show events since duration (e.g., 1h, 24h, 7d)") auditCmd.Flags().IntVarP(&auditLimit, "limit", "n", 50, "Maximum number of entries to show") auditCmd.Flags().BoolVar(&auditJSON, "json", false, "Output as JSON") rootCmd.AddCommand(auditCmd) } // AuditEntry represents a single entry in the audit log. type AuditEntry struct { Timestamp time.Time `json:"timestamp"` Source string `json:"source"` // "git", "beads", "townlog", "events" Type string `json:"type"` // "commit", "bead_created", "bead_closed", "spawn", etc. Actor string `json:"actor"` Summary string `json:"summary"` Details string `json:"details,omitempty"` ID string `json:"id,omitempty"` // commit hash, bead ID, etc. } func runAudit(cmd *cobra.Command, args []string) error { townRoot, err := workspace.FindFromCwdOrError() if err != nil { return fmt.Errorf("not in a Gas Town workspace: %w", err) } // Parse since duration if provided var sinceTime time.Time if auditSince != "" { duration, err := parseDuration(auditSince) if err != nil { return fmt.Errorf("invalid --since duration: %w", err) } sinceTime = time.Now().Add(-duration) } // Collect entries from all sources var allEntries []AuditEntry // 1. Git commits gitEntries, err := collectGitCommits(townRoot, auditActor, sinceTime) if err != nil { // Non-fatal: log and continue fmt.Fprintf(os.Stderr, "Warning: could not query git commits: %v\n", err) } allEntries = append(allEntries, gitEntries...) // 2. Beads (created_by, assignee) beadsEntries, err := collectBeadsActivity(townRoot, auditActor, sinceTime) if err != nil { fmt.Fprintf(os.Stderr, "Warning: could not query beads: %v\n", err) } allEntries = append(allEntries, beadsEntries...) // 3. Town log events townlogEntries, err := collectTownlogEvents(townRoot, auditActor, sinceTime) if err != nil { fmt.Fprintf(os.Stderr, "Warning: could not query town log: %v\n", err) } allEntries = append(allEntries, townlogEntries...) // 4. Activity feed events feedEntries, err := collectFeedEvents(townRoot, auditActor, sinceTime) if err != nil { fmt.Fprintf(os.Stderr, "Warning: could not query events feed: %v\n", err) } allEntries = append(allEntries, feedEntries...) // Sort by timestamp (newest first) sort.Slice(allEntries, func(i, j int) bool { return allEntries[i].Timestamp.After(allEntries[j].Timestamp) }) // Apply limit if auditLimit > 0 && len(allEntries) > auditLimit { allEntries = allEntries[:auditLimit] } if len(allEntries) == 0 { if auditActor != "" { fmt.Printf("%s No activity found for actor %q\n", style.Dim.Render("○"), auditActor) } else { fmt.Printf("%s No activity found\n", style.Dim.Render("○")) } return nil } // Output if auditJSON { return outputAuditJSON(allEntries) } return outputAuditText(allEntries) } // parseDuration parses a duration string with support for days (d). func parseDuration(s string) (time.Duration, error) { // Check for days suffix if strings.HasSuffix(s, "d") { days := strings.TrimSuffix(s, "d") var d int if _, err := fmt.Sscanf(days, "%d", &d); err != nil { return 0, fmt.Errorf("invalid days format: %s", s) } return time.Duration(d) * 24 * time.Hour, nil } return time.ParseDuration(s) } // collectGitCommits queries git log for commits by the actor. func collectGitCommits(townRoot, actor string, since time.Time) ([]AuditEntry, error) { //nolint:unparam // error return kept for future use var entries []AuditEntry // Build git log command args := []string{"log", "--format=%H|%aI|%an|%s", "--all"} if actor != "" { // Try to match actor in author name // Actor format might be "greenplace/crew/joe" - extract "joe" as the author name authorName := extractAuthorName(actor) args = append(args, "--author="+authorName) } if !since.IsZero() { args = append(args, "--since="+since.Format(time.RFC3339)) } // Limit to reasonable number args = append(args, "-n", "100") cmd := exec.Command("git", args...) cmd.Dir = townRoot output, err := cmd.Output() if err != nil { // Git might fail if not a repo - not fatal return nil, nil } scanner := bufio.NewScanner(strings.NewReader(string(output))) for scanner.Scan() { line := scanner.Text() parts := strings.SplitN(line, "|", 4) if len(parts) < 4 { continue } hash := parts[0] timestamp, _ := time.Parse(time.RFC3339, parts[1]) author := parts[2] subject := parts[3] // If actor filter is set, also match on the full actor string in commit message if actor != "" && !matchesActor(author, actor) && !strings.Contains(subject, actor) { continue } entries = append(entries, AuditEntry{ Timestamp: timestamp, Source: "git", Type: "commit", Actor: author, Summary: subject, ID: hash[:8], }) } return entries, nil } // extractAuthorName extracts the likely git author name from an actor address. func extractAuthorName(actor string) string { // Actor format: "greenplace/crew/joe" -> "joe" // Or: "mayor" -> "mayor" parts := strings.Split(actor, "/") if len(parts) > 0 { return parts[len(parts)-1] } return actor } // matchesActor checks if a name matches the actor filter (partial match). func matchesActor(name, actor string) bool { name = strings.ToLower(name) actor = strings.ToLower(actor) // Exact match if name == actor { return true } // Extract last component of actor for matching actorName := extractAuthorName(actor) if strings.Contains(name, actorName) { return true } // Check if actor appears in name if strings.Contains(name, actor) { return true } return false } // collectBeadsActivity queries beads for issues created or closed by the actor. func collectBeadsActivity(townRoot, actor string, since time.Time) ([]AuditEntry, error) { var entries []AuditEntry // Find the gastown beads path (where gt- prefix issues live) gastownBeadsPath := filepath.Join(townRoot, "gastown", "mayor", "rig") b := beads.New(gastownBeadsPath) // List all issues to filter by created_by and assignee issues, err := b.List(beads.ListOptions{ Status: "all", Priority: -1, }) if err != nil { return nil, err } for _, issue := range issues { // Check created_by if issue.CreatedBy != "" { if actor == "" || matchesActor(issue.CreatedBy, actor) { ts := parseBeadsTimestamp(issue.CreatedAt) if !since.IsZero() && ts.Before(since) { continue } entries = append(entries, AuditEntry{ Timestamp: ts, Source: "beads", Type: "bead_created", Actor: issue.CreatedBy, Summary: fmt.Sprintf("Created: %s", issue.Title), ID: issue.ID, Details: fmt.Sprintf("type=%s priority=%d", issue.Type, issue.Priority), }) } } // Check if issue was closed and has an assignee if issue.Status == "closed" && issue.Assignee != "" { if actor == "" || matchesActor(issue.Assignee, actor) { ts := parseBeadsTimestamp(issue.ClosedAt) if ts.IsZero() { ts = parseBeadsTimestamp(issue.UpdatedAt) } if !since.IsZero() && ts.Before(since) { continue } entries = append(entries, AuditEntry{ Timestamp: ts, Source: "beads", Type: "bead_closed", Actor: issue.Assignee, Summary: fmt.Sprintf("Closed: %s", issue.Title), ID: issue.ID, }) } } } return entries, nil } // parseBeadsTimestamp parses a beads timestamp string. func parseBeadsTimestamp(s string) time.Time { // Try various formats formats := []string{ time.RFC3339, "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02", } for _, format := range formats { if t, err := time.Parse(format, s); err == nil { return t } } return time.Time{} } // collectTownlogEvents queries the town log for agent lifecycle events. func collectTownlogEvents(townRoot, actor string, since time.Time) ([]AuditEntry, error) { var entries []AuditEntry allEvents, err := townlog.ReadEvents(townRoot) if err != nil { return nil, err } for _, e := range allEvents { // Apply actor filter if actor != "" && !matchesActor(e.Agent, actor) { continue } // Apply since filter if !since.IsZero() && e.Timestamp.Before(since) { continue } entries = append(entries, AuditEntry{ Timestamp: e.Timestamp, Source: "townlog", Type: string(e.Type), Actor: e.Agent, Summary: formatTownlogSummary(e), }) } return entries, nil } // formatTownlogSummary creates a readable summary from a town log event. func formatTownlogSummary(e townlog.Event) string { switch e.Type { case townlog.EventSpawn: if e.Context != "" { return fmt.Sprintf("Spawned for %s", e.Context) } return "Spawned" case townlog.EventDone: if e.Context != "" { return fmt.Sprintf("Completed %s", e.Context) } return "Completed work" case townlog.EventHandoff: return "Handed off session" case townlog.EventCrash: if e.Context != "" { return fmt.Sprintf("Crashed: %s", e.Context) } return "Crashed" case townlog.EventKill: return "Session killed" case townlog.EventNudge: return "Nudged" case townlog.EventWake: return "Resumed" default: if e.Context != "" { return fmt.Sprintf("%s: %s", e.Type, e.Context) } return string(e.Type) } } // collectFeedEvents queries the activity feed for events. func collectFeedEvents(townRoot, actor string, since time.Time) ([]AuditEntry, error) { var entries []AuditEntry eventsPath := filepath.Join(townRoot, events.EventsFile) file, err := os.Open(eventsPath) if err != nil { if os.IsNotExist(err) { return nil, nil // No events file yet } return nil, err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { var e events.Event if err := json.Unmarshal(scanner.Bytes(), &e); err != nil { continue // Skip malformed lines } // Apply actor filter if actor != "" && !matchesActor(e.Actor, actor) { continue } // Parse timestamp ts, _ := time.Parse(time.RFC3339, e.Timestamp) // Apply since filter if !since.IsZero() && ts.Before(since) { continue } entries = append(entries, AuditEntry{ Timestamp: ts, Source: "events", Type: e.Type, Actor: e.Actor, Summary: formatFeedSummary(e), }) } return entries, nil } // formatFeedSummary creates a readable summary from a feed event. func formatFeedSummary(e events.Event) string { switch e.Type { case events.TypeSling: if bead, ok := e.Payload["bead"].(string); ok { return fmt.Sprintf("Slung %s", bead) } return "Slung work" case events.TypeMerged: if branch, ok := e.Payload["branch"].(string); ok { return fmt.Sprintf("Merged %s", branch) } return "Merged work" case events.TypeMergeFailed: if reason, ok := e.Payload["reason"].(string); ok { return fmt.Sprintf("Merge failed: %s", reason) } return "Merge failed" case events.TypeHandoff: return "Handed off" case events.TypeDone: if bead, ok := e.Payload["bead"].(string); ok { return fmt.Sprintf("Done %s", bead) } return "Done" case events.TypeMail: if to, ok := e.Payload["to"].(string); ok { return fmt.Sprintf("Sent mail to %s", to) } return "Sent mail" default: return e.Type } } func outputAuditJSON(entries []AuditEntry) error { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(entries) } func outputAuditText(entries []AuditEntry) error { // Group by date for readability var currentDate string for _, e := range entries { date := e.Timestamp.Format("2006-01-02") if date != currentDate { if currentDate != "" { fmt.Println() } fmt.Printf("%s\n", style.Bold.Render("─── "+date+" ───────────────────────────────────────────")) currentDate = date } timeStr := e.Timestamp.Format("15:04:05") sourceStr := formatSource(e.Source) typeStr := formatType(e.Type) // Build the line var idPart string if e.ID != "" { idPart = style.Dim.Render(fmt.Sprintf(" [%s]", e.ID)) } fmt.Printf("%s %s %s %s%s\n", style.Dim.Render(timeStr), sourceStr, typeStr, e.Summary, idPart, ) if e.Actor != "" { fmt.Printf(" %s\n", style.Dim.Render("by "+e.Actor)) } } return nil } func formatSource(source string) string { switch source { case "git": return style.Bold.Render("[git]") case "beads": return style.Success.Render("[beads]") case "townlog": return style.Dim.Render("[log]") case "events": return style.Warning.Render("[events]") default: return fmt.Sprintf("[%s]", source) } } func formatType(t string) string { switch t { case "commit": return style.Success.Render("commit") case "bead_created": return style.Success.Render("created") case "bead_closed": return style.Bold.Render("closed") case "spawn": return style.Success.Render("spawn") case "done": return style.Success.Render("done") case "handoff": return style.Bold.Render("handoff") case "crash": return style.Error.Render("crash") case "kill": return style.Warning.Render("kill") case "merged": return style.Success.Render("merged") case "merge_failed": return style.Error.Render("merge_failed") default: return t } }