From 2d8949a3d3555fae170884072353496975be42e1 Mon Sep 17 00:00:00 2001 From: nux Date: Sun, 11 Jan 2026 21:07:53 -0800 Subject: [PATCH] feat(polecat): add identity show command with CV summary Add `gt polecat identity show ` command that displays: - Identity bead ID and creation date - Session count - Completion statistics (completed, failed, abandoned) - Language breakdown from file extensions in git history - Work type breakdown (feat, fix, refactor, etc.) - Recent work list with relative timestamps - First-pass success rate Supports --json flag for programmatic output. Closes: hq-d17es.4 Co-Authored-By: Claude Opus 4.5 --- internal/cmd/polecat_identity.go | 584 +++++++++++++++++++++++++++---- 1 file changed, 513 insertions(+), 71 deletions(-) diff --git a/internal/cmd/polecat_identity.go b/internal/cmd/polecat_identity.go index ced52e21..5e98a798 100644 --- a/internal/cmd/polecat_identity.go +++ b/internal/cmd/polecat_identity.go @@ -4,7 +4,14 @@ 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" @@ -15,8 +22,8 @@ import ( // Polecat identity command flags var ( - polecatIdentityListJSON bool - polecatIdentityShowJSON bool + polecatIdentityListJSON bool + polecatIdentityShowJSON bool polecatIdentityRemoveForce bool ) @@ -72,15 +79,18 @@ Example: var polecatIdentityShowCmd = &cobra.Command{ Use: "show ", - Short: "Show identity bead details and CV summary", - Long: `Show detailed identity bead information for a polecat. + Short: "Show polecat identity with CV summary", + Long: `Show detailed identity information for a polecat including work history. Displays: - - Identity bead fields - - CV history (past work) - - Current hook bead details + - 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 -Example: +Examples: gt polecat identity show gastown Toast gt polecat identity show gastown Toast --json`, Args: cobra.ExactArgs(2), @@ -160,6 +170,40 @@ type IdentityInfo struct { 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 @@ -328,16 +372,6 @@ func runPolecatIdentityList(cmd *cobra.Command, args []string) error { return nil } -// 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"` -} - func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { rigName := args[0] polecatName := args[1] @@ -365,69 +399,72 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { mgr := polecat.NewManager(r, nil) 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 details - details := IdentityDetails{ - 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, - } - if details.HookBead == "" { - details.HookBead = fields.HookBead + // Build CV summary with enhanced analytics + cv, err := buildCVSummary(r.Path, rigName, polecatName, beadID, clonePath) + if err != nil { + // Continue without CV if there's an error + cv = &CVSummary{Identity: beadID} } - // Get CV beads (work history) - beads that were assigned to this polecat - // Assignee format is "rig/name" (e.g., "gastown/Toast") - assignee := fmt.Sprintf("%s/%s", rigName, polecatName) - cvBeads, _ := bd.ListByAssignee(assignee) - for _, cv := range cvBeads { - if cv.ID != beadID && cv.Status == "closed" { - details.CVBeads = append(details.CVBeads, cv.ID) - } - } - - // JSON output + // 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(details) + return enc.Encode(output) } // Human-readable output - fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Identity: %s/%s", rigName, polecatName))) - - fmt.Printf(" Bead ID: %s\n", details.BeadID) - fmt.Printf(" Title: %s\n", details.Title) + 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 details.SessionRunning { + if sessionRunning { sessionStr = style.Success.Render("running") } fmt.Printf(" Session: %s\n", sessionStr) worktreeStr := style.Dim.Render("no") - if details.WorktreeExists { + if worktreeExists { worktreeStr = style.Success.Render("yes") } fmt.Printf(" Worktree: %s\n", worktreeStr) // Agent state - stateStr := details.AgentState + stateStr := fields.AgentState if stateStr == "" { stateStr = "unknown" } @@ -444,36 +481,71 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error { fmt.Printf(" Agent State: %s\n", stateStr) // Hook - if details.HookBead != "" { - fmt.Printf(" Hook: %s\n", details.HookBead) + 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 details.CleanupStatus != "" { - fmt.Printf(" Cleanup: %s\n", details.CleanupStatus) + if fields.CleanupStatus != "" { + fmt.Printf(" Cleanup: %s\n", fields.CleanupStatus) } // Timestamps - if details.CreatedAt != "" { - fmt.Printf(" Created: %s\n", style.Dim.Render(details.CreatedAt)) + if issue.CreatedAt != "" { + fmt.Printf(" Created: %s\n", style.Dim.Render(issue.CreatedAt)) } - if details.UpdatedAt != "" { - fmt.Printf(" Updated: %s\n", style.Dim.Render(details.UpdatedAt)) + if issue.UpdatedAt != "" { + fmt.Printf(" Updated: %s\n", style.Dim.Render(issue.UpdatedAt)) } - // CV summary - fmt.Println() - fmt.Printf("%s\n", style.Bold.Render("CV (Work History)")) - if len(details.CVBeads) == 0 { - fmt.Printf(" %s\n", style.Dim.Render("(no completed work)")) - } else { - for _, cv := range details.CVBeads { - fmt.Printf(" - %s\n", cv) + // 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 } @@ -633,3 +705,373 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error { fmt.Printf("%s Removed identity bead: %s\n", style.SuccessPrefix, beadID) return nil } + +// buildCVSummary constructs the CV summary for a polecat. +func buildCVSummary(rigPath, rigName, polecatName, identityBeadID, clonePath string) (*CVSummary, error) { + 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, nil +} + +// 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, ", ") +}