diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index f93ab729..13faacee 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -1169,15 +1169,21 @@ func checkSlungWork(ctx RoleContext) { return } - // Check for hook file in the clone root - cloneRoot := ctx.WorkDir + // Get the git clone root (hooks are stored at clone root, not cwd) + cloneRoot, err := getGitRoot() + if err != nil { + // Not in a git repo - can't have hooks + return + } + sw, err := wisp.ReadHook(cloneRoot, agentID) if err != nil { if errors.Is(err, wisp.ErrNoHook) { // No hook - normal case, nothing to do return } - // Other error - log but continue + // Log other errors (permission, corruption) but continue + fmt.Printf("%s Warning: error reading hook: %v\n", style.Dim.Render("⚠"), err) return } @@ -1196,14 +1202,15 @@ func checkSlungWork(ctx RoleContext) { fmt.Printf(" Slung at: %s\n", sw.CreatedAt.Format("2006-01-02 15:04:05")) fmt.Println() - // Show the bead details + // Show the bead details - verify it exists fmt.Println("**Bead details:**") cmd := exec.Command("bd", "show", sw.BeadID) cmd.Dir = cloneRoot var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = nil - if cmd.Run() == nil { + beadExists := cmd.Run() == nil + if beadExists { // Show first 20 lines of bead details lines := strings.Split(stdout.String(), "\n") maxLines := 20 @@ -1214,6 +1221,13 @@ func checkSlungWork(ctx RoleContext) { for _, line := range lines { fmt.Printf(" %s\n", line) } + } else { + fmt.Printf(" %s Bead %s not found! It may have been deleted.\n", + style.Bold.Render("⚠ WARNING:"), sw.BeadID) + fmt.Println(" The hook will NOT be burned. Investigate this issue.") + fmt.Println() + // Don't burn - leave hook for debugging + return } fmt.Println() @@ -1222,12 +1236,22 @@ func checkSlungWork(ctx RoleContext) { fmt.Println(" Begin working on this bead immediately. No human input needed.") fmt.Println() - // Burn the hook now that it's been read + // Burn the hook now that it's been read and verified if err := wisp.BurnHook(cloneRoot, agentID); err != nil { fmt.Printf("%s Warning: could not burn hook: %v\n", style.Dim.Render("⚠"), err) } } +// getGitRoot returns the root of the current git repository. +func getGitRoot() (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + // getAgentIdentity returns the agent identity string for hook lookup. func getAgentIdentity(ctx RoleContext) string { switch ctx.Role { diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index cbbda5ed..743f7a7b 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -50,6 +50,11 @@ func init() { func runSling(cmd *cobra.Command, args []string) error { beadID := args[0] + // Polecats cannot sling - check early before writing anything + if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" { + return fmt.Errorf("polecats cannot sling (use gt done for handoff)") + } + // Verify the bead exists if err := verifyBeadExists(beadID); err != nil { return err @@ -166,13 +171,6 @@ func detectCloneRoot() (string, error) { // triggerHandoff restarts the agent session. func triggerHandoff(agentID, beadID string) error { - // Check if we're a polecat - if polecatName := os.Getenv("GT_POLECAT"); polecatName != "" { - fmt.Printf("%s Polecat detected - cannot sling (use gt done instead)\n", - style.Bold.Render("⚠")) - return fmt.Errorf("polecats cannot sling - use gt done for handoff") - } - // Must be in tmux if !tmux.IsInsideTmux() { return fmt.Errorf("not running in tmux - cannot restart") diff --git a/internal/wisp/io.go b/internal/wisp/io.go index 158e4f0d..d6269e2f 100644 --- a/internal/wisp/io.go +++ b/internal/wisp/io.go @@ -136,6 +136,7 @@ func HasHook(root, agent string) bool { } // ListHooks returns a list of agents with active hooks. +// Agent IDs are returned in their original form (with slashes). func ListHooks(root string) ([]string, error) { dir := filepath.Join(root, WispDir) entries, err := os.ReadDir(dir) @@ -152,7 +153,8 @@ func ListHooks(root string) ([]string, error) { if len(name) > len(HookPrefix)+len(HookSuffix) && name[:len(HookPrefix)] == HookPrefix && name[len(name)-len(HookSuffix):] == HookSuffix { - agent := name[len(HookPrefix) : len(name)-len(HookSuffix)] + sanitized := name[len(HookPrefix) : len(name)-len(HookSuffix)] + agent := unsanitizeAgentID(sanitized) agents = append(agents, agent) } } diff --git a/internal/wisp/types.go b/internal/wisp/types.go index 64902f5c..92ffa5c9 100644 --- a/internal/wisp/types.go +++ b/internal/wisp/types.go @@ -9,6 +9,7 @@ package wisp import ( + "strings" "time" ) @@ -126,6 +127,23 @@ func NewPatrolCycle(formula, createdBy string) *PatrolCycle { } // HookFilename returns the filename for an agent's hook file. +// Agent IDs containing slashes (e.g., "gastown/crew/joe") are sanitized +// by replacing "/" with "--" to create valid filenames. func HookFilename(agent string) string { - return HookPrefix + agent + HookSuffix + // Sanitize agent ID: replace path separators with double-dash + // This is reversible and avoids creating subdirectories + sanitized := sanitizeAgentID(agent) + return HookPrefix + sanitized + HookSuffix +} + +// sanitizeAgentID converts an agent ID to a safe filename component. +// "gastown/crew/joe" -> "gastown--crew--joe" +func sanitizeAgentID(agent string) string { + return strings.ReplaceAll(agent, "/", "--") +} + +// unsanitizeAgentID converts a sanitized filename back to an agent ID. +// "gastown--crew--joe" -> "gastown/crew/joe" +func unsanitizeAgentID(sanitized string) string { + return strings.ReplaceAll(sanitized, "--", "/") }