From 0545d596c38c4b79a31a995d3221d6f80ff24bdb Mon Sep 17 00:00:00 2001 From: zoe Date: Thu, 15 Jan 2026 22:51:08 -0500 Subject: [PATCH] fix(ready): filter formula scaffolds from gt ready output (gt-579) Formula scaffold beads (created when formulas are installed) were appearing as actionable work items in `gt ready`. These are template beads, not actual work. Add filtering to exclude issues whose ID: - Matches a formula name exactly (e.g., "mol-deacon-patrol") - Starts with "." (step scaffolds like "mol-deacon-patrol.inbox-check") The fix reads the formulas directory to get installed formula names and filters issues accordingly for both town and rig beads. Fixes: gt-579 Co-Authored-By: Claude Opus 4.5 --- internal/cmd/ready.go | 82 ++++++++++++++------ internal/cmd/ready_test.go | 150 +++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 internal/cmd/ready_test.go diff --git a/internal/cmd/ready.go b/internal/cmd/ready.go index 9aead284..b168f3d5 100644 --- a/internal/cmd/ready.go +++ b/internal/cmd/ready.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sort" "strings" "sync" @@ -72,13 +73,6 @@ type ReadySummary struct { P4Count int `json:"p4_count"` } -// isFormulaScaffold returns true if the issue ID looks like a formula scaffold. -// Formula scaffolds are templates created when formulas are installed, not actual work. -// Pattern: "mol-" or "mol-." -func isFormulaScaffold(id string) bool { - return strings.HasPrefix(id, "mol-") -} - func runReady(cmd *cobra.Command, args []string) error { // Find town root townRoot, err := workspace.FindFromCwdOrError() @@ -136,14 +130,9 @@ func runReady(cmd *cobra.Command, args []string) error { if err != nil { src.Error = err.Error() } else { - // Filter out formula scaffolds - var filtered []*beads.Issue - for _, issue := range issues { - if !isFormulaScaffold(issue.ID) { - filtered = append(filtered, issue) - } - } - src.Issues = filtered + // Filter out formula scaffolds (gt-579) + formulaNames := getFormulaNames(townBeadsPath) + src.Issues = filterFormulaScaffolds(issues, formulaNames) } sources = append(sources, src) }() @@ -165,14 +154,9 @@ func runReady(cmd *cobra.Command, args []string) error { if err != nil { src.Error = err.Error() } else { - // Filter out formula scaffolds - var filtered []*beads.Issue - for _, issue := range issues { - if !isFormulaScaffold(issue.ID) { - filtered = append(filtered, issue) - } - } - src.Issues = filtered + // Filter out formula scaffolds (gt-579) + formulaNames := getFormulaNames(rigBeadsPath) + src.Issues = filterFormulaScaffolds(issues, formulaNames) } sources = append(sources, src) }(r) @@ -310,3 +294,55 @@ func printReadyHuman(result ReadyResult) error { return nil } + +// getFormulaNames reads the formulas directory and returns a set of formula names. +// Formula names are derived from filenames by removing the ".formula.toml" suffix. +func getFormulaNames(beadsPath string) map[string]bool { + formulasDir := filepath.Join(beadsPath, "formulas") + entries, err := os.ReadDir(formulasDir) + if err != nil { + return nil + } + + names := make(map[string]bool) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".formula.toml") { + // Remove suffix to get formula name + formulaName := strings.TrimSuffix(name, ".formula.toml") + names[formulaName] = true + } + } + return names +} + +// filterFormulaScaffolds removes formula scaffold issues from the list. +// Formula scaffolds are issues whose ID matches a formula name exactly +// or starts with "." (step scaffolds). +func filterFormulaScaffolds(issues []*beads.Issue, formulaNames map[string]bool) []*beads.Issue { + if formulaNames == nil || len(formulaNames) == 0 { + return issues + } + + filtered := make([]*beads.Issue, 0, len(issues)) + for _, issue := range issues { + // Check if this is a formula scaffold (exact match) + if formulaNames[issue.ID] { + continue + } + + // Check if this is a step scaffold (formula-name.step-id) + if idx := strings.Index(issue.ID, "."); idx > 0 { + prefix := issue.ID[:idx] + if formulaNames[prefix] { + continue + } + } + + filtered = append(filtered, issue) + } + return filtered +} diff --git a/internal/cmd/ready_test.go b/internal/cmd/ready_test.go new file mode 100644 index 00000000..a94804cc --- /dev/null +++ b/internal/cmd/ready_test.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/gastown/internal/beads" +) + +func TestGetFormulaNames(t *testing.T) { + // Create temp directory structure + tmpDir := t.TempDir() + formulasDir := filepath.Join(tmpDir, "formulas") + if err := os.MkdirAll(formulasDir, 0755); err != nil { + t.Fatalf("creating formulas dir: %v", err) + } + + // Create some formula files + formulas := []string{ + "mol-deacon-patrol.formula.toml", + "mol-witness-patrol.formula.toml", + "shiny.formula.toml", + } + for _, f := range formulas { + path := filepath.Join(formulasDir, f) + if err := os.WriteFile(path, []byte("# test"), 0644); err != nil { + t.Fatalf("writing %s: %v", f, err) + } + } + + // Also create a non-formula file (should be ignored) + if err := os.WriteFile(filepath.Join(formulasDir, ".installed.json"), []byte("{}"), 0644); err != nil { + t.Fatalf("writing .installed.json: %v", err) + } + + // Test + names := getFormulaNames(tmpDir) + if names == nil { + t.Fatal("getFormulaNames returned nil") + } + + expected := []string{"mol-deacon-patrol", "mol-witness-patrol", "shiny"} + for _, name := range expected { + if !names[name] { + t.Errorf("expected formula name %q not found", name) + } + } + + // Should not include the .installed.json file + if names[".installed"] { + t.Error(".installed should not be in formula names") + } + + if len(names) != len(expected) { + t.Errorf("got %d formula names, want %d", len(names), len(expected)) + } +} + +func TestGetFormulaNames_NonexistentDir(t *testing.T) { + names := getFormulaNames("/nonexistent/path") + if names != nil { + t.Error("expected nil for nonexistent directory") + } +} + +func TestFilterFormulaScaffolds(t *testing.T) { + formulaNames := map[string]bool{ + "mol-deacon-patrol": true, + "mol-witness-patrol": true, + } + + issues := []*beads.Issue{ + {ID: "mol-deacon-patrol", Title: "mol-deacon-patrol"}, + {ID: "mol-deacon-patrol.inbox-check", Title: "Handle callbacks"}, + {ID: "mol-deacon-patrol.health-scan", Title: "Check health"}, + {ID: "mol-witness-patrol", Title: "mol-witness-patrol"}, + {ID: "mol-witness-patrol.loop-or-exit", Title: "Loop or exit"}, + {ID: "hq-123", Title: "Real work item"}, + {ID: "hq-wisp-abc", Title: "Actual wisp"}, + {ID: "gt-456", Title: "Project issue"}, + } + + filtered := filterFormulaScaffolds(issues, formulaNames) + + // Should only have the non-scaffold issues + if len(filtered) != 3 { + t.Errorf("got %d filtered issues, want 3", len(filtered)) + } + + expectedIDs := map[string]bool{ + "hq-123": true, + "hq-wisp-abc": true, + "gt-456": true, + } + for _, issue := range filtered { + if !expectedIDs[issue.ID] { + t.Errorf("unexpected issue in filtered result: %s", issue.ID) + } + } +} + +func TestFilterFormulaScaffolds_NilFormulaNames(t *testing.T) { + issues := []*beads.Issue{ + {ID: "hq-123", Title: "Real work"}, + {ID: "mol-deacon-patrol", Title: "Would be filtered"}, + } + + // With nil formula names, should return all issues unchanged + filtered := filterFormulaScaffolds(issues, nil) + if len(filtered) != len(issues) { + t.Errorf("got %d issues, want %d (nil formulaNames should return all)", len(filtered), len(issues)) + } +} + +func TestFilterFormulaScaffolds_EmptyFormulaNames(t *testing.T) { + issues := []*beads.Issue{ + {ID: "hq-123", Title: "Real work"}, + {ID: "mol-deacon-patrol", Title: "Would be filtered"}, + } + + // With empty formula names, should return all issues unchanged + filtered := filterFormulaScaffolds(issues, map[string]bool{}) + if len(filtered) != len(issues) { + t.Errorf("got %d issues, want %d (empty formulaNames should return all)", len(filtered), len(issues)) + } +} + +func TestFilterFormulaScaffolds_EmptyIssues(t *testing.T) { + formulaNames := map[string]bool{"mol-deacon-patrol": true} + filtered := filterFormulaScaffolds([]*beads.Issue{}, formulaNames) + if len(filtered) != 0 { + t.Errorf("got %d issues, want 0", len(filtered)) + } +} + +func TestFilterFormulaScaffolds_DotInNonScaffold(t *testing.T) { + // Issue ID has a dot but prefix is not a formula name + formulaNames := map[string]bool{"mol-deacon-patrol": true} + + issues := []*beads.Issue{ + {ID: "hq-cv.synthesis-step", Title: "Convoy synthesis"}, + {ID: "some.other.thing", Title: "Random dotted ID"}, + } + + filtered := filterFormulaScaffolds(issues, formulaNames) + if len(filtered) != 2 { + t.Errorf("got %d issues, want 2 (non-formula dots should not filter)", len(filtered)) + } +}