fix(hook): enable cross-prefix bead hooking with database routing

The `gt hook` command was failing to persist hooks for beads from different
prefixes (e.g., hooking an hq-* bead from a rig worker). The issue was that
`runHook` used `b.Update()` which always writes to the local beads database,
regardless of the bead's prefix.

Changes:
- Use `beads.ResolveHookDir()` to determine the correct database directory
  for the bead being hooked, based on its prefix
- Execute `bd update` with the correct working directory for cross-prefix routing
- Call `updateAgentHookBead()` to set the agent's hook_bead slot, enabling
  `gt hook status` to find cross-prefix hooked beads
- Add integration test for cross-prefix hooking scenarios

This matches the pattern already used by `gt sling` for cross-prefix dispatch.

Fixes: gt-rphsv

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nux
2026-01-26 11:19:12 -08:00
committed by John Ogle
parent 5ab01f383a
commit c66dc4594c
2 changed files with 141 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
) )
var hookCmd = &cobra.Command{ var hookCmd = &cobra.Command{
@@ -147,6 +148,12 @@ func runHook(_ *cobra.Command, args []string) error {
return fmt.Errorf("detecting agent identity: %w", err) 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 // Find beads directory
workDir, err := findLocalBeadsDir() workDir, err := findLocalBeadsDir()
if err != nil { if err != nil {
@@ -225,9 +232,15 @@ func runHook(_ *cobra.Command, args []string) error {
return nil return nil
} }
// Hook the bead using beads package (uses RPC when daemon available) // Hook the bead using bd update with cross-prefix routing.
status := beads.StatusHooked // The bead may be in a different beads database than the agent's local one
if err := b.Update(beadID, beads.UpdateOptions{Status: &status, Assignee: &agentID}); err != nil { // (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) 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 handoff' to restart with this work\n")
fmt.Printf(" Use 'gt hook' to see hook status\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) // Log hook event to activity feed (non-fatal)
if err := events.LogFeed(events.TypeHook, agentID, events.HookPayload(beadID)); err != nil { 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) fmt.Fprintf(os.Stderr, "%s Warning: failed to log hook event: %v\n", style.Dim.Render("⚠"), err)

View File

@@ -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. // TestHookSlot_BasicHook verifies that a bead can be hooked to an agent.
func TestHookSlot_BasicHook(t *testing.T) { func TestHookSlot_BasicHook(t *testing.T) {
// Skip if bd is not available // 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) 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")
}
}