From dc3fd47a32f495faee354b7ac83248d7a77efb30 Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 24 Jan 2026 21:21:03 -0800 Subject: [PATCH] refactor(costs): decouple cost recording from Dolt database Replace bd create --ephemeral wisp with simple file append to ~/.gt/costs.jsonl. This ensures the stop hook never fails due to: - Dolt server not running (connection refused) - Dolt connection stale (invalid connection) - Database temporarily unavailable The costs.jsonl approach: - Stop hook appends JSON line (fire-and-forget, ~0ms) - gt costs --today reads from log file - gt costs digest aggregates log entries into permanent beads This is Option 1 from gt-99ls5z design bead. Co-Authored-By: Claude Opus 4.5 --- internal/cmd/costs.go | 431 ++++++++++++++---------------------------- 1 file changed, 144 insertions(+), 287 deletions(-) diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index 65e29345..d61bff64 100644 --- a/internal/cmd/costs.go +++ b/internal/cmd/costs.go @@ -63,28 +63,29 @@ The infrastructure remains in place and will work once cost data is available. 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 --today # Today's costs from log file (not yet digested) + gt costs --week # This week's costs from digest beads + today's log 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)`, + gt costs record # Record session cost to local log file (Stop hook) + gt costs digest # Aggregate log entries 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. + Short: "Record session cost to local log file (called by Stop hook)", + Long: `Record the final cost of a session to a local log file. 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). +It captures the final cost from the tmux session and appends it to +~/.gt/costs.jsonl. This is a simple append operation that never fails +due to database availability. -Session cost wisps are aggregated daily by 'gt costs digest' into a single +Session costs are aggregated daily by 'gt costs digest' into a single permanent "Cost Report YYYY-MM-DD" bead for audit purposes. Examples: @@ -95,12 +96,12 @@ Examples: 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. + Short: "Aggregate session cost log entries into a daily digest bead", + Long: `Aggregate session cost log entries 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. +It reads entries from ~/.gt/costs.jsonl for a target date, creates a single +aggregate "Cost Report YYYY-MM-DD" bead, then removes the source entries. The resulting digest bead is permanent (exported to JSONL, synced via git) and provides an audit trail without log-in-database pollution. @@ -114,18 +115,18 @@ Examples: var costsMigrateCmd = &cobra.Command{ Use: "migrate", - Short: "Migrate legacy session.ended beads to the new wisp architecture", + Short: "Migrate legacy session.ended beads to the new log-file 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. +session.ended event was a permanent bead) to the new log-file-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" +2. Closes them with reason "migrated to log-file architecture" Legacy beads remain in the database for historical queries but won't interfere -with the new wisp-based cost tracking. +with the new log-file-based cost tracking. Examples: gt costs migrate # Migrate legacy beads @@ -738,8 +739,29 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error { 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. +// CostLogEntry represents a single entry in the costs.jsonl log file. +type CostLogEntry 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"` + EndedAt time.Time `json:"ended_at"` + WorkItem string `json:"work_item,omitempty"` +} + +// getCostsLogPath returns the path to the costs log file (~/.gt/costs.jsonl). +func getCostsLogPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "/tmp/gt-costs.jsonl" // Fallback + } + return filepath.Join(home, ".gt", "costs.jsonl") +} + +// runCostsRecord captures the final cost from a session and appends it to a local log file. +// This is called by the Claude Code Stop hook. It's designed to never fail due to +// database availability - it's a simple file append operation. func runCostsRecord(cmd *cobra.Command, args []string) error { // Get session from flag or try to detect from environment session := recordSession @@ -773,92 +795,47 @@ func runCostsRecord(cmd *cobra.Command, args []string) error { // 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 log entry + entry := CostLogEntry{ + SessionID: session, + Role: role, + Rig: rig, + Worker: worker, + CostUSD: cost, + EndedAt: time.Now(), + WorkItem: 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) + // Marshal to JSON + entryJSON, err := json.Marshal(entry) if err != nil { - return fmt.Errorf("marshaling payload: %w", err) + return fmt.Errorf("marshaling cost entry: %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", + // Append to log file + logPath := getCostsLogPath() + + // Ensure directory exists + logDir := filepath.Dir(logPath) + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("creating log directory: %w", err) } - // 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. - - // Find town root so bd can find the .beads database. - // The stop hook may run from a role subdirectory (e.g., mayor/) that - // doesn't have its own .beads, so we need to run bd from town root. - townRoot, err := workspace.FindFromCwd() + // Open file for append (create if doesn't exist) + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return fmt.Errorf("finding town root: %w", err) - } - if townRoot == "" { - return fmt.Errorf("not in a Gas Town workspace") + return fmt.Errorf("opening costs log: %w", err) } + defer f.Close() - // Execute bd create from town root - bdCmd := exec.Command("bd", bdArgs...) - bdCmd.Dir = townRoot - 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") - closeCmd.Dir = townRoot - 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) + // Write entry with newline + if _, err := f.Write(append(entryJSON, '\n')); err != nil { + return fmt.Errorf("writing to costs log: %w", err) } // 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) + fmt.Printf("%s Recorded $%.2f for %s", style.Success.Render("✓"), cost, session) if recordWorkItem != "" { fmt.Printf(" (work: %s)", recordWorkItem) } @@ -928,44 +905,6 @@ func detectCurrentTmuxSession() string { 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"` @@ -976,21 +915,7 @@ type CostDigest struct { 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. +// runCostsDigest aggregates session cost entries into a daily digest bead. func runCostsDigest(cmd *cobra.Command, args []string) error { // Determine target date var targetDate time.Time @@ -1016,7 +941,7 @@ func runCostsDigest(cmd *cobra.Command, args []string) error { } if len(wisps) == 0 { - fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("○"), dateStr) + fmt.Printf("%s No session cost entries found for %s\n", style.Dim.Render("○"), dateStr) return nil } @@ -1060,105 +985,70 @@ func runCostsDigest(cmd *cobra.Command, args []string) error { return fmt.Errorf("creating digest bead: %w", err) } - // Delete source wisps (they're ephemeral, use bd mol burn) - deletedCount, deleteErr := deleteSessionCostWisps(targetDate) + // Delete source entries from log file + deletedCount, deleteErr := deleteSessionCostEntries(targetDate) if deleteErr != nil { - fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %v\n", deleteErr) + fmt.Fprintf(os.Stderr, "warning: failed to delete some source entries: %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) + fmt.Printf(" Removed %d entries from costs log\n", deletedCount) } return nil } -// querySessionCostWisps queries ephemeral session.ended events for a target date. +// querySessionCostWisps reads session cost entries from the local log file 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() + logPath := getCostsLogPath() + + // Read log file + data, err := os.ReadFile(logPath) if err != nil { - // No wisps database or command failed - if costsVerbose { - fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err) + if os.IsNotExist(err) { + return nil, nil // No log file yet } - return nil, nil + return nil, fmt.Errorf("reading costs log: %w", err) } - 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") + var entries []CostEntry - for _, event := range events { - // Filter for session.ended events only - if event.EventKind != "session.ended" { + // Parse each line as a CostLogEntry + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { 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 + var logEntry CostLogEntry + if err := json.Unmarshal([]byte(line), &logEntry); err != nil { + if costsVerbose { + fmt.Fprintf(os.Stderr, "[costs] failed to parse log entry: %v\n", err) } - } - - // 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, + // Filter by target date + if logEntry.EndedAt.Format("2006-01-02") != targetDay { + continue + } + + entries = append(entries, CostEntry{ + SessionID: logEntry.SessionID, + Role: logEntry.Role, + Rig: logEntry.Rig, + Worker: logEntry.Worker, + CostUSD: logEntry.CostUSD, + EndedAt: logEntry.EndedAt, + WorkItem: logEntry.WorkItem, }) } - return sessionCostWisps, nil + return entries, nil } // createCostDigestBead creates a permanent bead for the daily cost digest. @@ -1228,96 +1118,63 @@ func createCostDigestBead(digest CostDigest) (string, error) { 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 - } +// deleteSessionCostEntries removes entries for a target date from the costs log file. +// It rewrites the file without the entries for that date. +func deleteSessionCostEntries(targetDate time.Time) (int, error) { + logPath := getCostsLogPath() - var wispList WispListOutput - if err := json.Unmarshal(listOutput, &wispList); err != nil { - return 0, fmt.Errorf("parsing wisp list: %w", err) + // Read log file + data, err := os.ReadFile(logPath) + if err != nil { + if os.IsNotExist(err) { + return 0, nil // No log file + } + return 0, fmt.Errorf("reading costs log: %w", err) } targetDay := targetDate.Format("2006-01-02") + var keepLines []string + deletedCount := 0 - // 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) - } + // Filter out entries for target date + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { 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) - } + var logEntry CostLogEntry + if err := json.Unmarshal([]byte(line), &logEntry); err != nil { + // Keep unparseable lines (shouldn't happen but be safe) + keepLines = append(keepLines, line) continue } - if len(events) == 0 { + // Remove entries from target date + if logEntry.EndedAt.Format("2006-01-02") == targetDay { + deletedCount++ 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) + keepLines = append(keepLines, line) } - if len(wispIDsToDelete) == 0 { + if deletedCount == 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) + // Rewrite file without deleted entries + newContent := strings.Join(keepLines, "\n") + if len(keepLines) > 0 { + newContent += "\n" } - return len(wispIDsToDelete), nil + if err := os.WriteFile(logPath, []byte(newContent), 0644); err != nil { + return 0, fmt.Errorf("rewriting costs log: %w", err) + } + + return deletedCount, nil } // runCostsMigrate migrates legacy session.ended beads to the new architecture. @@ -1399,7 +1256,7 @@ func runCostsMigrate(cmd *cobra.Command, args []string) error { // Close all open session.ended events closedMigrated := 0 for _, event := range openEvents { - closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to wisp architecture") + closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to log-file architecture") if err := closeCmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err) continue @@ -1409,7 +1266,7 @@ func runCostsMigrate(cmd *cobra.Command, args []string) error { 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.")) + fmt.Println(style.Dim.Render("New session costs will use ~/.gt/costs.jsonl + daily digests.")) return nil }