package cmd import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/polecat" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" ) // Polecat identity command flags var ( polecatIdentityListJSON bool polecatIdentityShowJSON bool polecatIdentityRemoveForce bool ) var polecatIdentityCmd = &cobra.Command{ Use: "identity", Aliases: []string{"id"}, Short: "Manage polecat identities", Long: `Manage polecat identity beads in rigs. Identity beads track polecat metadata, CV history, and lifecycle state. Use subcommands to create, list, show, rename, or remove identities.`, RunE: requireSubcommand, } var polecatIdentityAddCmd = &cobra.Command{ Use: "add [name]", Short: "Create an identity bead for a polecat", Long: `Create an identity bead for a polecat in a rig. If name is not provided, a name will be generated from the rig's name pool. The identity bead tracks: - Role type (polecat) - Rig assignment - Agent state - Hook bead (current work) - Cleanup status Example: gt polecat identity add gastown Toast gt polecat identity add gastown # auto-generate name`, Args: cobra.RangeArgs(1, 2), RunE: runPolecatIdentityAdd, } var polecatIdentityListCmd = &cobra.Command{ Use: "list ", Short: "List polecat identity beads in a rig", Long: `List all polecat identity beads in a rig. Shows: - Polecat name - Agent state - Current hook (if any) - Whether worktree exists Example: gt polecat identity list gastown gt polecat identity list gastown --json`, Args: cobra.ExactArgs(1), RunE: runPolecatIdentityList, } var polecatIdentityShowCmd = &cobra.Command{ Use: "show ", Short: "Show polecat identity with CV summary", Long: `Show detailed identity information for a polecat including work history. Displays: - Identity bead ID and creation date - Session count - Completion statistics (issues completed, failed, abandoned) - Language breakdown from file extensions - Work type breakdown (feat, fix, refactor, etc.) - Recent work list with relative timestamps Examples: gt polecat identity show gastown Toast gt polecat identity show gastown Toast --json`, Args: cobra.ExactArgs(2), RunE: runPolecatIdentityShow, } var polecatIdentityRenameCmd = &cobra.Command{ Use: "rename ", Short: "Rename a polecat identity (preserves CV)", Long: `Rename a polecat identity bead, preserving CV history. The rename: 1. Creates a new identity bead with the new name 2. Copies CV history links to the new bead 3. Closes the old bead with a reference to the new one Safety checks: - Old identity must exist - New name must not already exist - Polecat session must not be running Example: gt polecat identity rename gastown Toast Imperator`, Args: cobra.ExactArgs(3), RunE: runPolecatIdentityRename, } var polecatIdentityRemoveCmd = &cobra.Command{ Use: "remove ", Short: "Remove a polecat identity", Long: `Remove a polecat identity bead. Safety checks: - No active tmux session - No work on hook (unless using --force) - Warns if CV exists Use --force to bypass safety checks. Example: gt polecat identity remove gastown Toast gt polecat identity remove gastown Toast --force`, Args: cobra.ExactArgs(2), RunE: runPolecatIdentityRemove, } func init() { // List flags polecatIdentityListCmd.Flags().BoolVar(&polecatIdentityListJSON, "json", false, "Output as JSON") // Show flags polecatIdentityShowCmd.Flags().BoolVar(&polecatIdentityShowJSON, "json", false, "Output as JSON") // Remove flags polecatIdentityRemoveCmd.Flags().BoolVarP(&polecatIdentityRemoveForce, "force", "f", false, "Force removal, bypassing safety checks") // Add subcommands to identity polecatIdentityCmd.AddCommand(polecatIdentityAddCmd) polecatIdentityCmd.AddCommand(polecatIdentityListCmd) polecatIdentityCmd.AddCommand(polecatIdentityShowCmd) polecatIdentityCmd.AddCommand(polecatIdentityRenameCmd) polecatIdentityCmd.AddCommand(polecatIdentityRemoveCmd) // Add identity to polecat command polecatCmd.AddCommand(polecatIdentityCmd) } // IdentityInfo holds identity bead information for display. type IdentityInfo struct { Rig string `json:"rig"` Name string `json:"name"` BeadID string `json:"bead_id"` AgentState string `json:"agent_state,omitempty"` HookBead string `json:"hook_bead,omitempty"` CleanupStatus string `json:"cleanup_status,omitempty"` WorktreeExists bool `json:"worktree_exists"` SessionRunning bool `json:"session_running"` } // IdentityDetails holds detailed identity information for show command. type IdentityDetails struct { IdentityInfo Title string `json:"title"` Description string `json:"description,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` CVBeads []string `json:"cv_beads,omitempty"` } // CVSummary represents the CV/work history summary for a polecat. type CVSummary struct { Identity string `json:"identity"` Created string `json:"created,omitempty"` Sessions int `json:"sessions"` IssuesCompleted int `json:"issues_completed"` IssuesFailed int `json:"issues_failed"` IssuesAbandoned int `json:"issues_abandoned"` Languages map[string]int `json:"languages,omitempty"` WorkTypes map[string]int `json:"work_types,omitempty"` AvgCompletionMin int `json:"avg_completion_minutes,omitempty"` FirstPassRate float64 `json:"first_pass_rate,omitempty"` RecentWork []RecentWorkItem `json:"recent_work,omitempty"` } // RecentWorkItem represents a recent work item in the CV. type RecentWorkItem struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type,omitempty"` Completed string `json:"completed"` Ago string `json:"ago"` } func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error { rigName := args[0] var polecatName string if len(args) > 1 { polecatName = args[1] } // Get rig _, r, err := getRig(rigName) if err != nil { return err } // Generate name if not provided if polecatName == "" { polecatGit := git.NewGit(r.Path) t := tmux.NewTmux() mgr := polecat.NewManager(r, polecatGit, t) polecatName, err = mgr.AllocateName() if err != nil { return fmt.Errorf("generating polecat name: %w", err) } fmt.Printf("Generated name: %s\n", polecatName) } // Check if identity already exists bd := beads.New(r.Path) beadID := polecatBeadIDForRig(r, rigName, polecatName) existingIssue, _, _ := bd.GetAgentBead(beadID) if existingIssue != nil && existingIssue.Status != "closed" { return fmt.Errorf("identity bead %s already exists", beadID) } // Create identity bead fields := &beads.AgentFields{ RoleType: "polecat", Rig: rigName, AgentState: "idle", } title := fmt.Sprintf("Polecat %s in %s", polecatName, rigName) issue, err := bd.CreateOrReopenAgentBead(beadID, title, fields) if err != nil { return fmt.Errorf("creating identity bead: %w", err) } fmt.Printf("%s Created identity bead: %s\n", style.SuccessPrefix, issue.ID) fmt.Printf(" Polecat: %s\n", polecatName) fmt.Printf(" Rig: %s\n", rigName) return nil } func runPolecatIdentityList(cmd *cobra.Command, args []string) error { rigName := args[0] // Get rig _, r, err := getRig(rigName) if err != nil { return err } // Get all agent beads bd := beads.New(r.Path) agentBeads, err := bd.ListAgentBeads() if err != nil { return fmt.Errorf("listing agent beads: %w", err) } // Filter for polecat beads in this rig identities := []IdentityInfo{} // Initialize to empty slice (not nil) for JSON t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, r) for id, issue := range agentBeads { // Parse the bead ID to check if it's a polecat for this rig beadRig, role, name, ok := beads.ParseAgentBeadID(id) if !ok || role != "polecat" || beadRig != rigName { continue } // Skip closed beads if issue.Status == "closed" { continue } fields := beads.ParseAgentFields(issue.Description) // Check if worktree exists worktreeExists := false mgr := polecat.NewManager(r, nil, t) if p, err := mgr.Get(name); err == nil && p != nil { worktreeExists = true } // Check if session is running sessionRunning, _ := polecatMgr.IsRunning(name) info := IdentityInfo{ Rig: rigName, Name: name, BeadID: id, AgentState: fields.AgentState, HookBead: issue.HookBead, CleanupStatus: fields.CleanupStatus, WorktreeExists: worktreeExists, SessionRunning: sessionRunning, } if info.HookBead == "" { info.HookBead = fields.HookBead } identities = append(identities, info) } // JSON output if polecatIdentityListJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(identities) } // Human-readable output if len(identities) == 0 { fmt.Printf("No polecat identities found in %s.\n", rigName) return nil } fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Polecat Identities in %s", rigName))) for _, info := range identities { // Status indicators sessionIcon := style.Dim.Render("○") if info.SessionRunning { sessionIcon = style.Success.Render("●") } worktreeIcon := "" if info.WorktreeExists { worktreeIcon = " " + style.Dim.Render("[worktree]") } // Agent state with color stateStr := info.AgentState if stateStr == "" { stateStr = "unknown" } switch stateStr { case "working": stateStr = style.Info.Render(stateStr) case "done": stateStr = style.Success.Render(stateStr) case "stuck": stateStr = style.Warning.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" %s %s %s%s\n", sessionIcon, style.Bold.Render(info.Name), stateStr, worktreeIcon) if info.HookBead != "" { fmt.Printf(" Hook: %s\n", style.Dim.Render(info.HookBead)) } } fmt.Printf("\n%d identity bead(s)\n", len(identities)) return nil } func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { rigName := args[0] polecatName := args[1] // Get rig _, r, err := getRig(rigName) if err != nil { return err } // Get identity bead bd := beads.New(r.Path) beadID := polecatBeadIDForRig(r, rigName, polecatName) issue, fields, err := bd.GetAgentBead(beadID) if err != nil { return fmt.Errorf("getting identity bead: %w", err) } if issue == nil { return fmt.Errorf("identity bead %s not found", beadID) } // Check worktree and session t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, r) mgr := polecat.NewManager(r, nil, t) worktreeExists := false var clonePath string if p, err := mgr.Get(polecatName); err == nil && p != nil { worktreeExists = true clonePath = p.ClonePath } sessionRunning, _ := polecatMgr.IsRunning(polecatName) // Build CV summary with enhanced analytics cv := buildCVSummary(r.Path, rigName, polecatName, beadID, clonePath) // JSON output - include both identity details and CV if polecatIdentityShowJSON { output := struct { IdentityInfo Title string `json:"title"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` CV *CVSummary `json:"cv,omitempty"` }{ IdentityInfo: IdentityInfo{ Rig: rigName, Name: polecatName, BeadID: beadID, AgentState: fields.AgentState, HookBead: issue.HookBead, CleanupStatus: fields.CleanupStatus, WorktreeExists: worktreeExists, SessionRunning: sessionRunning, }, Title: issue.Title, CreatedAt: issue.CreatedAt, UpdatedAt: issue.UpdatedAt, CV: cv, } if output.HookBead == "" { output.HookBead = fields.HookBead } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(output) } // Human-readable output fmt.Printf("\n%s %s/%s\n", style.Bold.Render("Identity:"), rigName, polecatName) fmt.Printf(" Bead ID: %s\n", beadID) fmt.Printf(" Title: %s\n", issue.Title) // Status sessionStr := style.Dim.Render("stopped") if sessionRunning { sessionStr = style.Success.Render("running") } fmt.Printf(" Session: %s\n", sessionStr) worktreeStr := style.Dim.Render("no") if worktreeExists { worktreeStr = style.Success.Render("yes") } fmt.Printf(" Worktree: %s\n", worktreeStr) // Agent state stateStr := fields.AgentState if stateStr == "" { stateStr = "unknown" } switch stateStr { case "working": stateStr = style.Info.Render(stateStr) case "done": stateStr = style.Success.Render(stateStr) case "stuck": stateStr = style.Warning.Render(stateStr) default: stateStr = style.Dim.Render(stateStr) } fmt.Printf(" Agent State: %s\n", stateStr) // Hook hookBead := issue.HookBead if hookBead == "" { hookBead = fields.HookBead } if hookBead != "" { fmt.Printf(" Hook: %s\n", hookBead) } else { fmt.Printf(" Hook: %s\n", style.Dim.Render("(empty)")) } // Cleanup status if fields.CleanupStatus != "" { fmt.Printf(" Cleanup: %s\n", fields.CleanupStatus) } // Timestamps if issue.CreatedAt != "" { fmt.Printf(" Created: %s\n", style.Dim.Render(issue.CreatedAt)) } if issue.UpdatedAt != "" { fmt.Printf(" Updated: %s\n", style.Dim.Render(issue.UpdatedAt)) } // CV Summary section with enhanced analytics fmt.Printf("\n%s\n", style.Bold.Render("CV Summary:")) fmt.Printf(" Sessions: %d\n", cv.Sessions) fmt.Printf(" Issues completed: %s\n", style.Success.Render(fmt.Sprintf("%d", cv.IssuesCompleted))) fmt.Printf(" Issues failed: %s\n", formatCountStyled(cv.IssuesFailed, style.Error)) fmt.Printf(" Issues abandoned: %s\n", formatCountStyled(cv.IssuesAbandoned, style.Warning)) // Language stats if len(cv.Languages) > 0 { fmt.Printf("\n %s %s\n", style.Bold.Render("Languages:"), formatLanguageStats(cv.Languages)) } // Work type stats if len(cv.WorkTypes) > 0 { fmt.Printf(" %s %s\n", style.Bold.Render("Types:"), formatWorkTypeStats(cv.WorkTypes)) } // Performance metrics if cv.AvgCompletionMin > 0 { fmt.Printf("\n Avg completion time: %d minutes\n", cv.AvgCompletionMin) } if cv.FirstPassRate > 0 { fmt.Printf(" First-pass success: %.0f%%\n", cv.FirstPassRate*100) } // Recent work if len(cv.RecentWork) > 0 { fmt.Printf("\n%s\n", style.Bold.Render("Recent work:")) for _, work := range cv.RecentWork { typeStr := "" if work.Type != "" { typeStr = work.Type + ": " } title := work.Title if len(title) > 40 { title = title[:37] + "..." } fmt.Printf(" %-10s %s%s %s\n", work.ID, typeStr, title, style.Dim.Render(work.Ago)) } } fmt.Println() return nil } func runPolecatIdentityRename(cmd *cobra.Command, args []string) error { rigName := args[0] oldName := args[1] newName := args[2] // Validate names if oldName == newName { return fmt.Errorf("old and new names are the same") } // Get rig _, r, err := getRig(rigName) if err != nil { return err } bd := beads.New(r.Path) oldBeadID := polecatBeadIDForRig(r, rigName, oldName) newBeadID := polecatBeadIDForRig(r, rigName, newName) // Check old identity exists oldIssue, oldFields, err := bd.GetAgentBead(oldBeadID) if err != nil { return fmt.Errorf("getting old identity bead: %w", err) } if oldIssue == nil || oldIssue.Status == "closed" { return fmt.Errorf("identity bead %s not found or already closed", oldBeadID) } // Check new identity doesn't exist newIssue, _, _ := bd.GetAgentBead(newBeadID) if newIssue != nil && newIssue.Status != "closed" { return fmt.Errorf("identity bead %s already exists", newBeadID) } // Safety check: no active session t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, r) running, _ := polecatMgr.IsRunning(oldName) if running { return fmt.Errorf("cannot rename: polecat session %s is running", oldName) } // Create new identity bead with inherited fields newFields := &beads.AgentFields{ RoleType: "polecat", Rig: rigName, AgentState: oldFields.AgentState, CleanupStatus: oldFields.CleanupStatus, } newTitle := fmt.Sprintf("Polecat %s in %s", newName, rigName) _, err = bd.CreateOrReopenAgentBead(newBeadID, newTitle, newFields) if err != nil { return fmt.Errorf("creating new identity bead: %w", err) } // Close old bead with reference to new one closeReason := fmt.Sprintf("renamed to %s", newBeadID) if err := bd.CloseWithReason(closeReason, oldBeadID); err != nil { // Try to clean up new bead _ = bd.CloseWithReason("rename failed", newBeadID) return fmt.Errorf("closing old identity bead: %w", err) } fmt.Printf("%s Renamed identity:\n", style.SuccessPrefix) fmt.Printf(" Old: %s\n", oldBeadID) fmt.Printf(" New: %s\n", newBeadID) fmt.Printf("\n%s Note: If a worktree exists for %s, you'll need to recreate it with the new name.\n", style.Warning.Render("⚠"), oldName) return nil } func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error { rigName := args[0] polecatName := args[1] // Get rig _, r, err := getRig(rigName) if err != nil { return err } bd := beads.New(r.Path) beadID := polecatBeadIDForRig(r, rigName, polecatName) // Check identity exists issue, fields, err := bd.GetAgentBead(beadID) if err != nil { return fmt.Errorf("getting identity bead: %w", err) } if issue == nil { return fmt.Errorf("identity bead %s not found", beadID) } if issue.Status == "closed" { return fmt.Errorf("identity bead %s is already closed", beadID) } // Safety checks (unless --force) if !polecatIdentityRemoveForce { var reasons []string // Check for active session t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, r) running, _ := polecatMgr.IsRunning(polecatName) if running { reasons = append(reasons, "session is running") } // Check for work on hook hookBead := issue.HookBead if hookBead == "" && fields != nil { hookBead = fields.HookBead } if hookBead != "" { // Check if hooked bead is still open hookedIssue, _ := bd.Show(hookBead) if hookedIssue != nil && hookedIssue.Status != "closed" { reasons = append(reasons, fmt.Sprintf("has work on hook (%s)", hookBead)) } } if len(reasons) > 0 { fmt.Printf("%s Cannot remove identity %s:\n", style.Error.Render("Error:"), beadID) for _, r := range reasons { fmt.Printf(" - %s\n", r) } fmt.Println("\nUse --force to bypass safety checks.") return fmt.Errorf("safety checks failed") } // Warn if CV exists assignee := fmt.Sprintf("%s/%s", rigName, polecatName) cvBeads, _ := bd.ListByAssignee(assignee) cvCount := 0 for _, cv := range cvBeads { if cv.ID != beadID && cv.Status == "closed" { cvCount++ } } if cvCount > 0 { fmt.Printf("%s Warning: This polecat has %d completed work item(s) in CV.\n", style.Warning.Render("⚠"), cvCount) } } // Close the identity bead if err := bd.CloseWithReason("removed via gt polecat identity remove", beadID); err != nil { return fmt.Errorf("closing identity bead: %w", err) } fmt.Printf("%s Removed identity bead: %s\n", style.SuccessPrefix, beadID) return nil } // buildCVSummary constructs the CV summary for a polecat. // Returns a partial CV on errors rather than failing - CV data is best-effort. func buildCVSummary(rigPath, rigName, polecatName, identityBeadID, clonePath string) *CVSummary { cv := &CVSummary{ Identity: identityBeadID, Languages: make(map[string]int), WorkTypes: make(map[string]int), RecentWork: []RecentWorkItem{}, } // Use clonePath for beads queries (has proper redirect setup) // Fall back to rigPath if clonePath is empty beadsQueryPath := clonePath if beadsQueryPath == "" { beadsQueryPath = rigPath } // Get agent bead info for creation date bd := beads.New(beadsQueryPath) agentBead, _, err := bd.GetAgentBead(identityBeadID) if err == nil && agentBead != nil { if agentBead.CreatedAt != "" && len(agentBead.CreatedAt) >= 10 { cv.Created = agentBead.CreatedAt[:10] // Just the date part } } // Count sessions from checkpoint files (session history) cv.Sessions = countPolecatSessions(rigPath, polecatName) // Query completed issues assigned to this polecat assignee := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) completedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "closed") if err == nil { cv.IssuesCompleted = len(completedIssues) // Extract work types from issue titles/types for _, issue := range completedIssues { workType := extractWorkType(issue.Title, issue.Type) if workType != "" { cv.WorkTypes[workType]++ } // Add to recent work (limit to 5) if len(cv.RecentWork) < 5 { ago := formatRelativeTimeCV(issue.Updated) cv.RecentWork = append(cv.RecentWork, RecentWorkItem{ ID: issue.ID, Title: issue.Title, Type: workType, Completed: issue.Updated, Ago: ago, }) } } } // Query failed/escalated issues escalatedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "escalated") if err == nil { cv.IssuesFailed = len(escalatedIssues) } // Query abandoned issues (deferred) deferredIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "deferred") if err == nil { cv.IssuesAbandoned = len(deferredIssues) } // Get language stats from git commits if clonePath != "" { langStats := getLanguageStats(clonePath) if len(langStats) > 0 { cv.Languages = langStats } } // Calculate first-pass success rate total := cv.IssuesCompleted + cv.IssuesFailed + cv.IssuesAbandoned if total > 0 { cv.FirstPassRate = float64(cv.IssuesCompleted) / float64(total) } return cv } // IssueInfo holds basic issue information for CV queries. type IssueInfo struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"issue_type"` Status string `json:"status"` Updated string `json:"updated_at"` } // queryAssignedIssues queries beads for issues assigned to a specific agent. func queryAssignedIssues(rigPath, assignee, status string) ([]IssueInfo, error) { // Use bd list with filters args := []string{"list", "--assignee=" + assignee, "--json"} if status != "" { args = append(args, "--status="+status) } cmd := exec.Command("bd", args...) cmd.Dir = rigPath out, err := cmd.Output() if err != nil { return nil, err } if len(out) == 0 { return []IssueInfo{}, nil } var issues []IssueInfo if err := json.Unmarshal(out, &issues); err != nil { return nil, err } // Sort by updated date (most recent first) sort.Slice(issues, func(i, j int) bool { return issues[i].Updated > issues[j].Updated }) return issues, nil } // extractWorkType extracts the work type from issue title or type. func extractWorkType(title, issueType string) string { // Check explicit issue type first switch issueType { case "bug": return "fix" case "task", "feature": return "feat" case "epic": return "epic" } // Try to extract from conventional commit-style title title = strings.ToLower(title) prefixes := []string{"feat:", "fix:", "refactor:", "docs:", "test:", "chore:", "style:", "perf:"} for _, prefix := range prefixes { if strings.HasPrefix(title, prefix) { return strings.TrimSuffix(prefix, ":") } } // Try to infer from keywords if strings.Contains(title, "fix") || strings.Contains(title, "bug") { return "fix" } if strings.Contains(title, "add") || strings.Contains(title, "implement") || strings.Contains(title, "create") { return "feat" } if strings.Contains(title, "refactor") || strings.Contains(title, "cleanup") { return "refactor" } return "" } // getLanguageStats analyzes git history to determine language distribution. func getLanguageStats(clonePath string) map[string]int { stats := make(map[string]int) // Get list of files changed in commits by this author // We use git log with --name-only to get file names cmd := exec.Command("git", "log", "--name-only", "--pretty=format:", "--diff-filter=ACMR", "-100") cmd.Dir = clonePath out, err := cmd.Output() if err != nil { return stats } // Count file extensions extCount := make(map[string]int) for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { continue } ext := filepath.Ext(line) if ext != "" { extCount[ext]++ } } // Map extensions to languages extToLang := map[string]string{ ".go": "Go", ".ts": "TypeScript", ".tsx": "TypeScript", ".js": "JavaScript", ".jsx": "JavaScript", ".py": "Python", ".rs": "Rust", ".java": "Java", ".rb": "Ruby", ".c": "C", ".cpp": "C++", ".h": "C", ".hpp": "C++", ".cs": "C#", ".swift": "Swift", ".kt": "Kotlin", ".scala": "Scala", ".php": "PHP", ".sh": "Shell", ".bash": "Shell", ".zsh": "Shell", ".md": "Markdown", ".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".toml": "TOML", ".sql": "SQL", ".html": "HTML", ".css": "CSS", ".scss": "SCSS", } for ext, count := range extCount { if lang, ok := extToLang[ext]; ok { stats[lang] += count } } return stats } // formatRelativeTimeCV returns a human-readable relative time string for CV display. func formatRelativeTimeCV(timestamp string) string { // Try RFC3339 format with timezone (ISO 8601) t, err := time.Parse(time.RFC3339, timestamp) if err != nil { // Try RFC3339Nano t, err = time.Parse(time.RFC3339Nano, timestamp) if err != nil { // Try without timezone t, err = time.Parse("2006-01-02T15:04:05", timestamp) if err != nil { // Try alternative format t, err = time.Parse("2006-01-02 15:04:05", timestamp) if err != nil { // Try date only t, err = time.Parse("2006-01-02", timestamp) if err != nil { return "" } } } } } d := time.Since(t) switch { case d < time.Minute: return "just now" case d < time.Hour: mins := int(d.Minutes()) if mins == 1 { return "1m ago" } return fmt.Sprintf("%dm ago", mins) case d < 24*time.Hour: hours := int(d.Hours()) if hours == 1 { return "1h ago" } return fmt.Sprintf("%dh ago", hours) case d < 7*24*time.Hour: days := int(d.Hours() / 24) if days == 1 { return "1d ago" } return fmt.Sprintf("%dd ago", days) default: weeks := int(d.Hours() / 24 / 7) if weeks == 1 { return "1w ago" } return fmt.Sprintf("%dw ago", weeks) } } // formatCountStyled formats a count with appropriate styling using lipgloss.Style. func formatCountStyled(count int, s lipgloss.Style) string { if count == 0 { return style.Dim.Render("0") } return s.Render(strconv.Itoa(count)) } // countPolecatSessions counts the number of sessions from checkpoint files. func countPolecatSessions(rigPath, polecatName string) int { // Look for checkpoint files in the polecat's directory checkpointDir := filepath.Join(rigPath, "polecats", polecatName, ".checkpoints") entries, err := os.ReadDir(checkpointDir) if err != nil { // Also check at rig level checkpointDir = filepath.Join(rigPath, ".checkpoints") entries, err = os.ReadDir(checkpointDir) if err != nil { return 0 } } // Count checkpoint files that contain this polecat's name count := 0 for _, entry := range entries { if !entry.IsDir() && strings.Contains(entry.Name(), polecatName) { count++ } } // If no checkpoint files found, return at least 1 if polecat exists if count == 0 { return 1 } return count } // formatLanguageStats formats language statistics for display. func formatLanguageStats(langs map[string]int) string { // Sort by count descending type langCount struct { lang string count int } var sorted []langCount for lang, count := range langs { sorted = append(sorted, langCount{lang, count}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) // Format top languages var parts []string for i, lc := range sorted { if i >= 3 { // Show top 3 break } parts = append(parts, fmt.Sprintf("%s (%d)", lc.lang, lc.count)) } return strings.Join(parts, ", ") } // formatWorkTypeStats formats work type statistics for display. func formatWorkTypeStats(types map[string]int) string { // Sort by count descending type typeCount struct { typ string count int } var sorted []typeCount for typ, count := range types { sorted = append(sorted, typeCount{typ, count}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].count > sorted[j].count }) // Format all types var parts []string for _, tc := range sorted { parts = append(parts, fmt.Sprintf("%s (%d)", tc.typ, tc.count)) } return strings.Join(parts, ", ") }