Implement town activity logging (gt-ewzon)

Add centralized logging for Gas Town agent lifecycle events:
- spawn: new agent created
- wake: agent resumed
- nudge: message injected
- handoff: agent handed off
- done: agent finished work
- crash: agent exited unexpectedly
- kill: agent killed intentionally

Implementation:
- Add internal/townlog package with LogEvent() function
- Log to ~/gt/logs/town.log with human-readable format
- Add gt log command to view/tail/filter logs
- Integrate logging into spawn, nudge, handoff, done, stop, session commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-26 15:51:28 -08:00
parent d524f65af3
commit e2b8f16c48
9 changed files with 903 additions and 0 deletions

View File

@@ -220,5 +220,8 @@ func runDone(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Witness notified of %s\n", style.Bold.Render("✓"), exitType)
}
// Log done event
LogDone(townRoot, sender, issueID)
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/wisp"
"github.com/steveyegge/gastown/internal/workspace"
)
var handoffCmd = &cobra.Command{
@@ -126,6 +127,15 @@ func runHandoff(cmd *cobra.Command, args []string) error {
// Handing off ourselves - print feedback then respawn
fmt.Printf("%s Handing off %s...\n", style.Bold.Render("🤝"), currentSession)
// Log handoff event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := sessionToGTRole(currentSession)
if agent == "" {
agent = currentSession
}
LogHandoff(townRoot, agent, handoffSubject)
}
// Dry run mode - show what would happen (BEFORE any side effects)
if handoffDryRun {
if handoffSubject != "" || handoffMessage != "" {

290
internal/cmd/log.go Normal file
View File

@@ -0,0 +1,290 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
// Log command flags
var (
logTail int
logType string
logAgent string
logSince string
logFollow bool
)
var logCmd = &cobra.Command{
Use: "log",
GroupID: GroupDiag,
Short: "View town activity log",
Long: `View the centralized log of Gas Town agent lifecycle events.
Events logged include:
spawn - new agent created
wake - agent resumed
nudge - message injected into agent
handoff - agent handed off to fresh session
done - agent finished work
crash - agent exited unexpectedly
kill - agent killed intentionally
Examples:
gt log # Show last 20 events
gt log -n 50 # Show last 50 events
gt log --type spawn # Show only spawn events
gt log --agent gastown/ # Show events for gastown rig
gt log --since 1h # Show events from last hour
gt log -f # Follow log (like tail -f)`,
RunE: runLog,
}
func init() {
logCmd.Flags().IntVarP(&logTail, "tail", "n", 20, "Number of events to show")
logCmd.Flags().StringVarP(&logType, "type", "t", "", "Filter by event type (spawn,wake,nudge,handoff,done,crash,kill)")
logCmd.Flags().StringVarP(&logAgent, "agent", "a", "", "Filter by agent prefix (e.g., gastown/, gastown/crew/max)")
logCmd.Flags().StringVar(&logSince, "since", "", "Show events since duration (e.g., 1h, 30m, 24h)")
logCmd.Flags().BoolVarP(&logFollow, "follow", "f", false, "Follow log output (like tail -f)")
rootCmd.AddCommand(logCmd)
}
func runLog(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
logPath := fmt.Sprintf("%s/logs/town.log", townRoot)
// If following, use tail -f
if logFollow {
return followLog(logPath)
}
// Check if log file exists
if _, err := os.Stat(logPath); os.IsNotExist(err) {
fmt.Printf("%s No log file yet (no events recorded)\n", style.Dim.Render("○"))
return nil
}
// Read events
events, err := townlog.ReadEvents(townRoot)
if err != nil {
return fmt.Errorf("reading events: %w", err)
}
if len(events) == 0 {
fmt.Printf("%s No events in log\n", style.Dim.Render("○"))
return nil
}
// Build filter
filter := townlog.Filter{}
if logType != "" {
filter.Type = townlog.EventType(logType)
}
if logAgent != "" {
filter.Agent = logAgent
}
if logSince != "" {
duration, err := time.ParseDuration(logSince)
if err != nil {
return fmt.Errorf("invalid --since duration: %w", err)
}
filter.Since = time.Now().Add(-duration)
}
// Apply filter
events = townlog.FilterEvents(events, filter)
// Apply tail limit
if logTail > 0 && len(events) > logTail {
events = events[len(events)-logTail:]
}
if len(events) == 0 {
fmt.Printf("%s No events match filter\n", style.Dim.Render("○"))
return nil
}
// Print events
for _, e := range events {
printEvent(e)
}
return nil
}
// followLog uses tail -f to follow the log file.
func followLog(logPath string) error {
// Check if log file exists, create empty if not
if _, err := os.Stat(logPath); os.IsNotExist(err) {
// Create logs directory and empty file
if err := os.MkdirAll(fmt.Sprintf("%s", logPath[:len(logPath)-len("town.log")-1]), 0755); err != nil {
return fmt.Errorf("creating logs directory: %w", err)
}
if _, err := os.Create(logPath); err != nil {
return fmt.Errorf("creating log file: %w", err)
}
}
fmt.Printf("%s Following %s (Ctrl+C to stop)\n\n", style.Dim.Render("○"), logPath)
tailCmd := exec.Command("tail", "-f", logPath)
tailCmd.Stdout = os.Stdout
tailCmd.Stderr = os.Stderr
return tailCmd.Run()
}
// printEvent prints a single event with styling.
func printEvent(e townlog.Event) {
ts := e.Timestamp.Format("2006-01-02 15:04:05")
// Color-code event types
var typeStr string
switch e.Type {
case townlog.EventSpawn:
typeStr = style.Success.Render("[spawn]")
case townlog.EventWake:
typeStr = style.Bold.Render("[wake]")
case townlog.EventNudge:
typeStr = style.Dim.Render("[nudge]")
case townlog.EventHandoff:
typeStr = style.Bold.Render("[handoff]")
case townlog.EventDone:
typeStr = style.Success.Render("[done]")
case townlog.EventCrash:
typeStr = style.Error.Render("[crash]")
case townlog.EventKill:
typeStr = style.Warning.Render("[kill]")
default:
typeStr = fmt.Sprintf("[%s]", e.Type)
}
detail := formatEventDetail(e)
fmt.Printf("%s %s %s %s\n", style.Dim.Render(ts), typeStr, e.Agent, detail)
}
// formatEventDetail returns a human-readable detail string for an event.
func formatEventDetail(e townlog.Event) string {
switch e.Type {
case townlog.EventSpawn:
if e.Context != "" {
return fmt.Sprintf("spawned for %s", e.Context)
}
return "spawned"
case townlog.EventWake:
if e.Context != "" {
return fmt.Sprintf("resumed (%s)", e.Context)
}
return "resumed"
case townlog.EventNudge:
if e.Context != "" {
return fmt.Sprintf("nudged with %q", truncateStr(e.Context, 40))
}
return "nudged"
case townlog.EventHandoff:
if e.Context != "" {
return fmt.Sprintf("handed off (%s)", e.Context)
}
return "handed off"
case townlog.EventDone:
if e.Context != "" {
return fmt.Sprintf("completed %s", e.Context)
}
return "completed work"
case townlog.EventCrash:
if e.Context != "" {
return fmt.Sprintf("exited unexpectedly (%s)", e.Context)
}
return "exited unexpectedly"
case townlog.EventKill:
if e.Context != "" {
return fmt.Sprintf("killed (%s)", e.Context)
}
return "killed"
default:
if e.Context != "" {
return fmt.Sprintf("%s (%s)", e.Type, e.Context)
}
return string(e.Type)
}
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// LogEvent is a helper that logs an event from anywhere in the codebase.
// It finds the town root and logs the event.
func LogEvent(eventType townlog.EventType, agent, context string) error {
townRoot, err := workspace.FindFromCwd()
if err != nil {
return err // Silently fail if not in a workspace
}
if townRoot == "" {
return nil
}
logger := townlog.NewLogger(townRoot)
return logger.Log(eventType, agent, context)
}
// LogEventWithRoot logs an event when the town root is already known.
func LogEventWithRoot(townRoot string, eventType townlog.EventType, agent, context string) error {
logger := townlog.NewLogger(townRoot)
return logger.Log(eventType, agent, context)
}
// Convenience functions for common events
// LogSpawn logs a spawn event.
func LogSpawn(townRoot, agent, issueID string) error {
return LogEventWithRoot(townRoot, townlog.EventSpawn, agent, issueID)
}
// LogWake logs a wake event.
func LogWake(townRoot, agent, context string) error {
return LogEventWithRoot(townRoot, townlog.EventWake, agent, context)
}
// LogNudge logs a nudge event.
func LogNudge(townRoot, agent, message string) error {
return LogEventWithRoot(townRoot, townlog.EventNudge, agent, strings.TrimSpace(message))
}
// LogHandoff logs a handoff event.
func LogHandoff(townRoot, agent, context string) error {
return LogEventWithRoot(townRoot, townlog.EventHandoff, agent, context)
}
// LogDone logs a done event.
func LogDone(townRoot, agent, issueID string) error {
return LogEventWithRoot(townRoot, townlog.EventDone, agent, issueID)
}
// LogCrash logs a crash event.
func LogCrash(townRoot, agent, reason string) error {
return LogEventWithRoot(townRoot, townlog.EventCrash, agent, reason)
}
// LogKill logs a kill event.
func LogKill(townRoot, agent, reason string) error {
return LogEventWithRoot(townRoot, townlog.EventKill, agent, reason)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
func init() {
@@ -60,6 +61,11 @@ func runNudge(cmd *cobra.Command, args []string) error {
}
fmt.Printf("%s Nudged %s/%s\n", style.Bold.Render("✓"), rigName, polecatName)
// Log nudge event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
LogNudge(townRoot, target, message)
}
} else {
// Raw session name (legacy)
exists, err := t.HasSession(target)
@@ -75,6 +81,11 @@ func runNudge(cmd *cobra.Command, args []string) error {
}
fmt.Printf("✓ Nudged %s\n", target)
// Log nudge event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
LogNudge(townRoot, target, message)
}
}
return nil

View File

@@ -17,6 +17,7 @@ import (
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/suggest"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -240,6 +241,13 @@ func runSessionStart(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
// Log wake event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventWake, agent, sessionIssue)
}
return nil
}
@@ -264,6 +272,18 @@ func runSessionStop(cmd *cobra.Command, args []string) error {
}
fmt.Printf("%s Session stopped.\n", style.Bold.Render("✓"))
// Log kill event
if townRoot, err := workspace.FindFromCwd(); err == nil && townRoot != "" {
agent := fmt.Sprintf("%s/%s", rigName, polecatName)
reason := "gt session stop"
if sessionForce {
reason = "gt session stop --force"
}
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventKill, agent, reason)
}
return nil
}

View File

@@ -461,6 +461,9 @@ func runSpawn(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
style.Dim.Render(fmt.Sprintf("gt session at %s/%s", rigName, polecatName)))
// Log spawn event
LogSpawn(townRoot, polecatAddress, assignmentID)
// NOTE: We do NOT send a nudge here. Claude Code takes 10-20+ seconds to initialize,
// and sending keys before the prompt is ready causes them to be mangled.
// The Deacon will poll with WaitForClaudeReady and send a trigger when ready.

View File

@@ -11,6 +11,7 @@ import (
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/townlog"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -141,6 +142,11 @@ func runStop(cmd *cobra.Command, args []string) error {
style.Bold.Render("✓"),
r.Name, info.Polecat)
// Log kill event
agent := fmt.Sprintf("%s/%s", r.Name, info.Polecat)
logger := townlog.NewLogger(townRoot)
logger.Log(townlog.EventKill, agent, "gt stop")
// Log captured output (truncated)
if len(output) > 200 {
output = output[len(output)-200:]