From d6a4bc22fd9aa0768ba53c424e429951238b4ad6 Mon Sep 17 00:00:00 2001 From: gastown/crew/jack Date: Sat, 17 Jan 2026 02:11:12 -0800 Subject: [PATCH] feat(patrol): add daily patrol digest aggregation Per-cycle patrol digests were polluting JSONL with O(cycles/day) beads. Apply the same pattern used for cost digests: - Make per-cycle squash digests ephemeral (not exported to JSONL) - Add 'gt patrol digest' command to aggregate into daily summary - Add patrol-digest step to deacon patrol formula Daily cadence reduces noise while preserving observability. Closes: gt-nbmceh Co-Authored-By: Claude Opus 4.5 --- internal/cmd/molecule_lifecycle.go | 6 +- internal/cmd/patrol.go | 334 ++++++++++++++++++ .../formulas/mol-deacon-patrol.formula.toml | 39 +- 3 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/patrol.go diff --git a/internal/cmd/molecule_lifecycle.go b/internal/cmd/molecule_lifecycle.go index 92a38c44..7179a31c 100644 --- a/internal/cmd/molecule_lifecycle.go +++ b/internal/cmd/molecule_lifecycle.go @@ -215,13 +215,15 @@ squashed_at: %s }()) } - // Create the digest bead + // Create the digest bead (ephemeral to avoid JSONL pollution) + // Per-cycle digests are aggregated daily by 'gt patrol digest' digestIssue, err := b.Create(beads.CreateOptions{ Title: digestTitle, Description: digestDesc, Type: "task", - Priority: 4, // P4 - backlog priority for digests + Priority: 4, // P4 - backlog priority for digests Actor: target, + Ephemeral: true, // Don't export to JSONL - daily aggregation handles permanent record }) if err != nil { return fmt.Errorf("creating digest: %w", err) diff --git a/internal/cmd/patrol.go b/internal/cmd/patrol.go new file mode 100644 index 00000000..3885ad4c --- /dev/null +++ b/internal/cmd/patrol.go @@ -0,0 +1,334 @@ +// Package cmd provides CLI commands for the gt tool. +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/style" +) + +var ( + // Patrol digest flags + patrolDigestYesterday bool + patrolDigestDate string + patrolDigestDryRun bool + patrolDigestVerbose bool +) + +var patrolCmd = &cobra.Command{ + Use: "patrol", + GroupID: GroupDiag, + Short: "Patrol digest management", + Long: `Manage patrol cycle digests. + +Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests +to avoid JSONL pollution. This command aggregates them into daily summaries. + +Examples: + gt patrol digest --yesterday # Aggregate yesterday's patrol digests + gt patrol digest --dry-run # Preview what would be aggregated`, +} + +var patrolDigestCmd = &cobra.Command{ + Use: "digest", + Short: "Aggregate patrol cycle digests into a daily summary bead", + Long: `Aggregate ephemeral patrol cycle digests into a permanent daily summary. + +This command is intended to be run by Deacon patrol (daily) or manually. +It queries patrol digests for a target date, creates a single aggregate +"Patrol Report YYYY-MM-DD" bead, then deletes the source digests. + +The resulting digest bead is permanent (exported to JSONL, synced via git) +and provides an audit trail without per-cycle pollution. + +Examples: + gt patrol digest --yesterday # Digest yesterday's patrols (for daily patrol) + gt patrol digest --date 2026-01-15 + gt patrol digest --yesterday --dry-run`, + RunE: runPatrolDigest, +} + +func init() { + patrolCmd.AddCommand(patrolDigestCmd) + rootCmd.AddCommand(patrolCmd) + + // Patrol digest flags + patrolDigestCmd.Flags().BoolVar(&patrolDigestYesterday, "yesterday", false, "Digest yesterday's patrol cycles") + patrolDigestCmd.Flags().StringVar(&patrolDigestDate, "date", "", "Digest patrol cycles for specific date (YYYY-MM-DD)") + patrolDigestCmd.Flags().BoolVar(&patrolDigestDryRun, "dry-run", false, "Preview what would be created without creating") + patrolDigestCmd.Flags().BoolVarP(&patrolDigestVerbose, "verbose", "v", false, "Verbose output") +} + +// PatrolDigest represents the aggregated daily patrol report. +type PatrolDigest struct { + Date string `json:"date"` + TotalCycles int `json:"total_cycles"` + ByRole map[string]int `json:"by_role"` // deacon, witness, refinery + Cycles []PatrolCycleEntry `json:"cycles"` +} + +// PatrolCycleEntry represents a single patrol cycle in the digest. +type PatrolCycleEntry struct { + ID string `json:"id"` + Role string `json:"role"` // deacon, witness, refinery + Title string `json:"title"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + ClosedAt time.Time `json:"closed_at,omitempty"` +} + +// runPatrolDigest aggregates patrol cycle digests into a daily digest bead. +func runPatrolDigest(cmd *cobra.Command, args []string) error { + // Determine target date + var targetDate time.Time + + if patrolDigestDate != "" { + parsed, err := time.Parse("2006-01-02", patrolDigestDate) + if err != nil { + return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err) + } + targetDate = parsed + } else if patrolDigestYesterday { + 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 patrol digest beads for target date + cycles, err := queryPatrolDigests(targetDate) + if err != nil { + return fmt.Errorf("querying patrol digests: %w", err) + } + + if len(cycles) == 0 { + fmt.Printf("%s No patrol digests found for %s\n", style.Dim.Render("○"), dateStr) + return nil + } + + // Build digest + digest := PatrolDigest{ + Date: dateStr, + Cycles: cycles, + ByRole: make(map[string]int), + } + + for _, c := range cycles { + digest.TotalCycles++ + digest.ByRole[c.Role]++ + } + + if patrolDigestDryRun { + fmt.Printf("%s [DRY RUN] Would create Patrol Report %s:\n", style.Bold.Render("📊"), dateStr) + fmt.Printf(" Total cycles: %d\n", digest.TotalCycles) + fmt.Printf(" By Role:\n") + roles := make([]string, 0, len(digest.ByRole)) + for role := range digest.ByRole { + roles = append(roles, role) + } + sort.Strings(roles) + for _, role := range roles { + fmt.Printf(" %s: %d cycles\n", role, digest.ByRole[role]) + } + return nil + } + + // Create permanent digest bead + digestID, err := createPatrolDigestBead(digest) + if err != nil { + return fmt.Errorf("creating digest bead: %w", err) + } + + // Delete source digests (they're ephemeral) + deletedCount, deleteErr := deletePatrolDigests(targetDate) + if deleteErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to delete some source digests: %v\n", deleteErr) + } + + fmt.Printf("%s Created Patrol Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID) + fmt.Printf(" Total: %d cycles\n", digest.TotalCycles) + for role, count := range digest.ByRole { + fmt.Printf(" %s: %d\n", role, count) + } + if deletedCount > 0 { + fmt.Printf(" Deleted %d source digests\n", deletedCount) + } + + return nil +} + +// queryPatrolDigests queries ephemeral patrol digest beads for a target date. +func queryPatrolDigests(targetDate time.Time) ([]PatrolCycleEntry, error) { + // List closed issues with "digest" label that are ephemeral + // Patrol digests have titles like "Digest: mol-deacon-patrol", "Digest: mol-witness-patrol" + listCmd := exec.Command("bd", "list", + "--status=closed", + "--label=digest", + "--json", + "--limit=0", // Get all + ) + listOutput, err := listCmd.Output() + if err != nil { + if patrolDigestVerbose { + fmt.Fprintf(os.Stderr, "[patrol] bd list failed: %v\n", err) + } + return nil, nil + } + + var issues []struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ClosedAt time.Time `json:"closed_at"` + Ephemeral bool `json:"ephemeral"` + } + + if err := json.Unmarshal(listOutput, &issues); err != nil { + return nil, fmt.Errorf("parsing issue list: %w", err) + } + + targetDay := targetDate.Format("2006-01-02") + var patrolDigests []PatrolCycleEntry + + for _, issue := range issues { + // Only process ephemeral patrol digests + if !issue.Ephemeral { + continue + } + + // Must be a patrol digest (title starts with "Digest: mol-") + if !strings.HasPrefix(issue.Title, "Digest: mol-") { + continue + } + + // Check if created on target date + if issue.CreatedAt.Format("2006-01-02") != targetDay { + continue + } + + // Extract role from title (e.g., "Digest: mol-deacon-patrol" -> "deacon") + role := extractPatrolRole(issue.Title) + + patrolDigests = append(patrolDigests, PatrolCycleEntry{ + ID: issue.ID, + Role: role, + Title: issue.Title, + Description: issue.Description, + CreatedAt: issue.CreatedAt, + ClosedAt: issue.ClosedAt, + }) + } + + return patrolDigests, nil +} + +// extractPatrolRole extracts the role from a patrol digest title. +// "Digest: mol-deacon-patrol" -> "deacon" +// "Digest: mol-witness-patrol" -> "witness" +// "Digest: gt-wisp-abc123" -> "unknown" +func extractPatrolRole(title string) string { + // Remove "Digest: " prefix + title = strings.TrimPrefix(title, "Digest: ") + + // Extract role from "mol--patrol" or "gt-wisp-" + if strings.HasPrefix(title, "mol-") && strings.HasSuffix(title, "-patrol") { + // "mol-deacon-patrol" -> "deacon" + role := strings.TrimPrefix(title, "mol-") + role = strings.TrimSuffix(role, "-patrol") + return role + } + + // For wisp digests, try to extract from description or return generic + return "patrol" +} + +// createPatrolDigestBead creates a permanent bead for the daily patrol digest. +func createPatrolDigestBead(digest PatrolDigest) (string, error) { + // Build description with aggregate data + var desc strings.Builder + desc.WriteString(fmt.Sprintf("Daily patrol aggregate for %s.\n\n", digest.Date)) + desc.WriteString(fmt.Sprintf("**Total Cycles:** %d\n\n", digest.TotalCycles)) + + if len(digest.ByRole) > 0 { + desc.WriteString("## By Role\n") + roles := make([]string, 0, len(digest.ByRole)) + for role := range digest.ByRole { + roles = append(roles, role) + } + sort.Strings(roles) + for _, role := range roles { + desc.WriteString(fmt.Sprintf("- %s: %d cycles\n", role, digest.ByRole[role])) + } + desc.WriteString("\n") + } + + // Build payload JSON with cycle 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("Patrol Report %s", digest.Date) + bdArgs := []string{ + "create", + "--type=event", + "--title=" + title, + "--event-category=patrol.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 patrol digest") + _ = closeCmd.Run() // Best effort + + return digestID, nil +} + +// deletePatrolDigests deletes ephemeral patrol digest beads for a target date. +func deletePatrolDigests(targetDate time.Time) (int, error) { + // Query patrol digests for the target date + cycles, err := queryPatrolDigests(targetDate) + if err != nil { + return 0, err + } + + if len(cycles) == 0 { + return 0, nil + } + + // Collect IDs to delete + var idsToDelete []string + for _, cycle := range cycles { + idsToDelete = append(idsToDelete, cycle.ID) + } + + // Delete in batch + deleteArgs := append([]string{"delete", "--force"}, idsToDelete...) + deleteCmd := exec.Command("bd", deleteArgs...) + if err := deleteCmd.Run(); err != nil { + return 0, fmt.Errorf("deleting patrol digests: %w", err) + } + + return len(idsToDelete), nil +} diff --git a/internal/formula/formulas/mol-deacon-patrol.formula.toml b/internal/formula/formulas/mol-deacon-patrol.formula.toml index de62749c..8f166aa6 100644 --- a/internal/formula/formulas/mol-deacon-patrol.formula.toml +++ b/internal/formula/formulas/mol-deacon-patrol.formula.toml @@ -665,10 +665,47 @@ we don't try to digest today's incomplete data. **Exit criteria:** Yesterday's costs digested (or no wisps to digest).""" +[[steps]] +id = "patrol-digest" +title = "Aggregate daily patrol digests" +needs = ["costs-digest"] +description = """ +**DAILY DIGEST** - Aggregate yesterday's patrol cycle digests. + +Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests +to avoid JSONL pollution. This step aggregates them into a single permanent +"Patrol Report YYYY-MM-DD" bead for audit purposes. + +**Step 1: Check if digest is needed** +```bash +# Preview yesterday's patrol digests (dry run) +gt patrol digest --yesterday --dry-run +``` + +If output shows "No patrol digests found", skip to Step 3. + +**Step 2: Create the digest** +```bash +gt patrol digest --yesterday +``` + +This: +- Queries all ephemeral patrol digests from yesterday +- Creates a single "Patrol Report YYYY-MM-DD" bead with aggregated data +- Deletes the source digests + +**Step 3: Verify** +Daily patrol digests preserve audit trail without per-cycle 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 patrol digests aggregated (or none to aggregate).""" + [[steps]] id = "log-maintenance" title = "Rotate logs and prune state" -needs = ["costs-digest"] +needs = ["patrol-digest"] description = """ Maintain daemon logs and state files.