Files
gastown/internal/cmd/costs.go
furiosa f19ddc5400 feat(costs): add verbose logging for silent failures
Add --verbose/-v flag to gt costs command that outputs debug information
when silent failures occur during cost tracking operations:

- wisp list failures in querySessionCostWisps and deleteSessionCostWisps
- bd show failures when querying wisp details
- JSON unmarshal failures when parsing wisp/event data
- payload unmarshal failures when parsing session payloads

This makes debugging cost tracking issues much easier as these error
paths previously continued silently without any indication of failure.

Closes: bd-qv8f9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 00:35:15 -08:00

1327 lines
37 KiB
Go

// Package cmd provides CLI commands for the gt tool.
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
)
var (
costsJSON bool
costsToday bool
costsWeek bool
costsByRole bool
costsByRig bool
costsVerbose bool
// Record subcommand flags
recordSession string
recordWorkItem string
// Digest subcommand flags
digestYesterday bool
digestDate string
digestDryRun bool
// Migrate subcommand flags
migrateDryRun bool
)
var costsCmd = &cobra.Command{
Use: "costs",
GroupID: GroupDiag,
Short: "Show costs for running Claude sessions",
Long: `Display costs for Claude Code sessions in Gas Town.
By default, shows live costs scraped from running tmux sessions.
Cost tracking uses ephemeral wisps for individual sessions that are
aggregated into daily "Cost Report" digest beads for audit purposes.
Examples:
gt costs # Live costs from running sessions
gt costs --today # Today's costs from wisps (not yet digested)
gt costs --week # This week's costs from digest beads + today's wisps
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
gt costs --by-rig # Breakdown by rig
gt costs --json # Output as JSON
Subcommands:
gt costs record # Record session cost as ephemeral wisp (Stop hook)
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`,
RunE: runCosts,
}
var costsRecordCmd = &cobra.Command{
Use: "record",
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
Long: `Record the final cost of a session as an ephemeral wisp.
This command is intended to be called from a Claude Code Stop hook.
It captures the final cost from the tmux session and creates an ephemeral
event that is NOT exported to JSONL (avoiding log-in-database pollution).
Session cost wisps are aggregated daily by 'gt costs digest' into a single
permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
Examples:
gt costs record --session gt-gastown-toast
gt costs record --session gt-gastown-toast --work-item gt-abc123`,
RunE: runCostsRecord,
}
var costsDigestCmd = &cobra.Command{
Use: "digest",
Short: "Aggregate session cost wisps into a daily digest bead",
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest.
This command is intended to be run by Deacon patrol (daily) or manually.
It queries session.ended wisps for a target date, creates a single aggregate
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps.
The resulting digest bead is permanent (exported to JSONL, synced via git)
and provides an audit trail without log-in-database pollution.
Examples:
gt costs digest --yesterday # Digest yesterday's costs (default for patrol)
gt costs digest --date 2026-01-07 # Digest a specific date
gt costs digest --yesterday --dry-run # Preview without changes`,
RunE: runCostsDigest,
}
var costsMigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Migrate legacy session.ended beads to the new wisp architecture",
Long: `Migrate legacy session.ended event beads to the new cost tracking system.
This command handles the transition from the old architecture (where each
session.ended event was a permanent bead) to the new wisp-based system.
The migration:
1. Finds all open session.ended event beads (should be none if auto-close worked)
2. Closes them with reason "migrated to wisp architecture"
Legacy beads remain in the database for historical queries but won't interfere
with the new wisp-based cost tracking.
Examples:
gt costs migrate # Migrate legacy beads
gt costs migrate --dry-run # Preview what would be migrated`,
RunE: runCostsMigrate,
}
func init() {
rootCmd.AddCommand(costsCmd)
costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON")
costsCmd.Flags().BoolVar(&costsToday, "today", false, "Show today's total from session events")
costsCmd.Flags().BoolVar(&costsWeek, "week", false, "Show this week's total from session events")
costsCmd.Flags().BoolVar(&costsByRole, "by-role", false, "Show breakdown by role")
costsCmd.Flags().BoolVar(&costsByRig, "by-rig", false, "Show breakdown by rig")
costsCmd.Flags().BoolVarP(&costsVerbose, "verbose", "v", false, "Show debug output for failures")
// Add record subcommand
costsCmd.AddCommand(costsRecordCmd)
costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record")
costsRecordCmd.Flags().StringVar(&recordWorkItem, "work-item", "", "Work item ID (bead) for attribution")
// Add digest subcommand
costsCmd.AddCommand(costsDigestCmd)
costsDigestCmd.Flags().BoolVar(&digestYesterday, "yesterday", false, "Digest yesterday's costs (default for patrol)")
costsDigestCmd.Flags().StringVar(&digestDate, "date", "", "Digest a specific date (YYYY-MM-DD)")
costsDigestCmd.Flags().BoolVar(&digestDryRun, "dry-run", false, "Preview what would be done without making changes")
// Add migrate subcommand
costsCmd.AddCommand(costsMigrateCmd)
costsMigrateCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Preview what would be migrated without making changes")
}
// SessionCost represents cost info for a single session.
type SessionCost struct {
Session string `json:"session"`
Role string `json:"role"`
Rig string `json:"rig,omitempty"`
Worker string `json:"worker,omitempty"`
Cost float64 `json:"cost_usd"`
Running bool `json:"running"`
}
// CostEntry is a ledger entry for historical cost tracking.
type CostEntry struct {
SessionID string `json:"session_id"`
Role string `json:"role"`
Rig string `json:"rig,omitempty"`
Worker string `json:"worker,omitempty"`
CostUSD float64 `json:"cost_usd"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
WorkItem string `json:"work_item,omitempty"`
}
// CostsOutput is the JSON output structure.
type CostsOutput struct {
Sessions []SessionCost `json:"sessions,omitempty"`
Total float64 `json:"total_usd"`
ByRole map[string]float64 `json:"by_role,omitempty"`
ByRig map[string]float64 `json:"by_rig,omitempty"`
Period string `json:"period,omitempty"`
}
// costRegex matches cost patterns like "$1.23" or "$12.34"
var costRegex = regexp.MustCompile(`\$(\d+\.\d{2})`)
func runCosts(cmd *cobra.Command, args []string) error {
// If querying ledger, use ledger functions
if costsToday || costsWeek || costsByRole || costsByRig {
return runCostsFromLedger()
}
// Default: show live costs from running sessions
return runLiveCosts()
}
func runLiveCosts() error {
t := tmux.NewTmux()
// Get all tmux sessions
sessions, err := t.ListSessions()
if err != nil {
return fmt.Errorf("listing sessions: %w", err)
}
var costs []SessionCost
var total float64
for _, session := range sessions {
// Only process Gas Town sessions (start with "gt-")
if !strings.HasPrefix(session, constants.SessionPrefix) {
continue
}
// Parse session name to get role/rig/worker
role, rig, worker := parseSessionName(session)
// Capture pane content
content, err := t.CapturePaneAll(session)
if err != nil {
continue // Skip sessions we can't capture
}
// Extract cost from content
cost := extractCost(content)
// Check if an agent appears to be running
running := t.IsAgentRunning(session)
costs = append(costs, SessionCost{
Session: session,
Role: role,
Rig: rig,
Worker: worker,
Cost: cost,
Running: running,
})
total += cost
}
// Sort by session name
sort.Slice(costs, func(i, j int) bool {
return costs[i].Session < costs[j].Session
})
if costsJSON {
return outputCostsJSON(CostsOutput{
Sessions: costs,
Total: total,
})
}
return outputCostsHuman(costs, total)
}
func runCostsFromLedger() error {
now := time.Now()
var entries []CostEntry
var err error
if costsToday {
// For today: query ephemeral wisps (not yet digested)
// This gives real-time view of today's costs
entries, err = querySessionCostWisps(now)
if err != nil {
return fmt.Errorf("querying session cost wisps: %w", err)
}
} else if costsWeek {
// For week: query digest beads (costs.digest events)
// These are the aggregated daily reports
entries, err = queryDigestBeads(7)
if err != nil {
return fmt.Errorf("querying digest beads: %w", err)
}
// Also include today's wisps (not yet digested)
todayWisps, _ := querySessionCostWisps(now)
entries = append(entries, todayWisps...)
} else {
// No time filter: query both digests and legacy session.ended events
// (for backwards compatibility during migration)
entries, err = querySessionEvents()
if err != nil {
return fmt.Errorf("querying session events: %w", err)
}
}
if len(entries) == 0 {
fmt.Println(style.Dim.Render("No cost data found. Costs are recorded when sessions end."))
return nil
}
// Calculate totals
var total float64
byRole := make(map[string]float64)
byRig := make(map[string]float64)
for _, entry := range entries {
total += entry.CostUSD
byRole[entry.Role] += entry.CostUSD
if entry.Rig != "" {
byRig[entry.Rig] += entry.CostUSD
}
}
// Build output
output := CostsOutput{
Total: total,
}
if costsByRole {
output.ByRole = byRole
}
if costsByRig {
output.ByRig = byRig
}
// Set period label
if costsToday {
output.Period = "today"
} else if costsWeek {
output.Period = "this week"
}
if costsJSON {
return outputCostsJSON(output)
}
return outputLedgerHuman(output, entries)
}
// SessionEvent represents a session.ended event from beads.
type SessionEvent struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EventKind string `json:"event_kind"`
Actor string `json:"actor"`
Target string `json:"target"`
Payload string `json:"payload"`
}
// SessionPayload represents the JSON payload of a session event.
type SessionPayload struct {
CostUSD float64 `json:"cost_usd"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Rig string `json:"rig"`
Worker string `json:"worker"`
EndedAt string `json:"ended_at"`
}
// EventListItem represents an event from bd list (minimal fields).
type EventListItem struct {
ID string `json:"id"`
}
// querySessionEvents queries beads for session.ended events and converts them to CostEntry.
func querySessionEvents() ([]CostEntry, error) {
// Step 1: Get list of event IDs
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listOutput, err := listCmd.Output()
if err != nil {
// If bd fails (e.g., no beads database), return empty list
return nil, nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return nil, fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
return nil, nil
}
// Step 2: Get full details for all events using bd show
// (bd list doesn't include event_kind, actor, payload)
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return nil, fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return nil, fmt.Errorf("parsing event details: %w", err)
}
var entries []CostEntry
for _, event := range events {
// Filter for session.ended events only
if event.EventKind != "session.ended" {
continue
}
// Parse payload
var payload SessionPayload
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
continue // Skip malformed payloads
}
}
// Parse ended_at from payload, fall back to created_at
endedAt := event.CreatedAt
if payload.EndedAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
endedAt = parsed
}
}
entries = append(entries, CostEntry{
SessionID: payload.SessionID,
Role: payload.Role,
Rig: payload.Rig,
Worker: payload.Worker,
CostUSD: payload.CostUSD,
EndedAt: endedAt,
WorkItem: event.Target,
})
}
return entries, nil
}
// queryDigestBeads queries costs.digest events from the past N days and extracts session entries.
func queryDigestBeads(days int) ([]CostEntry, error) {
// Get list of event IDs
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listOutput, err := listCmd.Output()
if err != nil {
return nil, nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return nil, fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
return nil, nil
}
// Get full details for all events
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return nil, fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return nil, fmt.Errorf("parsing event details: %w", err)
}
// Calculate date range
now := time.Now()
cutoff := now.AddDate(0, 0, -days)
var entries []CostEntry
for _, event := range events {
// Filter for costs.digest events only
if event.EventKind != "costs.digest" {
continue
}
// Parse the digest payload
var digest CostDigest
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &digest); err != nil {
continue
}
}
// Check date is within range
digestDate, err := time.Parse("2006-01-02", digest.Date)
if err != nil {
continue
}
if digestDate.Before(cutoff) {
continue
}
// Extract individual session entries from the digest
entries = append(entries, digest.Sessions...)
}
return entries, nil
}
// parseSessionName extracts role, rig, and worker from a session name.
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
// Examples:
// - gt-mayor -> role=mayor, rig="", worker="mayor"
// - gt-deacon -> role=deacon, rig="", worker="deacon"
// - gt-gastown-toast -> role=polecat, rig=gastown, worker=toast
// - gt-gastown-witness -> role=witness, rig=gastown, worker=""
// - gt-gastown-refinery -> role=refinery, rig=gastown, worker=""
// - gt-gastown-crew-joe -> role=crew, rig=gastown, worker=joe
func parseSessionName(session string) (role, rig, worker string) {
// Remove gt- prefix
name := strings.TrimPrefix(session, constants.SessionPrefix)
// Check for global agents
switch name {
case "mayor":
return constants.RoleMayor, "", "mayor"
case "deacon":
return constants.RoleDeacon, "", "deacon"
}
// Parse rig-based session: rig-worker or rig-crew-name
parts := strings.SplitN(name, "-", 3)
if len(parts) < 2 {
return "unknown", "", name
}
rig = parts[0]
worker = parts[1]
// Check for crew pattern: rig-crew-name
if worker == "crew" && len(parts) >= 3 {
return constants.RoleCrew, rig, parts[2]
}
// Check for special workers
switch worker {
case "witness":
return constants.RoleWitness, rig, ""
case "refinery":
return constants.RoleRefinery, rig, ""
}
// Default to polecat
return constants.RolePolecat, rig, worker
}
// extractCost finds the most recent cost value in pane content.
// Claude Code displays cost in the format "$X.XX" in the status area.
func extractCost(content string) float64 {
matches := costRegex.FindAllStringSubmatch(content, -1)
if len(matches) == 0 {
return 0.0
}
// Get the last (most recent) match
lastMatch := matches[len(matches)-1]
if len(lastMatch) < 2 {
return 0.0
}
var cost float64
_, _ = fmt.Sscanf(lastMatch[1], "%f", &cost)
return cost
}
func outputCostsJSON(output CostsOutput) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(output)
}
func outputCostsHuman(costs []SessionCost, total float64) error {
if len(costs) == 0 {
fmt.Println(style.Dim.Render("No Gas Town sessions found"))
return nil
}
fmt.Printf("\n%s Live Session Costs\n\n", style.Bold.Render("💰"))
// Print table header
fmt.Printf("%-25s %-10s %-15s %10s %8s\n",
"Session", "Role", "Rig/Worker", "Cost", "Status")
fmt.Println(strings.Repeat("─", 75))
// Print each session
for _, c := range costs {
statusIcon := style.Success.Render("●")
if !c.Running {
statusIcon = style.Dim.Render("○")
}
rigWorker := c.Rig
if c.Worker != "" && c.Worker != c.Rig {
if rigWorker != "" {
rigWorker += "/" + c.Worker
} else {
rigWorker = c.Worker
}
}
fmt.Printf("%-25s %-10s %-15s %10s %8s\n",
c.Session,
c.Role,
rigWorker,
fmt.Sprintf("$%.2f", c.Cost),
statusIcon)
}
// Print total
fmt.Println(strings.Repeat("─", 75))
fmt.Printf("%s %s\n", style.Bold.Render("Total:"), fmt.Sprintf("$%.2f", total))
return nil
}
func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
periodStr := ""
if output.Period != "" {
periodStr = fmt.Sprintf(" (%s)", output.Period)
}
fmt.Printf("\n%s Cost Summary%s\n\n", style.Bold.Render("📊"), periodStr)
// Total
fmt.Printf("%s $%.2f\n", style.Bold.Render("Total:"), output.Total)
// By role breakdown
if output.ByRole != nil && len(output.ByRole) > 0 {
fmt.Printf("\n%s\n", style.Bold.Render("By Role:"))
for role, cost := range output.ByRole {
icon := constants.RoleEmoji(role)
fmt.Printf(" %s %-12s $%.2f\n", icon, role, cost)
}
}
// By rig breakdown
if output.ByRig != nil && len(output.ByRig) > 0 {
fmt.Printf("\n%s\n", style.Bold.Render("By Rig:"))
for rig, cost := range output.ByRig {
fmt.Printf(" %-15s $%.2f\n", rig, cost)
}
}
// Session count
fmt.Printf("\n%s %d sessions\n", style.Dim.Render("Entries:"), len(entries))
return nil
}
// runCostsRecord captures the final cost from a session and records it as a bead event.
// This is called by the Claude Code Stop hook.
func runCostsRecord(cmd *cobra.Command, args []string) error {
// Get session from flag or try to detect from environment
session := recordSession
if session == "" {
session = os.Getenv("GT_SESSION")
}
if session == "" {
// Derive session name from GT_* environment variables
session = deriveSessionName()
}
if session == "" {
// Try to detect current tmux session (works when running inside tmux)
session = detectCurrentTmuxSession()
}
if session == "" {
return fmt.Errorf("--session flag required (or set GT_SESSION env var, or GT_RIG/GT_ROLE)")
}
t := tmux.NewTmux()
// Capture pane content
content, err := t.CapturePaneAll(session)
if err != nil {
// Session may already be gone - that's OK, we'll record with zero cost
content = ""
}
// Extract cost
cost := extractCost(content)
// Parse session name
role, rig, worker := parseSessionName(session)
// Build agent path for actor field
agentPath := buildAgentPath(role, rig, worker)
// Build event title
title := fmt.Sprintf("Session ended: %s", session)
if recordWorkItem != "" {
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem)
}
// Build payload JSON
payload := map[string]interface{}{
"cost_usd": cost,
"session_id": session,
"role": role,
"ended_at": time.Now().Format(time.RFC3339),
}
if rig != "" {
payload["rig"] = rig
}
if worker != "" {
payload["worker"] = worker
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshaling payload: %w", err)
}
// Build bd create command for ephemeral wisp
// Using --ephemeral creates a wisp that:
// - Is stored locally only (not exported to JSONL)
// - Won't pollute git history with O(sessions/day) events
// - Will be aggregated into daily digests by 'gt costs digest'
bdArgs := []string{
"create",
"--ephemeral",
"--type=event",
"--title=" + title,
"--event-category=session.ended",
"--event-actor=" + agentPath,
"--event-payload=" + string(payloadJSON),
"--silent",
}
// Add work item as event target if specified
if recordWorkItem != "" {
bdArgs = append(bdArgs, "--event-target="+recordWorkItem)
}
// NOTE: We intentionally don't use --rig flag here because it causes
// event fields (event_kind, actor, payload) to not be stored properly.
// The bd command will auto-detect the correct rig from cwd.
// Execute bd create
bdCmd := exec.Command("bd", bdArgs...)
output, err := bdCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output))
}
wispID := strings.TrimSpace(string(output))
// Auto-close session cost wisps immediately after creation.
// These are informational records that don't need to stay open.
// The wisp data is preserved and queryable until digested.
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
if closeErr := closeCmd.Run(); closeErr != nil {
// Non-fatal: wisp was created, just couldn't auto-close
fmt.Fprintf(os.Stderr, "warning: could not auto-close session cost wisp %s: %v\n", wispID, closeErr)
}
// Output confirmation (silent if cost is zero and no work item)
if cost > 0 || recordWorkItem != "" {
fmt.Printf("%s Recorded $%.2f for %s (wisp: %s)", style.Success.Render("✓"), cost, session, wispID)
if recordWorkItem != "" {
fmt.Printf(" (work: %s)", recordWorkItem)
}
fmt.Println()
}
return nil
}
// deriveSessionName derives the tmux session name from GT_* environment variables.
// Session naming patterns:
// - Polecats: gt-{rig}-{polecat} (e.g., gt-gastown-toast)
// - Crew: gt-{rig}-crew-{crew} (e.g., gt-gastown-crew-max)
// - Witness/Refinery: gt-{rig}-{role} (e.g., gt-gastown-witness)
// - Mayor/Deacon: gt-{town}-{role} (e.g., gt-ai-mayor)
func deriveSessionName() string {
role := os.Getenv("GT_ROLE")
rig := os.Getenv("GT_RIG")
polecat := os.Getenv("GT_POLECAT")
crew := os.Getenv("GT_CREW")
town := os.Getenv("GT_TOWN")
// Polecat: gt-{rig}-{polecat}
if polecat != "" && rig != "" {
return fmt.Sprintf("gt-%s-%s", rig, polecat)
}
// Crew: gt-{rig}-crew-{crew}
if crew != "" && rig != "" {
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
}
// Town-level roles (mayor, deacon): gt-{town}-{role} or gt-{role}
if role == "mayor" || role == "deacon" {
if town != "" {
return fmt.Sprintf("gt-%s-%s", town, role)
}
// No town set - use simple gt-{role} pattern
return fmt.Sprintf("gt-%s", role)
}
// Rig-based roles (witness, refinery): gt-{rig}-{role}
if role != "" && rig != "" {
return fmt.Sprintf("gt-%s-%s", rig, role)
}
return ""
}
// detectCurrentTmuxSession returns the current tmux session name if running inside tmux.
// Uses `tmux display-message -p '#S'` which prints the session name.
// Note: We don't check TMUX env var because it may not be inherited when Claude Code
// runs bash commands, even though we are inside a tmux session.
func detectCurrentTmuxSession() string {
cmd := exec.Command("tmux", "display-message", "-p", "#S")
output, err := cmd.Output()
if err != nil {
return ""
}
session := strings.TrimSpace(string(output))
// Only return if it looks like a Gas Town session
// Accept both gt- (rig sessions) and hq- (town-level sessions like hq-mayor)
if strings.HasPrefix(session, constants.SessionPrefix) || strings.HasPrefix(session, constants.HQSessionPrefix) {
return session
}
return ""
}
// buildAgentPath builds the agent path from role, rig, and worker.
// Examples: "mayor", "gastown/witness", "gastown/polecats/toast"
func buildAgentPath(role, rig, worker string) string {
switch role {
case constants.RoleMayor, constants.RoleDeacon:
return role
case constants.RoleWitness, constants.RoleRefinery:
if rig != "" {
return rig + "/" + role
}
return role
case constants.RolePolecat:
if rig != "" && worker != "" {
return rig + "/polecats/" + worker
}
if rig != "" {
return rig + "/polecat"
}
return "polecat/" + worker
case constants.RoleCrew:
if rig != "" && worker != "" {
return rig + "/crew/" + worker
}
if rig != "" {
return rig + "/crew"
}
return "crew/" + worker
default:
if rig != "" && worker != "" {
return rig + "/" + worker
}
if rig != "" {
return rig
}
return worker
}
}
// CostDigest represents the aggregated daily cost report.
type CostDigest struct {
Date string `json:"date"`
TotalUSD float64 `json:"total_usd"`
SessionCount int `json:"session_count"`
Sessions []CostEntry `json:"sessions"`
ByRole map[string]float64 `json:"by_role"`
ByRig map[string]float64 `json:"by_rig,omitempty"`
}
// WispListOutput represents the JSON output from bd mol wisp list.
type WispListOutput struct {
Wisps []WispItem `json:"wisps"`
Count int `json:"count"`
}
// WispItem represents a single wisp from bd mol wisp list.
type WispItem struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// runCostsDigest aggregates session cost wisps into a daily digest bead.
func runCostsDigest(cmd *cobra.Command, args []string) error {
// Determine target date
var targetDate time.Time
if digestDate != "" {
parsed, err := time.Parse("2006-01-02", digestDate)
if err != nil {
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
}
targetDate = parsed
} else if digestYesterday {
targetDate = time.Now().AddDate(0, 0, -1)
} else {
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
}
dateStr := targetDate.Format("2006-01-02")
// Query ephemeral session.ended wisps for target date
wisps, err := querySessionCostWisps(targetDate)
if err != nil {
return fmt.Errorf("querying session cost wisps: %w", err)
}
if len(wisps) == 0 {
fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("○"), dateStr)
return nil
}
// Build digest
digest := CostDigest{
Date: dateStr,
Sessions: wisps,
ByRole: make(map[string]float64),
ByRig: make(map[string]float64),
}
for _, w := range wisps {
digest.TotalUSD += w.CostUSD
digest.SessionCount++
digest.ByRole[w.Role] += w.CostUSD
if w.Rig != "" {
digest.ByRig[w.Rig] += w.CostUSD
}
}
if digestDryRun {
fmt.Printf("%s [DRY RUN] Would create Cost Report %s:\n", style.Bold.Render("📊"), dateStr)
fmt.Printf(" Total: $%.2f\n", digest.TotalUSD)
fmt.Printf(" Sessions: %d\n", digest.SessionCount)
fmt.Printf(" By Role:\n")
for role, cost := range digest.ByRole {
fmt.Printf(" %s: $%.2f\n", role, cost)
}
if len(digest.ByRig) > 0 {
fmt.Printf(" By Rig:\n")
for rig, cost := range digest.ByRig {
fmt.Printf(" %s: $%.2f\n", rig, cost)
}
}
return nil
}
// Create permanent digest bead
digestID, err := createCostDigestBead(digest)
if err != nil {
return fmt.Errorf("creating digest bead: %w", err)
}
// Delete source wisps (they're ephemeral, use bd mol burn)
deletedCount, deleteErr := deleteSessionCostWisps(targetDate)
if deleteErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %v\n", deleteErr)
}
fmt.Printf("%s Created Cost Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
if deletedCount > 0 {
fmt.Printf(" Deleted %d source wisps\n", deletedCount)
}
return nil
}
// querySessionCostWisps queries ephemeral session.ended events for a target date.
func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
// List all wisps including closed ones
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
listOutput, err := listCmd.Output()
if err != nil {
// No wisps database or command failed
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
}
return nil, nil
}
var wispList WispListOutput
if err := json.Unmarshal(listOutput, &wispList); err != nil {
return nil, fmt.Errorf("parsing wisp list: %w", err)
}
if wispList.Count == 0 {
return nil, nil
}
// Batch all wisp IDs into a single bd show call to avoid N+1 queries
showArgs := []string{"show", "--json"}
for _, wisp := range wispList.Wisps {
showArgs = append(showArgs, wisp.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return nil, fmt.Errorf("showing wisps: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return nil, fmt.Errorf("parsing wisp details: %w", err)
}
var sessionCostWisps []CostEntry
targetDay := targetDate.Format("2006-01-02")
for _, event := range events {
// Filter for session.ended events only
if event.EventKind != "session.ended" {
continue
}
// Parse payload
var payload SessionPayload
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for event %s: %v\n", event.ID, err)
}
continue
}
}
// Parse ended_at and filter by target date
endedAt := event.CreatedAt
if payload.EndedAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
endedAt = parsed
}
}
// Check if this event is from the target date
if endedAt.Format("2006-01-02") != targetDay {
continue
}
sessionCostWisps = append(sessionCostWisps, CostEntry{
SessionID: payload.SessionID,
Role: payload.Role,
Rig: payload.Rig,
Worker: payload.Worker,
CostUSD: payload.CostUSD,
EndedAt: endedAt,
WorkItem: event.Target,
})
}
return sessionCostWisps, nil
}
// createCostDigestBead creates a permanent bead for the daily cost digest.
func createCostDigestBead(digest CostDigest) (string, error) {
// Build description with aggregate data
var desc strings.Builder
desc.WriteString(fmt.Sprintf("Daily cost aggregate for %s.\n\n", digest.Date))
desc.WriteString(fmt.Sprintf("**Total:** $%.2f from %d sessions\n\n", digest.TotalUSD, digest.SessionCount))
if len(digest.ByRole) > 0 {
desc.WriteString("## By Role\n")
roles := make([]string, 0, len(digest.ByRole))
for role := range digest.ByRole {
roles = append(roles, role)
}
sort.Strings(roles)
for _, role := range roles {
icon := constants.RoleEmoji(role)
desc.WriteString(fmt.Sprintf("- %s %s: $%.2f\n", icon, role, digest.ByRole[role]))
}
desc.WriteString("\n")
}
if len(digest.ByRig) > 0 {
desc.WriteString("## By Rig\n")
rigs := make([]string, 0, len(digest.ByRig))
for rig := range digest.ByRig {
rigs = append(rigs, rig)
}
sort.Strings(rigs)
for _, rig := range rigs {
desc.WriteString(fmt.Sprintf("- %s: $%.2f\n", rig, digest.ByRig[rig]))
}
desc.WriteString("\n")
}
// Build payload JSON with full session details
payloadJSON, err := json.Marshal(digest)
if err != nil {
return "", fmt.Errorf("marshaling digest payload: %w", err)
}
// Create the digest bead (NOT ephemeral - this is permanent)
title := fmt.Sprintf("Cost Report %s", digest.Date)
bdArgs := []string{
"create",
"--type=event",
"--title=" + title,
"--event-category=costs.digest",
"--event-payload=" + string(payloadJSON),
"--description=" + desc.String(),
"--silent",
}
bdCmd := exec.Command("bd", bdArgs...)
output, err := bdCmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output))
}
digestID := strings.TrimSpace(string(output))
// Auto-close the digest (it's an audit record, not work)
closeCmd := exec.Command("bd", "close", digestID, "--reason=daily cost digest")
_ = closeCmd.Run() // Best effort
return digestID, nil
}
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date.
func deleteSessionCostWisps(targetDate time.Time) (int, error) {
// List all wisps
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
listOutput, err := listCmd.Output()
if err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] wisp list failed in deletion: %v\n", err)
}
return 0, nil
}
var wispList WispListOutput
if err := json.Unmarshal(listOutput, &wispList); err != nil {
return 0, fmt.Errorf("parsing wisp list: %w", err)
}
targetDay := targetDate.Format("2006-01-02")
// Collect all wisp IDs that match our criteria
var wispIDsToDelete []string
for _, wisp := range wispList.Wisps {
// Get full wisp details to check if it's a session.ended event
showCmd := exec.Command("bd", "show", wisp.ID, "--json")
showOutput, err := showCmd.Output()
if err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] bd show failed for wisp %s: %v\n", wisp.ID, err)
}
continue
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err)
}
continue
}
if len(events) == 0 {
continue
}
event := events[0]
// Only delete session.ended wisps
if event.EventKind != "session.ended" {
continue
}
// Parse payload to get ended_at for date filtering
var payload SessionPayload
if event.Payload != "" {
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil {
if costsVerbose {
fmt.Fprintf(os.Stderr, "[costs] payload unmarshal failed for wisp %s: %v\n", wisp.ID, err)
}
continue
}
}
endedAt := event.CreatedAt
if payload.EndedAt != "" {
if parsed, err := time.Parse(time.RFC3339, payload.EndedAt); err == nil {
endedAt = parsed
}
}
// Only delete wisps from the target date
if endedAt.Format("2006-01-02") != targetDay {
continue
}
wispIDsToDelete = append(wispIDsToDelete, wisp.ID)
}
if len(wispIDsToDelete) == 0 {
return 0, nil
}
// Batch delete all wisps in a single subprocess call
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...)
burnCmd := exec.Command("bd", burnArgs...)
if burnErr := burnCmd.Run(); burnErr != nil {
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
}
return len(wispIDsToDelete), nil
}
// runCostsMigrate migrates legacy session.ended beads to the new architecture.
func runCostsMigrate(cmd *cobra.Command, args []string) error {
// Query all session.ended events (both open and closed)
listArgs := []string{
"list",
"--type=event",
"--all",
"--limit=0",
"--json",
}
listCmd := exec.Command("bd", listArgs...)
listOutput, err := listCmd.Output()
if err != nil {
fmt.Println(style.Dim.Render("No events found or bd command failed"))
return nil
}
var listItems []EventListItem
if err := json.Unmarshal(listOutput, &listItems); err != nil {
return fmt.Errorf("parsing event list: %w", err)
}
if len(listItems) == 0 {
fmt.Println(style.Dim.Render("No events found"))
return nil
}
// Get full details for all events
showArgs := []string{"show", "--json"}
for _, item := range listItems {
showArgs = append(showArgs, item.ID)
}
showCmd := exec.Command("bd", showArgs...)
showOutput, err := showCmd.Output()
if err != nil {
return fmt.Errorf("showing events: %w", err)
}
var events []SessionEvent
if err := json.Unmarshal(showOutput, &events); err != nil {
return fmt.Errorf("parsing event details: %w", err)
}
// Find open session.ended events
var openEvents []SessionEvent
var closedCount int
for _, event := range events {
if event.EventKind != "session.ended" {
continue
}
if event.Status == "closed" {
closedCount++
continue
}
openEvents = append(openEvents, event)
}
fmt.Printf("%s Legacy session.ended beads:\n", style.Bold.Render("📊"))
fmt.Printf(" Closed: %d (no action needed)\n", closedCount)
fmt.Printf(" Open: %d (will be closed)\n", len(openEvents))
if len(openEvents) == 0 {
fmt.Println(style.Success.Render("\n✓ No migration needed - all session.ended events are already closed"))
return nil
}
if migrateDryRun {
fmt.Printf("\n%s Would close %d open session.ended events\n", style.Bold.Render("[DRY RUN]"), len(openEvents))
for _, event := range openEvents {
fmt.Printf(" - %s: %s\n", event.ID, event.Title)
}
return nil
}
// Close all open session.ended events
closedMigrated := 0
for _, event := range openEvents {
closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to wisp architecture")
if err := closeCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
continue
}
closedMigrated++
}
fmt.Printf("\n%s Migrated %d session.ended events (closed)\n", style.Success.Render("✓"), closedMigrated)
fmt.Println(style.Dim.Render("Legacy beads preserved for historical queries."))
fmt.Println(style.Dim.Render("New session costs will use ephemeral wisps + daily digests."))
return nil
}