feat(costs): Use beads for session cost tracking instead of JSONL (gt-f7jxr)

- Updated `gt costs record` to create session.ended events in beads
- Updated `gt costs --today/--week` queries to use bd instead of JSONL
- Removed JSONL ledger support (getLedgerPath, readLedger, WriteLedgerEntry)
- Session costs now stored with event_kind, actor, target, and payload fields
- Filed bd-xwvo for beads bug where --rig flag loses event fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
capable
2026-01-02 13:24:11 -08:00
committed by Steve Yegge
parent c0d597f092
commit aafc717113

View File

@@ -2,11 +2,10 @@
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"os/exec"
"regexp"
"sort"
"strings"
@@ -40,7 +39,7 @@ By default, shows live costs scraped from running tmux sessions.
Examples:
gt costs # Live costs from running sessions
gt costs --today # Today's total from ledger
gt costs --today # Today's total from session events
gt costs --week # This week's total
gt costs --by-role # Breakdown by role (polecat, witness, etc.)
gt costs --by-rig # Breakdown by rig
@@ -50,12 +49,12 @@ Examples:
var costsRecordCmd = &cobra.Command{
Use: "record",
Short: "Record session cost to ledger (called by Stop hook)",
Long: `Record the final cost of a session to the cost ledger.
Short: "Record session cost as a bead event (called by Stop hook)",
Long: `Record the final cost of a session as a session.ended event in beads.
This command is intended to be called from a Claude Code Stop hook.
It captures the final cost from the tmux session and writes it to
~/.gt/costs.jsonl.
It captures the final cost from the tmux session and creates an event
bead with the cost data.
Examples:
gt costs record --session gt-gastown-toast
@@ -66,8 +65,8 @@ Examples:
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 ledger")
costsCmd.Flags().BoolVar(&costsWeek, "week", false, "Show this week's total from ledger")
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")
@@ -181,14 +180,15 @@ func runLiveCosts() error {
}
func runCostsFromLedger() error {
ledgerPath := getLedgerPath()
entries, err := readLedger(ledgerPath)
// Query session events from beads
entries, err := querySessionEvents()
if err != nil {
if os.IsNotExist(err) {
fmt.Println(style.Dim.Render("No cost ledger found. Costs are recorded when sessions end."))
return nil
}
return fmt.Errorf("reading ledger: %w", err)
return fmt.Errorf("querying session events: %w", err)
}
if len(entries) == 0 {
fmt.Println(style.Dim.Render("No session events found. Costs are recorded when sessions end."))
return nil
}
// Filter entries by time period
@@ -253,6 +253,115 @@ func runCostsFromLedger() error {
return outputLedgerHuman(output, filtered)
}
// 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
}
// parseSessionName extracts role, rig, and worker from a session name.
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
// Examples:
@@ -319,63 +428,6 @@ func extractCost(content string) float64 {
return cost
}
// getLedgerPath returns the path to the cost ledger file.
func getLedgerPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".gt", "costs.jsonl")
}
// readLedger reads all entries from the cost ledger.
func readLedger(path string) ([]CostEntry, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var entries []CostEntry
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var entry CostEntry
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
continue // Skip malformed lines
}
entries = append(entries, entry)
}
return entries, scanner.Err()
}
// WriteLedgerEntry appends a cost entry to the ledger.
// This is called by the SessionEnd hook handler.
func WriteLedgerEntry(entry CostEntry) error {
path := getLedgerPath()
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating ledger directory: %w", err)
}
// Open file for appending
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("opening ledger: %w", err)
}
defer file.Close()
// Write JSON line
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshaling entry: %w", err)
}
_, err = file.Write(append(data, '\n'))
return err
}
func outputCostsJSON(output CostsOutput) error {
enc := json.NewEncoder(os.Stdout)
@@ -461,7 +513,7 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
return nil
}
// runCostsRecord captures the final cost from a session and writes to ledger.
// 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
@@ -489,26 +541,66 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
// Parse session name
role, rig, worker := parseSessionName(session)
// Create ledger entry
entry := CostEntry{
SessionID: session,
Role: role,
Rig: rig,
Worker: worker,
CostUSD: cost,
StartedAt: time.Time{}, // We don't have start time; could enhance later
EndedAt: time.Now(),
WorkItem: recordWorkItem,
// 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)
}
// Write to ledger
if err := WriteLedgerEntry(entry); err != nil {
return fmt.Errorf("writing ledger: %w", err)
// 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
bdArgs := []string{
"create",
"--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.
// TODO: File beads bug about --rig flag losing event fields.
// Execute bd create
bdCmd := exec.Command("bd", bdArgs...)
output, err := bdCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("creating session event: %w\nOutput: %s", err, string(output))
}
eventID := strings.TrimSpace(string(output))
// Output confirmation (silent if cost is zero and no work item)
if cost > 0 || recordWorkItem != "" {
fmt.Printf("%s Recorded $%.2f for %s", style.Success.Render("✓"), cost, session)
fmt.Printf("%s Recorded $%.2f for %s (event: %s)", style.Success.Render("✓"), cost, session, eventID)
if recordWorkItem != "" {
fmt.Printf(" (work: %s)", recordWorkItem)
}
@@ -517,3 +609,41 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
return nil
}
// 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
}
}