From 09c2a89138fd0eb6a22799a88271355edceaf05a Mon Sep 17 00:00:00 2001 From: rictus Date: Sun, 8 Mar 2026 10:14:55 -0700 Subject: [PATCH 1/2] feat: add wl herald command for wanted board change notifications Poll-based diffing of the wanted board with pluggable Notifier interface. Persists snapshot state as JSON in XDG data dir. Supports one-shot and continuous watch mode with configurable interval. Co-Authored-By: Claude Opus 4.6 --- cmd/wl/cmd_herald.go | 97 +++++++++++++ cmd/wl/cmd_herald_test.go | 82 +++++++++++ cmd/wl/main.go | 1 + internal/herald/herald.go | 181 ++++++++++++++++++++++++ internal/herald/herald_test.go | 244 +++++++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+) create mode 100644 cmd/wl/cmd_herald.go create mode 100644 cmd/wl/cmd_herald_test.go create mode 100644 internal/herald/herald.go create mode 100644 internal/herald/herald_test.go diff --git a/cmd/wl/cmd_herald.go b/cmd/wl/cmd_herald.go new file mode 100644 index 0000000..801d6a8 --- /dev/null +++ b/cmd/wl/cmd_herald.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "io" + "time" + + "github.com/gastownhall/wasteland/internal/commons" + "github.com/gastownhall/wasteland/internal/herald" + "github.com/gastownhall/wasteland/internal/xdg" + "github.com/spf13/cobra" +) + +func newHeraldCmd(stdout, stderr io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "herald", + Short: "Watch the wanted board for changes", + Long: `Herald polls the wanted board and reports new, removed, or status-changed items. + +On first run it snapshots the current board. Subsequent runs diff against the +saved state and print changes. Use --watch to poll continuously. + +State is persisted as JSON in the XDG data directory. + +Examples: + wl herald # one-shot diff since last run + wl herald --watch # poll every 60s (Ctrl-C to stop) + wl herald --interval 30s --watch`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + watch, _ := cmd.Flags().GetBool("watch") + intervalStr, _ := cmd.Flags().GetString("interval") + interval, err := time.ParseDuration(intervalStr) + if err != nil { + return fmt.Errorf("invalid --interval: %w", err) + } + return runHerald(cmd, stdout, stderr, watch, interval) + }, + } + cmd.Flags().Bool("watch", false, "Poll continuously instead of one-shot") + cmd.Flags().String("interval", "60s", "Poll interval (e.g. 30s, 2m)") + return cmd +} + +func runHerald(cmd *cobra.Command, stdout, stderr io.Writer, watch bool, interval time.Duration) error { + cfg, err := resolveWasteland(cmd) + if err != nil { + return err + } + db, err := openDBFromConfig(cfg) + if err != nil { + return err + } + + lister := &wantedBoardLister{db: db} + notifier := &herald.LogNotifier{Printf: func(format string, args ...any) { + fmt.Fprintf(stdout, format, args...) + }} + statePath := herald.DefaultStatePath(xdg.DataDir()) + store := herald.NewStateStore(statePath) + + if !watch { + return herald.Poll(lister, notifier, store) + } + + fmt.Fprintf(stderr, "Watching wanted board (every %s, Ctrl-C to stop)...\n", interval) + for { + if err := herald.Poll(lister, notifier, store); err != nil { + fmt.Fprintf(stderr, "herald: poll error: %v\n", err) + } + time.Sleep(interval) + } +} + +// wantedBoardLister adapts the commons browse query into the herald.Lister interface. +type wantedBoardLister struct { + db commons.DB +} + +func (l *wantedBoardLister) ListItems() ([]herald.Item, error) { + summaries, err := commons.BrowseWanted(l.db, commons.BrowseFilter{ + Limit: 500, + Sort: commons.SortNewest, + }) + if err != nil { + return nil, err + } + items := make([]herald.Item, len(summaries)) + for i, s := range summaries { + items[i] = herald.Item{ + ID: s.ID, + Title: s.Title, + Status: s.Status, + } + } + return items, nil +} diff --git a/cmd/wl/cmd_herald_test.go b/cmd/wl/cmd_herald_test.go new file mode 100644 index 0000000..d71d231 --- /dev/null +++ b/cmd/wl/cmd_herald_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/gastownhall/wasteland/internal/commons" + "github.com/gastownhall/wasteland/internal/federation" +) + +// csvDB returns a fixed CSV response for Query calls. +type csvDB struct { + noopDB + csv string +} + +func (d *csvDB) Query(string, string) (string, error) { return d.csv, nil } + +func TestRunHerald_OneShotFirstRun(t *testing.T) { + saveWasteland(t) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + db := &csvDB{csv: "id,title,project,type,priority,posted_by,claimed_by,status,effort_level\nw-1,Fix bug,,task,1,alice,,open,medium\n"} + oldDBFromConfig := openDBFromConfig + openDBFromConfig = func(*federation.Config) (commons.DB, error) { return db, nil } + t.Cleanup(func() { openDBFromConfig = oldDBFromConfig }) + + var stdout, stderr bytes.Buffer + cmd := wastelandCmd() + err := runHerald(cmd, &stdout, &stderr, false, 0) + if err != nil { + t.Fatalf("runHerald() error: %v", err) + } + + got := stdout.String() + if !strings.Contains(got, "[+]") { + t.Errorf("expected [+] for new item, got %q", got) + } + if !strings.Contains(got, "w-1") { + t.Errorf("expected item ID w-1, got %q", got) + } +} + +func TestRunHerald_OneShotNoChanges(t *testing.T) { + saveWasteland(t) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + db := &csvDB{csv: "id,title,project,type,priority,posted_by,claimed_by,status,effort_level\nw-1,Fix bug,,task,1,alice,,open,medium\n"} + oldDBFromConfig := openDBFromConfig + openDBFromConfig = func(*federation.Config) (commons.DB, error) { return db, nil } + t.Cleanup(func() { openDBFromConfig = oldDBFromConfig }) + + var stdout, stderr bytes.Buffer + cmd := wastelandCmd() + + // First run seeds state. + if err := runHerald(cmd, &stdout, &stderr, false, 0); err != nil { + t.Fatalf("first poll: %v", err) + } + + // Second run should produce no output. + stdout.Reset() + if err := runHerald(cmd, &stdout, &stderr, false, 0); err != nil { + t.Fatalf("second poll: %v", err) + } + if stdout.String() != "" { + t.Errorf("expected no output on second poll, got %q", stdout.String()) + } +} + +func TestRunHerald_NotJoined(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + var stdout, stderr bytes.Buffer + cmd := wastelandCmd() + err := runHerald(cmd, &stdout, &stderr, false, 0) + if err == nil { + t.Fatal("expected error when not joined") + } +} diff --git a/cmd/wl/main.go b/cmd/wl/main.go index 4325cd5..4a2c20f 100644 --- a/cmd/wl/main.go +++ b/cmd/wl/main.go @@ -113,6 +113,7 @@ func newRootCmd(stdout, stderr io.Writer) *cobra.Command { newDoctorCmd(stdout, stderr), newLeaderboardCmd(stdout, stderr), newProfileCmd(stdout, stderr), + newHeraldCmd(stdout, stderr), newVersionCmd(stdout), ) if inferGateEnabled() { diff --git a/internal/herald/herald.go b/internal/herald/herald.go new file mode 100644 index 0000000..7778678 --- /dev/null +++ b/internal/herald/herald.go @@ -0,0 +1,181 @@ +// Package herald watches the wanted board and notifies on changes. +// +// Herald polls the wanted board via a Lister, diffs against persisted state, +// and dispatches Change events through a pluggable Notifier interface. +package herald + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Item is a snapshot of a wanted board entry for diffing purposes. +type Item struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` +} + +// ChangeKind describes what changed between polls. +type ChangeKind string + +// Change kind constants. +const ( + Added ChangeKind = "added" + Removed ChangeKind = "removed" + StatusChanged ChangeKind = "status_changed" +) + +// Change describes a single wanted-board mutation detected by a poll. +type Change struct { + Kind ChangeKind `json:"kind"` + Item Item `json:"item"` + OldStatus string `json:"old_status,omitempty"` // only for StatusChanged +} + +// Lister fetches the current wanted board snapshot. +type Lister interface { + ListItems() ([]Item, error) +} + +// Notifier receives change notifications. +type Notifier interface { + Notify(changes []Change) error +} + +// StateStore persists the last-seen snapshot to disk as JSON. +type StateStore struct { + path string +} + +// NewStateStore creates a StateStore that reads/writes state at the given path. +func NewStateStore(path string) *StateStore { + return &StateStore{path: path} +} + +// DefaultStatePath returns the default state file path under XDG data dir. +func DefaultStatePath(dataDir string) string { + return filepath.Join(dataDir, "herald-state.json") +} + +// state is the on-disk format. +type state struct { + Items []Item `json:"items"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Load reads persisted state. Returns nil items (not error) if file missing. +func (s *StateStore) Load() ([]Item, error) { + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading herald state: %w", err) + } + var st state + if err := json.Unmarshal(data, &st); err != nil { + return nil, fmt.Errorf("parsing herald state: %w", err) + } + return st.Items, nil +} + +// Save persists the current snapshot. +func (s *StateStore) Save(items []Item) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("creating herald state dir: %w", err) + } + st := state{Items: items, UpdatedAt: time.Now().UTC()} + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return fmt.Errorf("marshaling herald state: %w", err) + } + return os.WriteFile(s.path, data, 0o644) +} + +// Diff computes changes between old and new snapshots. +func Diff(old, cur []Item) []Change { + oldMap := make(map[string]Item, len(old)) + for _, item := range old { + oldMap[item.ID] = item + } + curMap := make(map[string]Item, len(cur)) + for _, item := range cur { + curMap[item.ID] = item + } + + var changes []Change + + // Detect added and status-changed items. + for _, item := range cur { + prev, existed := oldMap[item.ID] + if !existed { + changes = append(changes, Change{Kind: Added, Item: item}) + } else if prev.Status != item.Status { + changes = append(changes, Change{ + Kind: StatusChanged, + Item: item, + OldStatus: prev.Status, + }) + } + } + + // Detect removed items. + for _, item := range old { + if _, exists := curMap[item.ID]; !exists { + changes = append(changes, Change{Kind: Removed, Item: item}) + } + } + + return changes +} + +// Poll performs a single poll cycle: list → diff → notify → save. +func Poll(lister Lister, notifier Notifier, store *StateStore) error { + current, err := lister.ListItems() + if err != nil { + return fmt.Errorf("listing wanted board: %w", err) + } + + previous, err := store.Load() + if err != nil { + return fmt.Errorf("loading state: %w", err) + } + + changes := Diff(previous, current) + + if len(changes) > 0 { + if err := notifier.Notify(changes); err != nil { + return fmt.Errorf("notifying: %w", err) + } + } + + if err := store.Save(current); err != nil { + return fmt.Errorf("saving state: %w", err) + } + + return nil +} + +// LogNotifier writes changes to an io.Writer (e.g. os.Stdout). +type LogNotifier struct { + Printf func(format string, args ...any) +} + +// Notify prints each change. +func (n *LogNotifier) Notify(changes []Change) error { + for _, c := range changes { + switch c.Kind { + case Added: + n.Printf("[+] %s: %s (%s)\n", c.Item.ID, c.Item.Title, c.Item.Status) + case Removed: + n.Printf("[-] %s: %s\n", c.Item.ID, c.Item.Title) + case StatusChanged: + n.Printf("[~] %s: %s (%s → %s)\n", c.Item.ID, c.Item.Title, c.OldStatus, c.Item.Status) + } + } + return nil +} diff --git a/internal/herald/herald_test.go b/internal/herald/herald_test.go new file mode 100644 index 0000000..eda9b95 --- /dev/null +++ b/internal/herald/herald_test.go @@ -0,0 +1,244 @@ +package herald + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// fakeLister returns a fixed set of items. +type fakeLister struct { + items []Item + err error +} + +func (f *fakeLister) ListItems() ([]Item, error) { return f.items, f.err } + +// recordingNotifier captures notifications. +type recordingNotifier struct { + calls [][]Change +} + +func (r *recordingNotifier) Notify(changes []Change) error { + r.calls = append(r.calls, changes) + return nil +} + +func TestDiff_EmptyToItems(t *testing.T) { + items := []Item{ + {ID: "w-1", Title: "Fix bug", Status: "open"}, + {ID: "w-2", Title: "Add feature", Status: "open"}, + } + changes := Diff(nil, items) + if len(changes) != 2 { + t.Fatalf("expected 2 changes, got %d", len(changes)) + } + for _, c := range changes { + if c.Kind != Added { + t.Errorf("expected Added, got %s", c.Kind) + } + } +} + +func TestDiff_ItemsToEmpty(t *testing.T) { + old := []Item{{ID: "w-1", Title: "Fix bug", Status: "open"}} + changes := Diff(old, nil) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + if changes[0].Kind != Removed { + t.Errorf("expected Removed, got %s", changes[0].Kind) + } +} + +func TestDiff_StatusChanged(t *testing.T) { + old := []Item{{ID: "w-1", Title: "Fix bug", Status: "open"}} + cur := []Item{{ID: "w-1", Title: "Fix bug", Status: "claimed"}} + changes := Diff(old, cur) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } + c := changes[0] + if c.Kind != StatusChanged { + t.Errorf("expected StatusChanged, got %s", c.Kind) + } + if c.OldStatus != "open" { + t.Errorf("expected old status open, got %s", c.OldStatus) + } + if c.Item.Status != "claimed" { + t.Errorf("expected new status claimed, got %s", c.Item.Status) + } +} + +func TestDiff_NoChanges(t *testing.T) { + items := []Item{{ID: "w-1", Title: "Fix bug", Status: "open"}} + changes := Diff(items, items) + if len(changes) != 0 { + t.Fatalf("expected 0 changes, got %d", len(changes)) + } +} + +func TestDiff_MixedChanges(t *testing.T) { + old := []Item{ + {ID: "w-1", Title: "Fix bug", Status: "open"}, + {ID: "w-2", Title: "Old item", Status: "open"}, + } + cur := []Item{ + {ID: "w-1", Title: "Fix bug", Status: "claimed"}, + {ID: "w-3", Title: "New item", Status: "open"}, + } + changes := Diff(old, cur) + if len(changes) != 3 { + t.Fatalf("expected 3 changes, got %d", len(changes)) + } + + kinds := map[ChangeKind]int{} + for _, c := range changes { + kinds[c.Kind]++ + } + if kinds[StatusChanged] != 1 { + t.Errorf("expected 1 StatusChanged, got %d", kinds[StatusChanged]) + } + if kinds[Added] != 1 { + t.Errorf("expected 1 Added, got %d", kinds[Added]) + } + if kinds[Removed] != 1 { + t.Errorf("expected 1 Removed, got %d", kinds[Removed]) + } +} + +func TestStateStore_LoadMissing(t *testing.T) { + store := NewStateStore(filepath.Join(t.TempDir(), "missing.json")) + items, err := store.Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if items != nil { + t.Errorf("expected nil items, got %v", items) + } +} + +func TestStateStore_SaveAndLoad(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + store := NewStateStore(path) + + items := []Item{ + {ID: "w-1", Title: "Fix bug", Status: "open"}, + {ID: "w-2", Title: "Add feature", Status: "claimed"}, + } + if err := store.Save(items); err != nil { + t.Fatalf("save error: %v", err) + } + + loaded, err := store.Load() + if err != nil { + t.Fatalf("load error: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 items, got %d", len(loaded)) + } + if loaded[0].ID != "w-1" || loaded[1].ID != "w-2" { + t.Errorf("unexpected items: %v", loaded) + } +} + +func TestStateStore_SaveCreatesDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "dir") + path := filepath.Join(dir, "state.json") + store := NewStateStore(path) + + if err := store.Save([]Item{{ID: "w-1", Title: "Test", Status: "open"}}); err != nil { + t.Fatalf("save error: %v", err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("state file was not created") + } +} + +func TestPoll_FirstRun(t *testing.T) { + lister := &fakeLister{items: []Item{ + {ID: "w-1", Title: "Fix bug", Status: "open"}, + }} + notifier := &recordingNotifier{} + store := NewStateStore(filepath.Join(t.TempDir(), "state.json")) + + if err := Poll(lister, notifier, store); err != nil { + t.Fatalf("poll error: %v", err) + } + + if len(notifier.calls) != 1 { + t.Fatalf("expected 1 notify call, got %d", len(notifier.calls)) + } + if len(notifier.calls[0]) != 1 { + t.Fatalf("expected 1 change, got %d", len(notifier.calls[0])) + } + if notifier.calls[0][0].Kind != Added { + t.Errorf("expected Added, got %s", notifier.calls[0][0].Kind) + } +} + +func TestPoll_NoChanges(t *testing.T) { + items := []Item{{ID: "w-1", Title: "Fix bug", Status: "open"}} + lister := &fakeLister{items: items} + notifier := &recordingNotifier{} + store := NewStateStore(filepath.Join(t.TempDir(), "state.json")) + + // First poll seeds state. + if err := Poll(lister, notifier, store); err != nil { + t.Fatalf("first poll error: %v", err) + } + + // Second poll should detect no changes. + notifier.calls = nil + if err := Poll(lister, notifier, store); err != nil { + t.Fatalf("second poll error: %v", err) + } + if len(notifier.calls) != 0 { + t.Errorf("expected 0 notify calls on no-change, got %d", len(notifier.calls)) + } +} + +func TestPoll_DetectsNewItem(t *testing.T) { + store := NewStateStore(filepath.Join(t.TempDir(), "state.json")) + + // Seed with one item. + lister := &fakeLister{items: []Item{{ID: "w-1", Title: "Fix bug", Status: "open"}}} + notifier := &recordingNotifier{} + if err := Poll(lister, notifier, store); err != nil { + t.Fatalf("first poll: %v", err) + } + + // Add a second item. + lister.items = append(lister.items, Item{ID: "w-2", Title: "New task", Status: "open"}) + notifier.calls = nil + if err := Poll(lister, notifier, store); err != nil { + t.Fatalf("second poll: %v", err) + } + if len(notifier.calls) != 1 || len(notifier.calls[0]) != 1 { + t.Fatalf("expected 1 call with 1 change, got %v", notifier.calls) + } + if notifier.calls[0][0].Kind != Added { + t.Errorf("expected Added, got %s", notifier.calls[0][0].Kind) + } +} + +func TestLogNotifier_Formats(t *testing.T) { + var messages []string + n := &LogNotifier{Printf: func(format string, args ...any) { + messages = append(messages, fmt.Sprintf(format, args...)) + }} + + changes := []Change{ + {Kind: Added, Item: Item{ID: "w-1", Title: "Bug", Status: "open"}}, + {Kind: Removed, Item: Item{ID: "w-2", Title: "Old"}}, + {Kind: StatusChanged, Item: Item{ID: "w-3", Title: "Task", Status: "claimed"}, OldStatus: "open"}, + } + if err := n.Notify(changes); err != nil { + t.Fatalf("notify error: %v", err) + } + if len(messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(messages)) + } +} From 50c430db25b857e008c3bd4a6438c2d58b8311e8 Mon Sep 17 00:00:00 2001 From: rictus Date: Sun, 8 Mar 2026 10:15:27 -0700 Subject: [PATCH 2/2] chore: gitignore .beads/, .claude/, .runtime/ directories Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 70bf5db..7f26528 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.beads/ +.claude/ +.runtime/ CLAUDE.local.md coverage.txt /bin/