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:
gastown/crew/jack
2026-01-17 02:11:12 -08:00
committed by Steve Yegge
parent 6d29f34cd0
commit d6a4bc22fd
3 changed files with 376 additions and 3 deletions

View File

@@ -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
View 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
}

View File

@@ -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.