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/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)

View File

@@ -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")
}
}