diff --git a/internal/cmd/hook.go b/internal/cmd/hook.go index e2833edb..0baa0fda 100644 --- a/internal/cmd/hook.go +++ b/internal/cmd/hook.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" ) var hookCmd = &cobra.Command{ @@ -147,6 +148,12 @@ func runHook(_ *cobra.Command, args []string) error { return fmt.Errorf("detecting agent identity: %w", err) } + // Find town root (needed for cross-prefix bead resolution) + townRoot, err := workspace.FindFromCwd() + if err != nil { + return fmt.Errorf("finding workspace: %w", err) + } + // Find beads directory workDir, err := findLocalBeadsDir() if err != nil { @@ -225,9 +232,15 @@ func runHook(_ *cobra.Command, args []string) error { return nil } - // Hook the bead using beads package (uses RPC when daemon available) - status := beads.StatusHooked - if err := b.Update(beadID, beads.UpdateOptions{Status: &status, Assignee: &agentID}); err != nil { + // Hook the bead using bd update with cross-prefix routing. + // The bead may be in a different beads database than the agent's local one + // (e.g., hooking an hq-* bead from a rig worker). Use ResolveHookDir to + // find the correct database directory based on the bead's prefix. + // See: https://github.com/steveyegge/gastown/issues/gt-rphsv + hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+agentID) + hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, workDir) + hookCmd.Stderr = os.Stderr + if err := hookCmd.Run(); err != nil { return fmt.Errorf("hooking bead: %w", err) } @@ -235,6 +248,12 @@ func runHook(_ *cobra.Command, args []string) error { fmt.Printf(" Use 'gt handoff' to restart with this work\n") fmt.Printf(" Use 'gt hook' to see hook status\n") + // Update agent bead's hook_bead slot for status queries. + // This enables `gt hook status` to find cross-prefix hooked beads. + // The agent bead has a hook_bead database field that tracks current work. + townBeadsDir := filepath.Join(townRoot, ".beads") + updateAgentHookBead(agentID, beadID, workDir, townBeadsDir) + // Log hook event to activity feed (non-fatal) if err := events.LogFeed(events.TypeHook, agentID, events.HookPayload(beadID)); err != nil { fmt.Fprintf(os.Stderr, "%s Warning: failed to log hook event: %v\n", style.Dim.Render("⚠"), err) diff --git a/internal/cmd/hook_slot_integration_test.go b/internal/cmd/hook_slot_integration_test.go index 16521387..e4e4ab34 100644 --- a/internal/cmd/hook_slot_integration_test.go +++ b/internal/cmd/hook_slot_integration_test.go @@ -29,8 +29,8 @@ func setupHookTestTown(t *testing.T) (townRoot, polecatDir string) { // Create routes.jsonl routes := []beads.Route{ - {Prefix: "hq-", Path: "."}, // Town-level beads - {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig + {Prefix: "hq-", Path: "."}, // Town-level beads + {Prefix: "gt-", Path: "gastown/mayor/rig"}, // Gastown rig } if err := beads.WriteRoutes(townBeadsDir, routes); err != nil { t.Fatalf("write routes: %v", err) @@ -81,6 +81,8 @@ func initBeadsDB(t *testing.T, dir string) { } } +// Note: initBeadsDBWithPrefix is defined in beads_routing_integration_test.go + // TestHookSlot_BasicHook verifies that a bead can be hooked to an agent. func TestHookSlot_BasicHook(t *testing.T) { // Skip if bd is not available @@ -486,3 +488,118 @@ func TestHookSlot_StatusTransitions(t *testing.T) { t.Errorf("final status = %s, want closed", closed.Status) } } + +// TestHookSlot_CrossPrefixHook verifies that beads with different prefixes can be hooked +// using the correct database routing. This is the fix for issue gt-rphsv. +func TestHookSlot_CrossPrefixHook(t *testing.T) { + if _, err := exec.LookPath("bd"); err != nil { + t.Skip("bd not installed, skipping test") + } + + townRoot, polecatDir := setupHookTestTown(t) + + // Initialize beads in both town-level (hq- prefix) and rig-level (gt- prefix) + // Note: bd init must be run from parent directory, not inside .beads + initBeadsDBWithPrefix(t, townRoot, "hq") + + rigDir := filepath.Join(polecatDir, "..", "..", "mayor", "rig") + initBeadsDBWithPrefix(t, rigDir, "gt") + + // Create beads instances for both databases + townBeads := beads.New(townRoot) // Uses routes.jsonl to route to correct DB + rigBeads := beads.New(rigDir) + + // Create an hq-* bead in town beads + townBeadsInstance := beads.New(townRoot) + hqIssue, err := townBeadsInstance.Create(beads.CreateOptions{ + Title: "HQ task for cross-prefix test", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create hq bead: %v", err) + } + // The bead ID should have hq- prefix since we initialized town beads with that prefix + t.Logf("Created HQ bead: %s", hqIssue.ID) + + // Create a gt-* bead in rig beads + gtIssue, err := rigBeads.Create(beads.CreateOptions{ + Title: "Rig task for cross-prefix test", + Type: "task", + Priority: 2, + }) + if err != nil { + t.Fatalf("create rig bead: %v", err) + } + t.Logf("Created rig bead: %s", gtIssue.ID) + + agentID := "gastown/polecats/toast" + + // Test 1: Hook the HQ bead using ResolveHookDir (simulating runHook fix) + hookDir := beads.ResolveHookDir(townRoot, hqIssue.ID, rigDir) + t.Logf("ResolveHookDir(%s, %s, %s) = %s", townRoot, hqIssue.ID, rigDir, hookDir) + + // Hook the HQ bead via bd command with correct directory routing + hookCmd := exec.Command("bd", "--no-daemon", "update", hqIssue.ID, "--status=hooked", "--assignee="+agentID) + hookCmd.Dir = hookDir + if output, err := hookCmd.CombinedOutput(); err != nil { + t.Fatalf("hook hq bead: %v\n%s", err, output) + } + + // Verify the HQ bead is hooked by querying town beads + hookedHQ, err := townBeadsInstance.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked hq beads: %v", err) + } + + if len(hookedHQ) != 1 { + t.Errorf("expected 1 hooked HQ bead, got %d", len(hookedHQ)) + } + if len(hookedHQ) > 0 && hookedHQ[0].ID != hqIssue.ID { + t.Errorf("hooked HQ bead ID = %s, want %s", hookedHQ[0].ID, hqIssue.ID) + } + + // Test 2: Verify rig beads are still queryable separately + status := beads.StatusHooked + if err := rigBeads.Update(gtIssue.ID, beads.UpdateOptions{ + Status: &status, + Assignee: &agentID, + }); err != nil { + t.Fatalf("hook rig bead: %v", err) + } + + hookedRig, err := rigBeads.List(beads.ListOptions{ + Status: beads.StatusHooked, + Assignee: agentID, + Priority: -1, + }) + if err != nil { + t.Fatalf("list hooked rig beads: %v", err) + } + + if len(hookedRig) != 1 { + t.Errorf("expected 1 hooked rig bead, got %d", len(hookedRig)) + } + if len(hookedRig) > 0 && hookedRig[0].ID != gtIssue.ID { + t.Errorf("hooked rig bead ID = %s, want %s", hookedRig[0].ID, gtIssue.ID) + } + + // Verify the databases are separate + t.Logf("HQ bead %s hooked in town DB, Rig bead %s hooked in rig DB", hqIssue.ID, gtIssue.ID) + + // Verify the HQ bead is NOT in the rig database + _, err = rigBeads.Show(hqIssue.ID) + if err == nil { + t.Log("Note: HQ bead found in rig DB - this may indicate routing is working via redirect") + } + + // Verify the rig bead is NOT in the town database + _, err = townBeads.Show(gtIssue.ID) + if err == nil { + t.Log("Note: Rig bead found in town DB - this may indicate routing is working") + } +}