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 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
6d29f34cd0
commit
d6a4bc22fd
@@ -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)
|
||||
|
||||
334
internal/cmd/patrol.go
Normal file
334
internal/cmd/patrol.go
Normal file
@@ -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-<role>-patrol" or "gt-wisp-<id>"
|
||||
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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user