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:
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
}