diff --git a/internal/claude/sessions.go b/internal/claude/sessions.go deleted file mode 100644 index ab7d5487..00000000 --- a/internal/claude/sessions.go +++ /dev/null @@ -1,271 +0,0 @@ -// Package claude provides integration with Claude Code's local data. -package claude - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "time" -) - -// SessionInfo represents a Claude Code session. -type SessionInfo struct { - ID string `json:"id"` // Session UUID - Path string `json:"path"` // Decoded project path - Role string `json:"role"` // Gas Town role (from beacon) - Topic string `json:"topic"` // Topic (from beacon) - StartTime time.Time `json:"start_time"` // First message timestamp - Summary string `json:"summary"` // Session summary - IsGasTown bool `json:"is_gastown"` // Has [GAS TOWN] beacon - FilePath string `json:"file_path"` // Full path to JSONL file -} - -// SessionFilter controls which sessions are returned. -type SessionFilter struct { - GasTownOnly bool // Only return Gas Town sessions - Role string // Filter by role (substring match) - Rig string // Filter by rig name - Path string // Filter by path (substring match) - Limit int // Max sessions to return (0 = unlimited) -} - -// gasTownPattern matches the beacon: [GAS TOWN] role • topic • timestamp -var gasTownPattern = regexp.MustCompile(`\[GAS TOWN\]\s+([^\s•]+)\s*(?:•\s*([^•]+?)\s*)?(?:•\s*(\S+))?\s*$`) - -// DiscoverSessions finds Claude Code sessions matching the filter. -func DiscoverSessions(filter SessionFilter) ([]SessionInfo, error) { - claudeDir := os.ExpandEnv("$HOME/.claude") - projectsDir := filepath.Join(claudeDir, "projects") - - if _, err := os.Stat(projectsDir); os.IsNotExist(err) { - return nil, nil // No sessions yet - } - - var sessions []SessionInfo - - // Walk project directories - entries, err := os.ReadDir(projectsDir) - if err != nil { - return nil, err - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - // Decode path from directory name - projectPath := decodePath(entry.Name()) - - // Apply path/rig filter early - if filter.Rig != "" && !strings.Contains(projectPath, "/"+filter.Rig+"/") { - continue - } - if filter.Path != "" && !strings.Contains(projectPath, filter.Path) { - continue - } - - projectDir := filepath.Join(projectsDir, entry.Name()) - sessionFiles, err := os.ReadDir(projectDir) - if err != nil { - continue - } - - for _, sf := range sessionFiles { - if !strings.HasSuffix(sf.Name(), ".jsonl") { - continue - } - - // Skip agent files (they're subprocesses, not main sessions) - if strings.HasPrefix(sf.Name(), "agent-") { - continue - } - - sessionPath := filepath.Join(projectDir, sf.Name()) - info, err := parseSession(sessionPath, projectPath) - if err != nil { - continue - } - - // Apply filters - if filter.GasTownOnly && !info.IsGasTown { - continue - } - if filter.Role != "" { - // Check Role field first, then path - roleMatch := strings.Contains(strings.ToLower(info.Role), strings.ToLower(filter.Role)) - pathMatch := strings.Contains(strings.ToLower(info.Path), strings.ToLower(filter.Role)) - if !roleMatch && !pathMatch { - continue - } - } - - sessions = append(sessions, info) - } - } - - // Sort by start time descending (most recent first) - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].StartTime.After(sessions[j].StartTime) - }) - - // Apply limit - if filter.Limit > 0 && len(sessions) > filter.Limit { - sessions = sessions[:filter.Limit] - } - - return sessions, nil -} - -// parseSession reads a session JSONL file and extracts metadata. -func parseSession(filePath, projectPath string) (SessionInfo, error) { - file, err := os.Open(filePath) - if err != nil { - return SessionInfo{}, err - } - defer file.Close() - - info := SessionInfo{ - Path: projectPath, - FilePath: filePath, - } - - // Extract session ID from filename - base := filepath.Base(filePath) - info.ID = strings.TrimSuffix(base, ".jsonl") - - scanner := bufio.NewScanner(file) - // Increase buffer size for large lines - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, 1024*1024) - - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := scanner.Bytes() - - // First line is usually the summary - if lineNum == 1 { - var entry struct { - Type string `json:"type"` - Summary string `json:"summary"` - } - if err := json.Unmarshal(line, &entry); err == nil && entry.Type == "summary" { - info.Summary = entry.Summary - } - continue - } - - // Look for user messages - var entry struct { - Type string `json:"type"` - SessionID string `json:"sessionId"` - Timestamp string `json:"timestamp"` - Message json.RawMessage `json:"message"` - } - if err := json.Unmarshal(line, &entry); err != nil { - continue - } - - if entry.Type == "user" { - // Parse timestamp - if entry.Timestamp != "" && info.StartTime.IsZero() { - if t, err := time.Parse(time.RFC3339, entry.Timestamp); err == nil { - info.StartTime = t - } - } - - // Set session ID if not already set - if info.ID == "" && entry.SessionID != "" { - info.ID = entry.SessionID - } - - // Look for Gas Town beacon in message - if !info.IsGasTown { - msgStr := extractMessageContent(entry.Message) - if match := gasTownPattern.FindStringSubmatch(msgStr); match != nil { - info.IsGasTown = true - info.Role = match[1] - if len(match) > 2 { - info.Topic = strings.TrimSpace(match[2]) - } - } - } - } - - // Stop after finding what we need - if info.IsGasTown && !info.StartTime.IsZero() && lineNum > 20 { - break - } - } - - return info, nil -} - -// extractMessageContent extracts text content from a message JSON. -func extractMessageContent(msg json.RawMessage) string { - if len(msg) == 0 { - return "" - } - - // Try as string first - var str string - if err := json.Unmarshal(msg, &str); err == nil { - return str - } - - // Try as object with role/content - var obj struct { - Content string `json:"content"` - Role string `json:"role"` - } - if err := json.Unmarshal(msg, &obj); err == nil { - return obj.Content - } - - return "" -} - -// decodePath converts Claude's path-encoded directory names back to paths. -// e.g., "-Users-stevey-gt-gastown" -> "/Users/stevey/gt/gastown" -func decodePath(encoded string) string { - // Replace leading dash with / - if strings.HasPrefix(encoded, "-") { - encoded = "/" + encoded[1:] - } - // Replace remaining dashes with / - return strings.ReplaceAll(encoded, "-", "/") -} - -// ShortID returns a shortened version of the session ID for display. -func (s SessionInfo) ShortID() string { - if len(s.ID) > 8 { - return s.ID[:8] - } - return s.ID -} - -// FormatTime returns the start time in a compact format. -func (s SessionInfo) FormatTime() string { - if s.StartTime.IsZero() { - return "unknown" - } - return s.StartTime.Format("2006-01-02 15:04") -} - -// RigFromPath extracts the rig name from the project path. -func (s SessionInfo) RigFromPath() string { - // Look for known rig patterns - parts := strings.Split(s.Path, "/") - for i, part := range parts { - if part == "gt" && i+1 < len(parts) { - // Next part after gt/ is usually the rig - return parts[i+1] - } - } - return "" -} diff --git a/internal/claude/sessions_test.go b/internal/claude/sessions_test.go deleted file mode 100644 index 75814ce4..00000000 --- a/internal/claude/sessions_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package claude - -import ( - "testing" -) - -func TestDecodePath(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"-Users-stevey-gt-gastown", "/Users/stevey/gt/gastown"}, - {"-Users-stevey-gt-beads-crew-joe", "/Users/stevey/gt/beads/crew/joe"}, - {"-Users-stevey", "/Users/stevey"}, - {"foo-bar", "foo/bar"}, // Edge case: no leading dash - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := decodePath(tt.input) - if result != tt.expected { - t.Errorf("decodePath(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -func TestGasTownPattern(t *testing.T) { - tests := []struct { - input string - shouldMatch bool - role string - topic string - }{ - { - input: "[GAS TOWN] gastown/polecats/furiosa • ready • 2025-12-30T22:49", - shouldMatch: true, - role: "gastown/polecats/furiosa", - topic: "ready", - }, - { - input: "[GAS TOWN] deacon • patrol • 2025-12-30T08:00", - shouldMatch: true, - role: "deacon", - topic: "patrol", - }, - { - input: "[GAS TOWN] gastown/crew/gus • assigned:gt-abc12 • 2025-12-30T15:42", - shouldMatch: true, - role: "gastown/crew/gus", - topic: "assigned:gt-abc12", - }, - { - input: "Regular message without beacon", - shouldMatch: false, - }, - { - input: "[GAS TOWN] witness • handoff", - shouldMatch: true, - role: "witness", - topic: "handoff", - }, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - match := gasTownPattern.FindStringSubmatch(tt.input) - if tt.shouldMatch && match == nil { - t.Errorf("Expected match for %q but got none", tt.input) - return - } - if !tt.shouldMatch && match != nil { - t.Errorf("Expected no match for %q but got %v", tt.input, match) - return - } - if tt.shouldMatch { - if match[1] != tt.role { - t.Errorf("Role: got %q, want %q", match[1], tt.role) - } - if len(match) > 2 && match[2] != tt.topic { - // Topic might have trailing space, trim for comparison - gotTopic := match[2] - if gotTopic != tt.topic { - t.Errorf("Topic: got %q, want %q", gotTopic, tt.topic) - } - } - } - }) - } -} - -func TestSessionInfoShortID(t *testing.T) { - s := SessionInfo{ID: "d6d8475f-94a9-4a66-bfa6-d60126964427"} - short := s.ShortID() - if short != "d6d8475f" { - t.Errorf("ShortID() = %q, want %q", short, "d6d8475f") - } - - s2 := SessionInfo{ID: "abc"} - if s2.ShortID() != "abc" { - t.Errorf("ShortID() for short ID = %q, want %q", s2.ShortID(), "abc") - } -} - -func TestInferRoleFromPath(t *testing.T) { - s := SessionInfo{Path: "/Users/stevey/gt/gastown/crew/joe"} - rig := s.RigFromPath() - if rig != "gastown" { - t.Errorf("RigFromPath() = %q, want %q", rig, "gastown") - } - - s2 := SessionInfo{Path: "/Users/stevey/gt/beads/polecats/jade"} - if s2.RigFromPath() != "beads" { - t.Errorf("RigFromPath() = %q, want %q", s2.RigFromPath(), "beads") - } -} diff --git a/internal/cmd/prime.go b/internal/cmd/prime.go index 46e9a48c..62fe95d4 100644 --- a/internal/cmd/prime.go +++ b/internal/cmd/prime.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/lock" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/templates" @@ -111,6 +112,9 @@ func runPrime(cmd *cobra.Command, args []string) error { // Report agent state as running (ZFC: agents self-report state) reportAgentState(ctx, "running") + // Emit session_start event for seance discovery + emitSessionEvent(ctx) + // Output context if err := outputPrimeContext(ctx); err != nil { return err @@ -1392,3 +1396,35 @@ func checkPendingEscalations(ctx RoleContext) { fmt.Println("Close resolved ones with `bd close --reason \"resolution\"`") fmt.Println() } + +// emitSessionEvent emits a session_start event for seance discovery. +// The event is written to ~/gt/.events.jsonl and can be queried via gt seance. +// Session ID comes from CLAUDE_SESSION_ID env var if available. +func emitSessionEvent(ctx RoleContext) { + if ctx.Role == RoleUnknown { + return + } + + // Get agent identity for the actor field + actor := getAgentIdentity(ctx) + if actor == "" { + return + } + + // Get session ID from environment (set by Claude Code hooks) + sessionID := os.Getenv("CLAUDE_SESSION_ID") + if sessionID == "" { + // Fall back to a generated identifier + sessionID = fmt.Sprintf("%s-%d", actor, os.Getpid()) + } + + // Determine topic from hook state or default + topic := "" + if ctx.Role == RoleWitness || ctx.Role == RoleRefinery || ctx.Role == RoleDeacon { + topic = "patrol" + } + + // Emit the event + payload := events.SessionPayload(sessionID, actor, topic, ctx.WorkDir) + events.LogFeed(events.TypeSessionStart, actor, payload) +} diff --git a/internal/cmd/seance.go b/internal/cmd/seance.go index 111ff3d0..301a5e67 100644 --- a/internal/cmd/seance.go +++ b/internal/cmd/seance.go @@ -1,170 +1,289 @@ package cmd import ( + "bufio" "encoding/json" "fmt" "os" + "os/exec" + "path/filepath" + "sort" "strings" + "time" "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/style" + "github.com/steveyegge/gastown/internal/workspace" ) var ( - seanceAll bool seanceRole string seanceRig string seanceRecent int + seanceTalk string + seancePrompt string seanceJSON bool ) var seanceCmd = &cobra.Command{ Use: "seance", GroupID: GroupDiag, - Short: "Discover and browse predecessor sessions", - Long: `Find and resume predecessor Claude Code sessions. + Short: "Talk to your predecessor sessions", + Long: `Seance lets you literally talk to predecessor sessions. -Seance scans Claude Code's session history to find Gas Town sessions. -Sessions are identified by the [GAS TOWN] beacon sent during startup. +"Where did you put the stuff you left for me?" - The #1 handoff question. -Examples: - gt seance # List recent Gas Town sessions - gt seance --all # Include non-Gas Town sessions +Instead of parsing logs, seance spawns a Claude subprocess that resumes +a predecessor session with full context. You can ask questions directly: + - "Why did you make this decision?" + - "Where were you stuck?" + - "What did you try that didn't work?" + +DISCOVERY: + gt seance # List recent sessions from events gt seance --role crew # Filter by role type gt seance --rig gastown # Filter by rig - gt seance --recent 10 # Last 10 sessions - gt seance --json # JSON output + gt seance --recent 10 # Last N sessions -Resume a session in Claude Code: - claude --resume +THE SEANCE (talk to predecessor): + gt seance --talk # Interactive conversation + gt seance --talk -p "Where is X?" # One-shot question -The beacon format parsed: - [GAS TOWN] gastown/crew/joe • assigned:gt-xyz • 2025-12-30T15:42`, +The --talk flag spawns: claude --fork-session --resume +This loads the predecessor's full context without modifying their session. + +Sessions are discovered from: + 1. Events emitted by SessionStart hooks (~/gt/.events.jsonl) + 2. The [GAS TOWN] beacon makes sessions searchable in /resume`, RunE: runSeance, } func init() { - seanceCmd.Flags().BoolVarP(&seanceAll, "all", "a", false, "Include non-Gas Town sessions") seanceCmd.Flags().StringVar(&seanceRole, "role", "", "Filter by role (crew, polecat, witness, etc.)") seanceCmd.Flags().StringVar(&seanceRig, "rig", "", "Filter by rig name") seanceCmd.Flags().IntVarP(&seanceRecent, "recent", "n", 20, "Number of recent sessions to show") + seanceCmd.Flags().StringVarP(&seanceTalk, "talk", "t", "", "Session ID to commune with") + seanceCmd.Flags().StringVarP(&seancePrompt, "prompt", "p", "", "One-shot prompt (with --talk)") seanceCmd.Flags().BoolVar(&seanceJSON, "json", false, "Output as JSON") rootCmd.AddCommand(seanceCmd) } +// sessionEvent represents a session_start event from our event stream. +type sessionEvent struct { + Timestamp string `json:"ts"` + Type string `json:"type"` + Actor string `json:"actor"` + Payload map[string]interface{} `json:"payload"` +} + func runSeance(cmd *cobra.Command, args []string) error { - filter := claude.SessionFilter{ - GasTownOnly: !seanceAll, - Role: seanceRole, - Rig: seanceRig, - Limit: seanceRecent, + // If --talk is provided, spawn a seance + if seanceTalk != "" { + return runSeanceTalk(seanceTalk, seancePrompt) } - sessions, err := claude.DiscoverSessions(filter) + // Otherwise, list discoverable sessions + return runSeanceList() +} + +func runSeanceList() error { + townRoot, err := workspace.FindFromCwd() + if err != nil || townRoot == "" { + return fmt.Errorf("not in a Gas Town workspace") + } + + // Read session events from our event stream + sessions, err := discoverSessions(townRoot) if err != nil { return fmt.Errorf("discovering sessions: %w", err) } + // Apply filters + var filtered []sessionEvent + for _, s := range sessions { + if seanceRole != "" { + actor := strings.ToLower(s.Actor) + if !strings.Contains(actor, strings.ToLower(seanceRole)) { + continue + } + } + if seanceRig != "" { + actor := strings.ToLower(s.Actor) + if !strings.Contains(actor, strings.ToLower(seanceRig)) { + continue + } + } + filtered = append(filtered, s) + } + + // Apply limit + if seanceRecent > 0 && len(filtered) > seanceRecent { + filtered = filtered[:seanceRecent] + } + if seanceJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(sessions) + return enc.Encode(filtered) } - if len(sessions) == 0 { - if seanceAll { - fmt.Println("No sessions found.") - } else { - fmt.Println("No Gas Town sessions found.") - fmt.Println(style.Dim.Render("Use --all to include non-Gas Town sessions")) - } + if len(filtered) == 0 { + fmt.Println("No session events found.") + fmt.Println(style.Dim.Render("Sessions are discovered from ~/gt/.events.jsonl")) + fmt.Println(style.Dim.Render("Ensure SessionStart hooks emit session_start events")) return nil } // Print header - fmt.Printf("%s\n\n", style.Bold.Render("Claude Code Sessions")) + fmt.Printf("%s\n\n", style.Bold.Render("Discoverable Sessions")) - // Calculate column widths - idWidth := 10 - roleWidth := 24 + // Column widths + idWidth := 12 + roleWidth := 26 timeWidth := 16 - topicWidth := 30 + topicWidth := 28 - // Header row fmt.Printf("%-*s %-*s %-*s %-*s\n", - idWidth, "ID", + idWidth, "SESSION_ID", roleWidth, "ROLE", timeWidth, "STARTED", topicWidth, "TOPIC") fmt.Printf("%s\n", strings.Repeat("─", idWidth+roleWidth+timeWidth+topicWidth+6)) - for _, s := range sessions { - id := s.ShortID() - - role := s.Role - if role == "" { - // Try to infer from path - role = inferRoleFromPath(s.Path) + for _, s := range filtered { + sessionID := getPayloadString(s.Payload, "session_id") + if len(sessionID) > idWidth { + sessionID = sessionID[:idWidth-1] + "…" } + + role := s.Actor if len(role) > roleWidth { role = role[:roleWidth-1] + "…" } - timeStr := s.FormatTime() + timeStr := formatEventTime(s.Timestamp) - topic := s.Topic - if topic == "" && s.Summary != "" { - // Use summary as fallback - topic = s.Summary + topic := getPayloadString(s.Payload, "topic") + if topic == "" { + topic = "-" } if len(topic) > topicWidth { topic = topic[:topicWidth-1] + "…" } - // Color based on Gas Town status - if s.IsGasTown { - fmt.Printf("%-*s %-*s %-*s %-*s\n", - idWidth, id, - roleWidth, role, - timeWidth, timeStr, - topicWidth, topic) - } else { - fmt.Printf("%s %s %s %s\n", - style.Dim.Render(fmt.Sprintf("%-*s", idWidth, id)), - style.Dim.Render(fmt.Sprintf("%-*s", roleWidth, role)), - style.Dim.Render(fmt.Sprintf("%-*s", timeWidth, timeStr)), - style.Dim.Render(fmt.Sprintf("%-*s", topicWidth, topic))) - } + fmt.Printf("%-*s %-*s %-*s %-*s\n", + idWidth, sessionID, + roleWidth, role, + timeWidth, timeStr, + topicWidth, topic) } - fmt.Printf("\n%s\n", style.Dim.Render("Resume a session: claude --resume ")) + fmt.Printf("\n%s\n", style.Bold.Render("Talk to a predecessor:")) + fmt.Printf(" gt seance --talk \n") + fmt.Printf(" gt seance --talk -p \"Where did you put X?\"\n") return nil } -// inferRoleFromPath attempts to extract a role from the project path. -func inferRoleFromPath(path string) string { - // Look for patterns like /crew/joe, /polecats/furiosa, /witness, etc. - parts := strings.Split(path, "/") - for i := len(parts) - 1; i >= 0; i-- { - part := parts[i] - switch part { - case "witness", "refinery", "deacon", "mayor": - return part - case "crew", "polecats": - if i+1 < len(parts) { - // Include the agent name - return fmt.Sprintf("%s/%s", part, parts[i+1]) +func runSeanceTalk(sessionID, prompt string) error { + // Expand short IDs if needed (user might provide partial) + // For now, require full ID or let claude --resume handle it + + fmt.Printf("%s Summoning session %s...\n\n", style.Bold.Render("🔮"), sessionID) + + // Build the command + args := []string{"--fork-session", "--resume", sessionID} + + if prompt != "" { + // One-shot mode with --print + args = append(args, "--print", prompt) + + cmd := exec.Command("claude", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("seance failed: %w", err) + } + return nil + } + + // Interactive mode - just launch claude + cmd := exec.Command("claude", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("%s\n", style.Dim.Render("You are now talking to your predecessor. Ask them anything.")) + fmt.Printf("%s\n\n", style.Dim.Render("Exit with /exit or Ctrl+C")) + + if err := cmd.Run(); err != nil { + // Exit errors are normal when user exits + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 0 || exitErr.ExitCode() == 130 { + return nil // Normal exit or Ctrl+C } - return part + } + return fmt.Errorf("seance ended: %w", err) + } + + return nil +} + +// discoverSessions reads session_start events from our event stream. +func discoverSessions(townRoot string) ([]sessionEvent, error) { + eventsPath := filepath.Join(townRoot, events.EventsFile) + + file, err := os.Open(eventsPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + + var sessions []sessionEvent + scanner := bufio.NewScanner(file) + + // Increase buffer for large lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + var event sessionEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + continue + } + + if event.Type == events.TypeSessionStart { + sessions = append(sessions, event) } } - // Fall back to last path component - if len(parts) > 0 { - return parts[len(parts)-1] - } - return "unknown" + // Sort by timestamp descending (most recent first) + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].Timestamp > sessions[j].Timestamp + }) + + return sessions, scanner.Err() +} + +func getPayloadString(payload map[string]interface{}, key string) string { + if v, ok := payload[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func formatEventTime(ts string) string { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return ts + } + return t.Local().Format("2006-01-02 15:04") } diff --git a/internal/events/events.go b/internal/events/events.go index a3b402d3..600d0a29 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -46,6 +46,10 @@ const ( TypeBoot = "boot" TypeHalt = "halt" + // Session events (for seance discovery) + TypeSessionStart = "session_start" + TypeSessionEnd = "session_end" + // Witness patrol events TypePatrolStarted = "patrol_started" TypePolecatChecked = "polecat_checked" @@ -269,3 +273,22 @@ func HaltPayload(services []string) map[string]interface{} { "services": services, } } + +// SessionPayload creates a payload for session start/end events. +// sessionID: Claude Code session UUID +// role: Gas Town role (e.g., "gastown/crew/joe", "deacon") +// topic: What the session is working on +// cwd: Working directory +func SessionPayload(sessionID, role, topic, cwd string) map[string]interface{} { + p := map[string]interface{}{ + "session_id": sessionID, + "role": role, + } + if topic != "" { + p["topic"] = topic + } + if cwd != "" { + p["cwd"] = cwd + } + return p +}