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:
@@ -2,11 +2,10 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -40,7 +39,7 @@ By default, shows live costs scraped from running tmux sessions.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt costs # Live costs from running sessions
|
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 --week # This week's total
|
||||||
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
|
||||||
@@ -50,12 +49,12 @@ Examples:
|
|||||||
|
|
||||||
var costsRecordCmd = &cobra.Command{
|
var costsRecordCmd = &cobra.Command{
|
||||||
Use: "record",
|
Use: "record",
|
||||||
Short: "Record session cost to ledger (called by Stop hook)",
|
Short: "Record session cost as a bead event (called by Stop hook)",
|
||||||
Long: `Record the final cost of a session to the cost ledger.
|
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.
|
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
|
It captures the final cost from the tmux session and creates an event
|
||||||
~/.gt/costs.jsonl.
|
bead with the cost data.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
gt costs record --session gt-gastown-toast
|
gt costs record --session gt-gastown-toast
|
||||||
@@ -66,8 +65,8 @@ Examples:
|
|||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(costsCmd)
|
rootCmd.AddCommand(costsCmd)
|
||||||
costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON")
|
costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON")
|
||||||
costsCmd.Flags().BoolVar(&costsToday, "today", false, "Show today'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 ledger")
|
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(&costsByRole, "by-role", false, "Show breakdown by role")
|
||||||
costsCmd.Flags().BoolVar(&costsByRig, "by-rig", false, "Show breakdown by rig")
|
costsCmd.Flags().BoolVar(&costsByRig, "by-rig", false, "Show breakdown by rig")
|
||||||
|
|
||||||
@@ -181,14 +180,15 @@ func runLiveCosts() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runCostsFromLedger() error {
|
func runCostsFromLedger() error {
|
||||||
ledgerPath := getLedgerPath()
|
// Query session events from beads
|
||||||
entries, err := readLedger(ledgerPath)
|
entries, err := querySessionEvents()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return fmt.Errorf("querying session events: %w", 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)
|
|
||||||
|
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
|
// Filter entries by time period
|
||||||
@@ -253,6 +253,115 @@ func runCostsFromLedger() error {
|
|||||||
return outputLedgerHuman(output, filtered)
|
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.
|
// parseSessionName extracts role, rig, and worker from a session name.
|
||||||
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
|
// Session names follow the pattern: gt-<rig>-<worker> or gt-<global-agent>
|
||||||
// Examples:
|
// Examples:
|
||||||
@@ -319,63 +428,6 @@ func extractCost(content string) float64 {
|
|||||||
return cost
|
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 {
|
func outputCostsJSON(output CostsOutput) error {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
@@ -461,7 +513,7 @@ func outputLedgerHuman(output CostsOutput, entries []CostEntry) error {
|
|||||||
return nil
|
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.
|
// This is called by the Claude Code Stop hook.
|
||||||
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
|
||||||
@@ -489,26 +541,66 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
// Parse session name
|
// Parse session name
|
||||||
role, rig, worker := parseSessionName(session)
|
role, rig, worker := parseSessionName(session)
|
||||||
|
|
||||||
// Create ledger entry
|
// Build agent path for actor field
|
||||||
entry := CostEntry{
|
agentPath := buildAgentPath(role, rig, worker)
|
||||||
SessionID: session,
|
|
||||||
Role: role,
|
// Build event title
|
||||||
Rig: rig,
|
title := fmt.Sprintf("Session ended: %s", session)
|
||||||
Worker: worker,
|
if recordWorkItem != "" {
|
||||||
CostUSD: cost,
|
title = fmt.Sprintf("Session: %s completed %s", session, recordWorkItem)
|
||||||
StartedAt: time.Time{}, // We don't have start time; could enhance later
|
|
||||||
EndedAt: time.Now(),
|
|
||||||
WorkItem: recordWorkItem,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to ledger
|
// Build payload JSON
|
||||||
if err := WriteLedgerEntry(entry); err != nil {
|
payload := map[string]interface{}{
|
||||||
return fmt.Errorf("writing ledger: %w", err)
|
"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)
|
// 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", style.Success.Render("✓"), cost, session)
|
fmt.Printf("%s Recorded $%.2f for %s (event: %s)", style.Success.Render("✓"), cost, session, eventID)
|
||||||
if recordWorkItem != "" {
|
if recordWorkItem != "" {
|
||||||
fmt.Printf(" (work: %s)", recordWorkItem)
|
fmt.Printf(" (work: %s)", recordWorkItem)
|
||||||
}
|
}
|
||||||
@@ -517,3 +609,41 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user