Files
gastown/internal/cmd/polecat_identity.go
rictus 40cc4c9335 fix(identity): copy all agent fields during polecat identity rename
The rename operation was only copying AgentState and CleanupStatus,
missing HookBead (the primary fix), ActiveMR, and NotificationLevel.
This ensures all agent state is preserved when renaming an identity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:04:55 -08:00

1079 lines
29 KiB
Go

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 <rig> [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 <rig>",
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 <rig> <name>",
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 <rig> <old-name> <new-name>",
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 <rig> <name>",
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,
HookBead: oldFields.HookBead,
CleanupStatus: oldFields.CleanupStatus,
ActiveMR: oldFields.ActiveMR,
NotificationLevel: oldFields.NotificationLevel,
}
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, ", ")
}