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 <noreply@anthropic.com>
This commit is contained in:
joe
2026-01-24 21:21:03 -08:00
committed by Steve Yegge
parent 889c5863fa
commit dc3fd47a32

View File

@@ -63,28 +63,29 @@ The infrastructure remains in place and will work once cost data is available.
Examples: Examples:
gt costs # Live costs from running sessions gt costs # Live costs from running sessions
gt costs --today # Today's costs from wisps (not yet digested) gt costs --today # Today's costs from log file (not yet digested)
gt costs --week # This week's costs from digest beads + today's wisps 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-role # Breakdown by role (polecat, witness, etc.)
gt costs --by-rig # Breakdown by rig gt costs --by-rig # Breakdown by rig
gt costs --json # Output as JSON gt costs --json # Output as JSON
Subcommands: Subcommands:
gt costs record # Record session cost as ephemeral wisp (Stop hook) gt costs record # Record session cost to local log file (Stop hook)
gt costs digest # Aggregate wisps into daily digest bead (Deacon patrol)`, gt costs digest # Aggregate log entries into daily digest bead (Deacon patrol)`,
RunE: runCosts, RunE: runCosts,
} }
var costsRecordCmd = &cobra.Command{ var costsRecordCmd = &cobra.Command{
Use: "record", Use: "record",
Short: "Record session cost as an ephemeral wisp (called by Stop hook)", Short: "Record session cost to local log file (called by Stop hook)",
Long: `Record the final cost of a session as an ephemeral wisp. 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. 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 It captures the final cost from the tmux session and appends it to
event that is NOT exported to JSONL (avoiding log-in-database pollution). ~/.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. permanent "Cost Report YYYY-MM-DD" bead for audit purposes.
Examples: Examples:
@@ -95,12 +96,12 @@ Examples:
var costsDigestCmd = &cobra.Command{ var costsDigestCmd = &cobra.Command{
Use: "digest", Use: "digest",
Short: "Aggregate session cost wisps into a daily digest bead", Short: "Aggregate session cost log entries into a daily digest bead",
Long: `Aggregate ephemeral session cost wisps into a permanent daily digest. Long: `Aggregate session cost log entries into a permanent daily digest.
This command is intended to be run by Deacon patrol (daily) or manually. 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 It reads entries from ~/.gt/costs.jsonl for a target date, creates a single
"Cost Report YYYY-MM-DD" bead, then deletes the source wisps. aggregate "Cost Report YYYY-MM-DD" bead, then removes the source entries.
The resulting digest bead is permanent (exported to JSONL, synced via git) The resulting digest bead is permanent (exported to JSONL, synced via git)
and provides an audit trail without log-in-database pollution. and provides an audit trail without log-in-database pollution.
@@ -114,18 +115,18 @@ Examples:
var costsMigrateCmd = &cobra.Command{ var costsMigrateCmd = &cobra.Command{
Use: "migrate", 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. Long: `Migrate legacy session.ended event beads to the new cost tracking system.
This command handles the transition from the old architecture (where each 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: The migration:
1. Finds all open session.ended event beads (should be none if auto-close worked) 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 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: Examples:
gt costs migrate # Migrate legacy beads gt costs migrate # Migrate legacy beads
@@ -738,8 +739,29 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
return nil return nil
} }
// runCostsRecord captures the final cost from a session and records it as a bead event. // CostLogEntry represents a single entry in the costs.jsonl log file.
// This is called by the Claude Code Stop hook. 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 { func runCostsRecord(cmd *cobra.Command, args []string) error {
// Get session from flag or try to detect from environment // Get session from flag or try to detect from environment
session := recordSession session := recordSession
@@ -773,92 +795,47 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
// Parse session name // Parse session name
role, rig, worker := parseSessionName(session) role, rig, worker := parseSessionName(session)
// Build agent path for actor field // Build log entry
agentPath := buildAgentPath(role, rig, worker) entry := CostLogEntry{
SessionID: session,
// Build event title Role: role,
title := fmt.Sprintf("Session ended: %s", session) Rig: rig,
if recordWorkItem != "" { Worker: worker,
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem) CostUSD: cost,
EndedAt: time.Now(),
WorkItem: recordWorkItem,
} }
// Build payload JSON // Marshal to JSON
payload := map[string]interface{}{ entryJSON, err := json.Marshal(entry)
"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 { 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 // Append to log file
// Using --ephemeral creates a wisp that: logPath := getCostsLogPath()
// - Is stored locally only (not exported to JSONL)
// - Won't pollute git history with O(sessions/day) events // Ensure directory exists
// - Will be aggregated into daily digests by 'gt costs digest' logDir := filepath.Dir(logPath)
bdArgs := []string{ if err := os.MkdirAll(logDir, 0755); err != nil {
"create", return fmt.Errorf("creating log directory: %w", err)
"--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 // Open file for append (create if doesn't exist)
if recordWorkItem != "" { f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
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()
if err != nil { if err != nil {
return fmt.Errorf("finding town root: %w", err) return fmt.Errorf("opening costs log: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
} }
defer f.Close()
// Execute bd create from town root // Write entry with newline
bdCmd := exec.Command("bd", bdArgs...) if _, err := f.Write(append(entryJSON, '\n')); err != nil {
bdCmd.Dir = townRoot return fmt.Errorf("writing to costs log: %w", err)
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)
} }
// Output confirmation (silent if cost is zero and no work item) // Output confirmation (silent if cost is zero and no work item)
if cost > 0 || recordWorkItem != "" { 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 != "" { if recordWorkItem != "" {
fmt.Printf(" (work: %s)", recordWorkItem) fmt.Printf(" (work: %s)", recordWorkItem)
} }
@@ -928,44 +905,6 @@ func detectCurrentTmuxSession() string {
return "" 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. // CostDigest represents the aggregated daily cost report.
type CostDigest struct { type CostDigest struct {
Date string `json:"date"` Date string `json:"date"`
@@ -976,21 +915,7 @@ type CostDigest struct {
ByRig map[string]float64 `json:"by_rig,omitempty"` ByRig map[string]float64 `json:"by_rig,omitempty"`
} }
// WispListOutput represents the JSON output from bd mol wisp list. // runCostsDigest aggregates session cost entries into a daily digest bead.
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 { func runCostsDigest(cmd *cobra.Command, args []string) error {
// Determine target date // Determine target date
var targetDate time.Time var targetDate time.Time
@@ -1016,7 +941,7 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
} }
if len(wisps) == 0 { 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 return nil
} }
@@ -1060,105 +985,70 @@ func runCostsDigest(cmd *cobra.Command, args []string) error {
return fmt.Errorf("creating digest bead: %w", err) return fmt.Errorf("creating digest bead: %w", err)
} }
// Delete source wisps (they're ephemeral, use bd mol burn) // Delete source entries from log file
deletedCount, deleteErr := deleteSessionCostWisps(targetDate) deletedCount, deleteErr := deleteSessionCostEntries(targetDate)
if deleteErr != nil { 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("%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) fmt.Printf(" Total: $%.2f from %d sessions\n", digest.TotalUSD, digest.SessionCount)
if deletedCount > 0 { if deletedCount > 0 {
fmt.Printf(" Deleted %d source wisps\n", deletedCount) fmt.Printf(" Removed %d entries from costs log\n", deletedCount)
} }
return nil 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) { func querySessionCostWisps(targetDate time.Time) ([]CostEntry, error) {
// List all wisps including closed ones logPath := getCostsLogPath()
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json")
listOutput, err := listCmd.Output() // Read log file
data, err := os.ReadFile(logPath)
if err != nil { if err != nil {
// No wisps database or command failed if os.IsNotExist(err) {
if costsVerbose { return nil, nil // No log file yet
fmt.Fprintf(os.Stderr, "[costs] wisp list failed: %v\n", err)
} }
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") targetDay := targetDate.Format("2006-01-02")
var entries []CostEntry
for _, event := range events { // Parse each line as a CostLogEntry
// Filter for session.ended events only lines := strings.Split(string(data), "\n")
if event.EventKind != "session.ended" { for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue continue
} }
// Parse payload var logEntry CostLogEntry
var payload SessionPayload if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
if event.Payload != "" { if costsVerbose {
if err := json.Unmarshal([]byte(event.Payload), &payload); err != nil { fmt.Fprintf(os.Stderr, "[costs] failed to parse log entry: %v\n", err)
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 continue
} }
sessionCostWisps = append(sessionCostWisps, CostEntry{ // Filter by target date
SessionID: payload.SessionID, if logEntry.EndedAt.Format("2006-01-02") != targetDay {
Role: payload.Role, continue
Rig: payload.Rig, }
Worker: payload.Worker,
CostUSD: payload.CostUSD, entries = append(entries, CostEntry{
EndedAt: endedAt, SessionID: logEntry.SessionID,
WorkItem: event.Target, 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. // createCostDigestBead creates a permanent bead for the daily cost digest.
@@ -1228,96 +1118,63 @@ func createCostDigestBead(digest CostDigest) (string, error) {
return digestID, nil return digestID, nil
} }
// deleteSessionCostWisps deletes ephemeral session.ended wisps for a target date. // deleteSessionCostEntries removes entries for a target date from the costs log file.
func deleteSessionCostWisps(targetDate time.Time) (int, error) { // It rewrites the file without the entries for that date.
// List all wisps func deleteSessionCostEntries(targetDate time.Time) (int, error) {
listCmd := exec.Command("bd", "mol", "wisp", "list", "--all", "--json") logPath := getCostsLogPath()
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 // Read log file
if err := json.Unmarshal(listOutput, &wispList); err != nil { data, err := os.ReadFile(logPath)
return 0, fmt.Errorf("parsing wisp list: %w", err) 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") targetDay := targetDate.Format("2006-01-02")
var keepLines []string
deletedCount := 0
// Collect all wisp IDs that match our criteria // Filter out entries for target date
var wispIDsToDelete []string lines := strings.Split(string(data), "\n")
for _, line := range lines {
for _, wisp := range wispList.Wisps { line = strings.TrimSpace(line)
// Get full wisp details to check if it's a session.ended event if line == "" {
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 continue
} }
var events []SessionEvent var logEntry CostLogEntry
if err := json.Unmarshal(showOutput, &events); err != nil { if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
if costsVerbose { // Keep unparseable lines (shouldn't happen but be safe)
fmt.Fprintf(os.Stderr, "[costs] JSON unmarshal failed for wisp %s: %v\n", wisp.ID, err) keepLines = append(keepLines, line)
}
continue continue
} }
if len(events) == 0 { // Remove entries from target date
if logEntry.EndedAt.Format("2006-01-02") == targetDay {
deletedCount++
continue continue
} }
event := events[0] keepLines = append(keepLines, line)
// 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 { if deletedCount == 0 {
return 0, nil return 0, nil
} }
// Batch delete all wisps in a single subprocess call // Rewrite file without deleted entries
burnArgs := append([]string{"mol", "burn", "--force"}, wispIDsToDelete...) newContent := strings.Join(keepLines, "\n")
burnCmd := exec.Command("bd", burnArgs...) if len(keepLines) > 0 {
if burnErr := burnCmd.Run(); burnErr != nil { newContent += "\n"
return 0, fmt.Errorf("batch burn failed: %w", burnErr)
} }
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. // 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 // Close all open session.ended events
closedMigrated := 0 closedMigrated := 0
for _, event := range openEvents { 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 { if err := closeCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err) fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err)
continue 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.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("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 return nil
} }