From 0eacdd367bc75f19a52f887c8a7df3d8fd0f0ac6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 8 Jan 2026 22:52:47 -0800 Subject: [PATCH] feat(costs): redesign session cost tracking with wisps and daily digests (#292) * feat(costs): redesign session cost tracking with wisps and daily digests Implement the wisp-based cost tracking architecture per gt-cm900: - gt costs record now creates ephemeral wisps (not exported to JSONL) to avoid log-in-database pollution with O(sessions/day) events - gt costs digest aggregates yesterday's session wisps into a single permanent "Cost Report YYYY-MM-DD" bead for audit purposes - gt costs query updated: --today queries wisps, --week queries digest beads + today's wisps - gt costs migrate closes legacy open session.ended beads - Deacon patrol formula updated with costs-digest step The new architecture: Session ends -> Wisp (fast, N/day) -> Patrol digest -> Bead (1/day) This preserves audit trail while keeping issues.jsonl clean. Co-Authored-By: Claude Opus 4.5 * chore: sync canonical formula with embedded copy Update .beads/formulas/ with the costs-digest step added to mol-deacon-patrol.formula.toml. The go:generate copies from .beads/formulas/ to internal/formula/formulas/. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../formulas/mol-deacon-patrol.formula.toml | 87 ++- internal/cmd/costs.go | 662 ++++++++++++++++-- .../formulas/mol-deacon-patrol.formula.toml | 40 +- 3 files changed, 736 insertions(+), 53 deletions(-) diff --git a/.beads/formulas/mol-deacon-patrol.formula.toml b/.beads/formulas/mol-deacon-patrol.formula.toml index f07b42e9..854cc926 100644 --- a/.beads/formulas/mol-deacon-patrol.formula.toml +++ b/.beads/formulas/mol-deacon-patrol.formula.toml @@ -23,7 +23,7 @@ Witnesses detect it and escalate to the Mayor. The Deacon's agent bead last_activity timestamp is updated during each patrol cycle. Witnesses check this timestamp to verify health.""" formula = "mol-deacon-patrol" -version = 5 +version = 6 [[steps]] id = "inbox-check" @@ -148,6 +148,49 @@ bd gate list --json After closing a gate, the Waiters field contains mail addresses to notify. Send a brief notification to each waiter that the gate has cleared.""" +[[steps]] +id = "dispatch-gated-molecules" +title = "Dispatch molecules with resolved gates" +needs = ["gate-evaluation"] +description = """ +Find molecules blocked on gates that have now closed and dispatch them. + +This completes the async resume cycle without explicit waiter tracking. +The molecule state IS the waiter - patrol discovers reality each cycle. + +**Step 1: Find gate-ready molecules** +```bash +bd mol ready --gated --json +``` + +This returns molecules where: +- Status is in_progress +- Current step has a gate dependency +- The gate bead is now closed +- No polecat currently has it hooked + +**Step 2: For each ready molecule, dispatch to the appropriate rig** +```bash +# Determine target rig from molecule metadata +bd mol show --json +# Look for rig field or infer from prefix + +# Dispatch to that rig's polecat pool +gt sling /polecats +``` + +**Step 3: Log dispatch** +Note which molecules were dispatched for observability: +```bash +# Molecule dispatched to /polecats (gate cleared) +``` + +**If no gate-ready molecules:** +Skip - nothing to dispatch. Gates haven't closed yet or molecules +already have active polecats working on them. + +**Exit criteria:** All gate-ready molecules dispatched to polecats.""" + [[steps]] id = "check-convoy-completion" title = "Check convoy completion" @@ -258,7 +301,7 @@ Keep notifications brief and actionable. The recipient can run bd show for detai [[steps]] id = "health-scan" title = "Check Witness and Refinery health" -needs = ["trigger-pending-spawns", "gate-evaluation", "fire-notifications"] +needs = ["trigger-pending-spawns", "dispatch-gated-molecules", "fire-notifications"] description = """ Check Witness and Refinery health for each rig. @@ -520,10 +563,48 @@ Skip dispatch - system is healthy. **Exit criteria:** Session GC dispatched to dog (if needed).""" +[[steps]] +id = "costs-digest" +title = "Aggregate daily costs" +needs = ["session-gc"] +description = """ +**DAILY DIGEST** - Aggregate yesterday's session cost wisps. + +Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid +log-in-database pollution. This step aggregates them into a permanent daily +"Cost Report YYYY-MM-DD" bead for audit purposes. + +**Step 1: Check if digest is needed** +```bash +# Preview yesterday's costs (dry run) +gt costs digest --yesterday --dry-run +``` + +If output shows "No session cost wisps found", skip to Step 3. + +**Step 2: Create the digest** +```bash +gt costs digest --yesterday +``` + +This: +- Queries all session.ended wisps from yesterday +- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data +- Deletes the source wisps + +**Step 3: Verify** +The digest appears in `gt costs --week` queries. +Daily digests preserve audit trail without per-session pollution. + +**Timing**: Run once per morning patrol cycle. The --yesterday flag ensures +we don't try to digest today's incomplete data. + +**Exit criteria:** Yesterday's costs digested (or no wisps to digest).""" + [[steps]] id = "log-maintenance" title = "Rotate logs and prune state" -needs = ["session-gc"] +needs = ["costs-digest"] description = """ Maintain daemon logs and state files. diff --git a/internal/cmd/costs.go b/internal/cmd/costs.go index 5945f91a..cc539b69 100644 --- a/internal/cmd/costs.go +++ b/internal/cmd/costs.go @@ -27,6 +27,14 @@ var ( // Record subcommand flags recordSession string recordWorkItem string + + // Digest subcommand flags + digestYesterday bool + digestDate string + digestDryRun bool + + // Migrate subcommand flags + migrateDryRun bool ) var costsCmd = &cobra.Command{ @@ -37,24 +45,34 @@ var costsCmd = &cobra.Command{ By default, shows live costs scraped from running tmux sessions. +Cost tracking uses ephemeral wisps for individual sessions that are +aggregated into daily "Cost Report" digest beads for audit purposes. + Examples: gt costs # Live costs from running sessions - gt costs --today # Today's total from session events - gt costs --week # This week's total + 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 --by-role # Breakdown by role (polecat, witness, etc.) gt costs --by-rig # Breakdown by rig - gt costs --json # Output as JSON`, + 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)`, RunE: runCosts, } var costsRecordCmd = &cobra.Command{ Use: "record", - 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. + Short: "Record session cost as an ephemeral wisp (called by Stop hook)", + Long: `Record the final cost of a session as an ephemeral wisp. 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 event -bead with the cost data. +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). + +Session cost wisps are aggregated daily by 'gt costs digest' into a single +permanent "Cost Report YYYY-MM-DD" bead for audit purposes. Examples: gt costs record --session gt-gastown-toast @@ -62,6 +80,46 @@ Examples: RunE: runCostsRecord, } +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. + +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. + +The resulting digest bead is permanent (exported to JSONL, synced via git) +and provides an audit trail without log-in-database pollution. + +Examples: + gt costs digest --yesterday # Digest yesterday's costs (default for patrol) + gt costs digest --date 2026-01-07 # Digest a specific date + gt costs digest --yesterday --dry-run # Preview without changes`, + RunE: runCostsDigest, +} + +var costsMigrateCmd = &cobra.Command{ + Use: "migrate", + Short: "Migrate legacy session.ended beads to the new wisp 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. + +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" + +Legacy beads remain in the database for historical queries but won't interfere +with the new wisp-based cost tracking. + +Examples: + gt costs migrate # Migrate legacy beads + gt costs migrate --dry-run # Preview what would be migrated`, + RunE: runCostsMigrate, +} + func init() { rootCmd.AddCommand(costsCmd) costsCmd.Flags().BoolVar(&costsJSON, "json", false, "Output as JSON") @@ -74,6 +132,16 @@ func init() { costsCmd.AddCommand(costsRecordCmd) costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record") costsRecordCmd.Flags().StringVar(&recordWorkItem, "work-item", "", "Work item ID (bead) for attribution") + + // Add digest subcommand + costsCmd.AddCommand(costsDigestCmd) + costsDigestCmd.Flags().BoolVar(&digestYesterday, "yesterday", false, "Digest yesterday's costs (default for patrol)") + costsDigestCmd.Flags().StringVar(&digestDate, "date", "", "Digest a specific date (YYYY-MM-DD)") + costsDigestCmd.Flags().BoolVar(&digestDryRun, "dry-run", false, "Preview what would be done without making changes") + + // Add migrate subcommand + costsCmd.AddCommand(costsMigrateCmd) + costsMigrateCmd.Flags().BoolVar(&migrateDryRun, "dry-run", false, "Preview what would be migrated without making changes") } // SessionCost represents cost info for a single session. @@ -180,46 +248,48 @@ func runLiveCosts() error { } func runCostsFromLedger() error { - // Query session events from beads - entries, err := querySessionEvents() - if err != nil { - return fmt.Errorf("querying session events: %w", err) + now := time.Now() + var entries []CostEntry + var err error + + if costsToday { + // For today: query ephemeral wisps (not yet digested) + // This gives real-time view of today's costs + entries, err = querySessionCostWisps(now) + if err != nil { + return fmt.Errorf("querying session cost wisps: %w", err) + } + } else if costsWeek { + // For week: query digest beads (costs.digest events) + // These are the aggregated daily reports + entries, err = queryDigestBeads(7) + if err != nil { + return fmt.Errorf("querying digest beads: %w", err) + } + + // Also include today's wisps (not yet digested) + todayWisps, _ := querySessionCostWisps(now) + entries = append(entries, todayWisps...) + } else { + // No time filter: query both digests and legacy session.ended events + // (for backwards compatibility during migration) + entries, err = querySessionEvents() + if err != nil { + 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.")) + fmt.Println(style.Dim.Render("No cost data found. Costs are recorded when sessions end.")) return nil } - // Filter entries by time period - var filtered []CostEntry - now := time.Now() - - for _, entry := range entries { - if costsToday { - // Today: same day - if entry.EndedAt.Year() == now.Year() && - entry.EndedAt.YearDay() == now.YearDay() { - filtered = append(filtered, entry) - } - } else if costsWeek { - // This week: within 7 days - weekAgo := now.AddDate(0, 0, -7) - if entry.EndedAt.After(weekAgo) { - filtered = append(filtered, entry) - } - } else { - // No time filter - filtered = append(filtered, entry) - } - } - // Calculate totals var total float64 byRole := make(map[string]float64) byRig := make(map[string]float64) - for _, entry := range filtered { + for _, entry := range entries { total += entry.CostUSD byRole[entry.Role] += entry.CostUSD if entry.Rig != "" { @@ -250,7 +320,7 @@ func runCostsFromLedger() error { return outputCostsJSON(output) } - return outputLedgerHuman(output, filtered) + return outputLedgerHuman(output, entries) } // SessionEvent represents a session.ended event from beads. @@ -362,6 +432,84 @@ func querySessionEvents() ([]CostEntry, error) { return entries, nil } +// queryDigestBeads queries costs.digest events from the past N days and extracts session entries. +func queryDigestBeads(days int) ([]CostEntry, error) { + // 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 { + 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 + } + + // Get full details for all events + 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) + } + + // Calculate date range + now := time.Now() + cutoff := now.AddDate(0, 0, -days) + + var entries []CostEntry + for _, event := range events { + // Filter for costs.digest events only + if event.EventKind != "costs.digest" { + continue + } + + // Parse the digest payload + var digest CostDigest + if event.Payload != "" { + if err := json.Unmarshal([]byte(event.Payload), &digest); err != nil { + continue + } + } + + // Check date is within range + digestDate, err := time.Parse("2006-01-02", digest.Date) + if err != nil { + continue + } + if digestDate.Before(cutoff) { + continue + } + + // Extract individual session entries from the digest + entries = append(entries, digest.Sessions...) + } + + return entries, nil +} + // parseSessionName extracts role, rig, and worker from a session name. // Session names follow the pattern: gt-- or gt- // Examples: @@ -574,9 +722,14 @@ func runCostsRecord(cmd *cobra.Command, args []string) error { return fmt.Errorf("marshaling payload: %w", err) } - // Build bd create command + // 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", @@ -593,30 +746,28 @@ func runCostsRecord(cmd *cobra.Command, args []string) error { // 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)) + return fmt.Errorf("creating session cost wisp: %w\nOutput: %s", err, string(output)) } - eventID := strings.TrimSpace(string(output)) + wispID := strings.TrimSpace(string(output)) - // Auto-close session events immediately after creation. - // These are informational audit events that don't need to stay open. - // The event data is preserved in the closed bead and remains queryable. - closeCmd := exec.Command("bd", "close", eventID, "--reason=auto-closed session event") + // 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") if closeErr := closeCmd.Run(); closeErr != nil { - // Non-fatal: event was created, just couldn't auto-close - // The witness patrol can clean these up if needed - fmt.Fprintf(os.Stderr, "warning: could not auto-close session event %s: %v\n", eventID, closeErr) + // 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) if cost > 0 || recordWorkItem != "" { - fmt.Printf("%s Recorded $%.2f for %s (event: %s)", style.Success.Render("āœ“"), cost, session, eventID) + fmt.Printf("%s Recorded $%.2f for %s (wisp: %s)", style.Success.Render("āœ“"), cost, session, wispID) if recordWorkItem != "" { fmt.Printf(" (work: %s)", recordWorkItem) } @@ -723,3 +874,416 @@ func buildAgentPath(role, rig, worker string) string { return worker } } + +// CostDigest represents the aggregated daily cost report. +type CostDigest struct { + Date string `json:"date"` + TotalUSD float64 `json:"total_usd"` + SessionCount int `json:"session_count"` + Sessions []CostEntry `json:"sessions"` + ByRole map[string]float64 `json:"by_role"` + 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. +func runCostsDigest(cmd *cobra.Command, args []string) error { + // Determine target date + var targetDate time.Time + + if digestDate != "" { + parsed, err := time.Parse("2006-01-02", digestDate) + if err != nil { + return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err) + } + targetDate = parsed + } else if digestYesterday { + targetDate = time.Now().AddDate(0, 0, -1) + } else { + return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD") + } + + dateStr := targetDate.Format("2006-01-02") + + // Query ephemeral session.ended wisps for target date + wisps, err := querySessionCostWisps(targetDate) + if err != nil { + return fmt.Errorf("querying session cost wisps: %w", err) + } + + if len(wisps) == 0 { + fmt.Printf("%s No session cost wisps found for %s\n", style.Dim.Render("ā—‹"), dateStr) + return nil + } + + // Build digest + digest := CostDigest{ + Date: dateStr, + Sessions: wisps, + ByRole: make(map[string]float64), + ByRig: make(map[string]float64), + } + + for _, w := range wisps { + digest.TotalUSD += w.CostUSD + digest.SessionCount++ + digest.ByRole[w.Role] += w.CostUSD + if w.Rig != "" { + digest.ByRig[w.Rig] += w.CostUSD + } + } + + if digestDryRun { + fmt.Printf("%s [DRY RUN] Would create Cost Report %s:\n", style.Bold.Render("šŸ“Š"), dateStr) + fmt.Printf(" Total: $%.2f\n", digest.TotalUSD) + fmt.Printf(" Sessions: %d\n", digest.SessionCount) + fmt.Printf(" By Role:\n") + for role, cost := range digest.ByRole { + fmt.Printf(" %s: $%.2f\n", role, cost) + } + if len(digest.ByRig) > 0 { + fmt.Printf(" By Rig:\n") + for rig, cost := range digest.ByRig { + fmt.Printf(" %s: $%.2f\n", rig, cost) + } + } + return nil + } + + // Create permanent digest bead + digestID, err := createCostDigestBead(digest) + if err != nil { + return fmt.Errorf("creating digest bead: %w", err) + } + + // Delete source wisps (they're ephemeral, use bd mol burn) + deletedCount, deleteErr := deleteSessionCostWisps(targetDate) + if deleteErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to delete some source wisps: %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) + } + + return nil +} + +// querySessionCostWisps queries ephemeral session.ended events 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() + if err != nil { + // No wisps database or command failed + return nil, nil + } + + 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 + } + + // Get full details for each wisp to check event_kind and payload + var sessionCostWisps []CostEntry + targetDay := targetDate.Format("2006-01-02") + + for _, wisp := range wispList.Wisps { + // Get full wisp details + showCmd := exec.Command("bd", "show", wisp.ID, "--json") + showOutput, err := showCmd.Output() + if err != nil { + continue + } + + var events []SessionEvent + if err := json.Unmarshal(showOutput, &events); err != nil { + continue + } + + if len(events) == 0 { + continue + } + + event := events[0] + + // 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 + } + } + + // 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, + }) + } + + return sessionCostWisps, nil +} + +// createCostDigestBead creates a permanent bead for the daily cost digest. +func createCostDigestBead(digest CostDigest) (string, error) { + // Build description with aggregate data + var desc strings.Builder + desc.WriteString(fmt.Sprintf("Daily cost aggregate for %s.\n\n", digest.Date)) + desc.WriteString(fmt.Sprintf("**Total:** $%.2f from %d sessions\n\n", digest.TotalUSD, digest.SessionCount)) + + if len(digest.ByRole) > 0 { + desc.WriteString("## By Role\n") + for role, cost := range digest.ByRole { + icon := constants.RoleEmoji(role) + desc.WriteString(fmt.Sprintf("- %s %s: $%.2f\n", icon, role, cost)) + } + desc.WriteString("\n") + } + + if len(digest.ByRig) > 0 { + desc.WriteString("## By Rig\n") + for rig, cost := range digest.ByRig { + desc.WriteString(fmt.Sprintf("- %s: $%.2f\n", rig, cost)) + } + desc.WriteString("\n") + } + + // Build payload JSON with full session details + payloadJSON, err := json.Marshal(digest) + if err != nil { + return "", fmt.Errorf("marshaling digest payload: %w", err) + } + + // Create the digest bead (NOT ephemeral - this is permanent) + title := fmt.Sprintf("Cost Report %s", digest.Date) + bdArgs := []string{ + "create", + "--type=event", + "--title=" + title, + "--event-category=costs.digest", + "--event-payload=" + string(payloadJSON), + "--description=" + desc.String(), + "--silent", + } + + bdCmd := exec.Command("bd", bdArgs...) + output, err := bdCmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output)) + } + + digestID := strings.TrimSpace(string(output)) + + // Auto-close the digest (it's an audit record, not work) + closeCmd := exec.Command("bd", "close", digestID, "--reason=daily cost digest") + _ = closeCmd.Run() // Best effort + + 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 { + return 0, nil + } + + var wispList WispListOutput + if err := json.Unmarshal(listOutput, &wispList); err != nil { + return 0, fmt.Errorf("parsing wisp list: %w", err) + } + + targetDay := targetDate.Format("2006-01-02") + deletedCount := 0 + + 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 { + continue + } + + var events []SessionEvent + if err := json.Unmarshal(showOutput, &events); err != nil { + continue + } + + if len(events) == 0 { + 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 { + 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 + } + + // Delete using bd mol burn (for ephemeral wisps) + burnCmd := exec.Command("bd", "mol", "burn", wisp.ID) + if burnErr := burnCmd.Run(); burnErr == nil { + deletedCount++ + } + } + + return deletedCount, nil +} + +// runCostsMigrate migrates legacy session.ended beads to the new architecture. +func runCostsMigrate(cmd *cobra.Command, args []string) error { + // Query all session.ended events (both open and closed) + listArgs := []string{ + "list", + "--type=event", + "--all", + "--limit=0", + "--json", + } + + listCmd := exec.Command("bd", listArgs...) + listOutput, err := listCmd.Output() + if err != nil { + fmt.Println(style.Dim.Render("No events found or bd command failed")) + return nil + } + + var listItems []EventListItem + if err := json.Unmarshal(listOutput, &listItems); err != nil { + return fmt.Errorf("parsing event list: %w", err) + } + + if len(listItems) == 0 { + fmt.Println(style.Dim.Render("No events found")) + return nil + } + + // Get full details for all events + 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 fmt.Errorf("showing events: %w", err) + } + + var events []SessionEvent + if err := json.Unmarshal(showOutput, &events); err != nil { + return fmt.Errorf("parsing event details: %w", err) + } + + // Find open session.ended events + var openEvents []SessionEvent + var closedCount int + for _, event := range events { + if event.EventKind != "session.ended" { + continue + } + if event.Status == "closed" { + closedCount++ + continue + } + openEvents = append(openEvents, event) + } + + fmt.Printf("%s Legacy session.ended beads:\n", style.Bold.Render("šŸ“Š")) + fmt.Printf(" Closed: %d (no action needed)\n", closedCount) + fmt.Printf(" Open: %d (will be closed)\n", len(openEvents)) + + if len(openEvents) == 0 { + fmt.Println(style.Success.Render("\nāœ“ No migration needed - all session.ended events are already closed")) + return nil + } + + if migrateDryRun { + fmt.Printf("\n%s Would close %d open session.ended events\n", style.Bold.Render("[DRY RUN]"), len(openEvents)) + for _, event := range openEvents { + fmt.Printf(" - %s: %s\n", event.ID, event.Title) + } + return nil + } + + // Close all open session.ended events + closedMigrated := 0 + for _, event := range openEvents { + closeCmd := exec.Command("bd", "close", event.ID, "--reason=migrated to wisp architecture") + if err := closeCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not close %s: %v\n", event.ID, err) + continue + } + 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("New session costs will use ephemeral wisps + daily digests.")) + + return nil +} diff --git a/internal/formula/formulas/mol-deacon-patrol.formula.toml b/internal/formula/formulas/mol-deacon-patrol.formula.toml index 7cb54263..854cc926 100644 --- a/internal/formula/formulas/mol-deacon-patrol.formula.toml +++ b/internal/formula/formulas/mol-deacon-patrol.formula.toml @@ -563,10 +563,48 @@ Skip dispatch - system is healthy. **Exit criteria:** Session GC dispatched to dog (if needed).""" +[[steps]] +id = "costs-digest" +title = "Aggregate daily costs" +needs = ["session-gc"] +description = """ +**DAILY DIGEST** - Aggregate yesterday's session cost wisps. + +Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid +log-in-database pollution. This step aggregates them into a permanent daily +"Cost Report YYYY-MM-DD" bead for audit purposes. + +**Step 1: Check if digest is needed** +```bash +# Preview yesterday's costs (dry run) +gt costs digest --yesterday --dry-run +``` + +If output shows "No session cost wisps found", skip to Step 3. + +**Step 2: Create the digest** +```bash +gt costs digest --yesterday +``` + +This: +- Queries all session.ended wisps from yesterday +- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data +- Deletes the source wisps + +**Step 3: Verify** +The digest appears in `gt costs --week` queries. +Daily digests preserve audit trail without per-session pollution. + +**Timing**: Run once per morning patrol cycle. The --yesterday flag ensures +we don't try to digest today's incomplete data. + +**Exit criteria:** Yesterday's costs digested (or no wisps to digest).""" + [[steps]] id = "log-maintenance" title = "Rotate logs and prune state" -needs = ["session-gc"] +needs = ["costs-digest"] description = """ Maintain daemon logs and state files.