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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
The Deacon's agent bead last_activity timestamp is updated during each patrol
|
||||||
cycle. Witnesses check this timestamp to verify health."""
|
cycle. Witnesses check this timestamp to verify health."""
|
||||||
formula = "mol-deacon-patrol"
|
formula = "mol-deacon-patrol"
|
||||||
version = 5
|
version = 6
|
||||||
|
|
||||||
[[steps]]
|
[[steps]]
|
||||||
id = "inbox-check"
|
id = "inbox-check"
|
||||||
@@ -148,6 +148,49 @@ bd gate list --json
|
|||||||
After closing a gate, the Waiters field contains mail addresses to notify.
|
After closing a gate, the Waiters field contains mail addresses to notify.
|
||||||
Send a brief notification to each waiter that the gate has cleared."""
|
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 <mol-id> --json
|
||||||
|
# Look for rig field or infer from prefix
|
||||||
|
|
||||||
|
# Dispatch to that rig's polecat pool
|
||||||
|
gt sling <mol-id> <rig>/polecats
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Log dispatch**
|
||||||
|
Note which molecules were dispatched for observability:
|
||||||
|
```bash
|
||||||
|
# Molecule <mol-id> dispatched to <rig>/polecats (gate <gate-id> 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]]
|
[[steps]]
|
||||||
id = "check-convoy-completion"
|
id = "check-convoy-completion"
|
||||||
title = "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]]
|
[[steps]]
|
||||||
id = "health-scan"
|
id = "health-scan"
|
||||||
title = "Check Witness and Refinery health"
|
title = "Check Witness and Refinery health"
|
||||||
needs = ["trigger-pending-spawns", "gate-evaluation", "fire-notifications"]
|
needs = ["trigger-pending-spawns", "dispatch-gated-molecules", "fire-notifications"]
|
||||||
description = """
|
description = """
|
||||||
Check Witness and Refinery health for each rig.
|
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)."""
|
**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]]
|
[[steps]]
|
||||||
id = "log-maintenance"
|
id = "log-maintenance"
|
||||||
title = "Rotate logs and prune state"
|
title = "Rotate logs and prune state"
|
||||||
needs = ["session-gc"]
|
needs = ["costs-digest"]
|
||||||
description = """
|
description = """
|
||||||
Maintain daemon logs and state files.
|
Maintain daemon logs and state files.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ var (
|
|||||||
// Record subcommand flags
|
// Record subcommand flags
|
||||||
recordSession string
|
recordSession string
|
||||||
recordWorkItem string
|
recordWorkItem string
|
||||||
|
|
||||||
|
// Digest subcommand flags
|
||||||
|
digestYesterday bool
|
||||||
|
digestDate string
|
||||||
|
digestDryRun bool
|
||||||
|
|
||||||
|
// Migrate subcommand flags
|
||||||
|
migrateDryRun bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var costsCmd = &cobra.Command{
|
var costsCmd = &cobra.Command{
|
||||||
@@ -37,24 +45,34 @@ var costsCmd = &cobra.Command{
|
|||||||
|
|
||||||
By default, shows live costs scraped from running tmux sessions.
|
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:
|
Examples:
|
||||||
gt costs # Live costs from running sessions
|
gt costs # Live costs from running sessions
|
||||||
gt costs --today # Today's total from session events
|
gt costs --today # Today's costs from wisps (not yet digested)
|
||||||
gt costs --week # This week's total
|
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-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:
|
||||||
|
gt costs record # Record session cost as ephemeral wisp (Stop hook)
|
||||||
|
gt costs digest # Aggregate wisps 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 a bead event (called by Stop hook)",
|
Short: "Record session cost as an ephemeral wisp (called by Stop hook)",
|
||||||
Long: `Record the final cost of a session as a session.ended event in beads.
|
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.
|
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
|
It captures the final cost from the tmux session and creates an ephemeral
|
||||||
bead with the cost data.
|
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:
|
Examples:
|
||||||
gt costs record --session gt-gastown-toast
|
gt costs record --session gt-gastown-toast
|
||||||
@@ -62,6 +80,46 @@ Examples:
|
|||||||
RunE: runCostsRecord,
|
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() {
|
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")
|
||||||
@@ -74,6 +132,16 @@ func init() {
|
|||||||
costsCmd.AddCommand(costsRecordCmd)
|
costsCmd.AddCommand(costsRecordCmd)
|
||||||
costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record")
|
costsRecordCmd.Flags().StringVar(&recordSession, "session", "", "Tmux session name to record")
|
||||||
costsRecordCmd.Flags().StringVar(&recordWorkItem, "work-item", "", "Work item ID (bead) for attribution")
|
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.
|
// SessionCost represents cost info for a single session.
|
||||||
@@ -180,46 +248,48 @@ func runLiveCosts() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runCostsFromLedger() error {
|
func runCostsFromLedger() error {
|
||||||
// Query session events from beads
|
now := time.Now()
|
||||||
entries, err := querySessionEvents()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("querying session events: %w", err)
|
return fmt.Errorf("querying session events: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(entries) == 0 {
|
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
|
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
|
// Calculate totals
|
||||||
var total float64
|
var total float64
|
||||||
byRole := make(map[string]float64)
|
byRole := make(map[string]float64)
|
||||||
byRig := make(map[string]float64)
|
byRig := make(map[string]float64)
|
||||||
|
|
||||||
for _, entry := range filtered {
|
for _, entry := range entries {
|
||||||
total += entry.CostUSD
|
total += entry.CostUSD
|
||||||
byRole[entry.Role] += entry.CostUSD
|
byRole[entry.Role] += entry.CostUSD
|
||||||
if entry.Rig != "" {
|
if entry.Rig != "" {
|
||||||
@@ -250,7 +320,7 @@ func runCostsFromLedger() error {
|
|||||||
return outputCostsJSON(output)
|
return outputCostsJSON(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputLedgerHuman(output, filtered)
|
return outputLedgerHuman(output, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionEvent represents a session.ended event from beads.
|
// SessionEvent represents a session.ended event from beads.
|
||||||
@@ -362,6 +432,84 @@ func querySessionEvents() ([]CostEntry, error) {
|
|||||||
return entries, nil
|
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.
|
// 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:
|
||||||
@@ -574,9 +722,14 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("marshaling payload: %w", err)
|
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{
|
bdArgs := []string{
|
||||||
"create",
|
"create",
|
||||||
|
"--ephemeral",
|
||||||
"--type=event",
|
"--type=event",
|
||||||
"--title=" + title,
|
"--title=" + title,
|
||||||
"--event-category=session.ended",
|
"--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
|
// NOTE: We intentionally don't use --rig flag here because it causes
|
||||||
// event fields (event_kind, actor, payload) to not be stored properly.
|
// event fields (event_kind, actor, payload) to not be stored properly.
|
||||||
// The bd command will auto-detect the correct rig from cwd.
|
// The bd command will auto-detect the correct rig from cwd.
|
||||||
// TODO: File beads bug about --rig flag losing event fields.
|
|
||||||
|
|
||||||
// Execute bd create
|
// Execute bd create
|
||||||
bdCmd := exec.Command("bd", bdArgs...)
|
bdCmd := exec.Command("bd", bdArgs...)
|
||||||
output, err := bdCmd.CombinedOutput()
|
output, err := bdCmd.CombinedOutput()
|
||||||
if err != nil {
|
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.
|
// Auto-close session cost wisps immediately after creation.
|
||||||
// These are informational audit events that don't need to stay open.
|
// These are informational records that don't need to stay open.
|
||||||
// The event data is preserved in the closed bead and remains queryable.
|
// The wisp data is preserved and queryable until digested.
|
||||||
closeCmd := exec.Command("bd", "close", eventID, "--reason=auto-closed session event")
|
closeCmd := exec.Command("bd", "close", wispID, "--reason=auto-closed session cost wisp")
|
||||||
if closeErr := closeCmd.Run(); closeErr != nil {
|
if closeErr := closeCmd.Run(); closeErr != nil {
|
||||||
// Non-fatal: event was created, just couldn't auto-close
|
// Non-fatal: wisp 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 cost wisp %s: %v\n", wispID, closeErr)
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not auto-close session event %s: %v\n", eventID, 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 (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 != "" {
|
if recordWorkItem != "" {
|
||||||
fmt.Printf(" (work: %s)", recordWorkItem)
|
fmt.Printf(" (work: %s)", recordWorkItem)
|
||||||
}
|
}
|
||||||
@@ -723,3 +874,416 @@ func buildAgentPath(role, rig, worker string) string {
|
|||||||
return worker
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -563,10 +563,48 @@ Skip dispatch - system is healthy.
|
|||||||
|
|
||||||
**Exit criteria:** Session GC dispatched to dog (if needed)."""
|
**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]]
|
[[steps]]
|
||||||
id = "log-maintenance"
|
id = "log-maintenance"
|
||||||
title = "Rotate logs and prune state"
|
title = "Rotate logs and prune state"
|
||||||
needs = ["session-gc"]
|
needs = ["costs-digest"]
|
||||||
description = """
|
description = """
|
||||||
Maintain daemon logs and state files.
|
Maintain daemon logs and state files.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user