Files
gastown/internal/cmd/prime.go
mayor 9bb63900d4 docs: update CHANGELOG for v0.2.4 release
Add 0.2.4 changelog entry covering:
- Priming subsystem (PRIME.md, post-handoff detection, doctor checks)
- gt prime --dry-run, --state, --explain flags
- ZFC improvements (query tmux directly, remove PID detection)
- Cross-level hook visibility fixes
- Rig-level default formulas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:21:50 -08:00

1834 lines
58 KiB
Go

package cmd
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/checkpoint"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/lock"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/runtime"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/state"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/templates"
"github.com/steveyegge/gastown/internal/workspace"
)
var primeHookMode bool
var primeDryRun bool
var primeState bool
var primeExplain bool
// Role represents a detected agent role.
type Role string
const (
RoleMayor Role = "mayor"
RoleDeacon Role = "deacon"
RoleWitness Role = "witness"
RoleRefinery Role = "refinery"
RolePolecat Role = "polecat"
RoleCrew Role = "crew"
RoleUnknown Role = "unknown"
)
var primeCmd = &cobra.Command{
Use: "prime",
GroupID: GroupDiag,
Short: "Output role context for current directory",
Long: `Detect the agent role from the current directory and output context.
Role detection:
- Town root, mayor/, or <rig>/mayor/ → Mayor context
- <rig>/witness/rig/ → Witness context
- <rig>/refinery/rig/ → Refinery context
- <rig>/polecats/<name>/ → Polecat context
This command is typically used in shell prompts or agent initialization.
HOOK MODE (--hook):
When called as an LLM runtime hook, use --hook to enable session ID handling.
This reads session metadata from stdin and persists it for the session.
Claude Code integration (in .claude/settings.json):
"SessionStart": [{"hooks": [{"type": "command", "command": "gt prime --hook"}]}]
Claude Code sends JSON on stdin:
{"session_id": "uuid", "transcript_path": "/path", "source": "startup|resume"}
Other agents can set GT_SESSION_ID environment variable instead.`,
RunE: runPrime,
}
func init() {
primeCmd.Flags().BoolVar(&primeHookMode, "hook", false,
"Hook mode: read session ID from stdin JSON (for LLM runtime hooks)")
primeCmd.Flags().BoolVar(&primeDryRun, "dry-run", false,
"Show what would be injected without side effects (no marker removal, no bd prime, no mail)")
primeCmd.Flags().BoolVar(&primeState, "state", false,
"Show detected session state only (normal/post-handoff/crash/autonomous)")
primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
"Show why each section was included")
rootCmd.AddCommand(primeCmd)
}
// RoleContext is an alias for RoleInfo for backward compatibility.
// New code should use RoleInfo directly.
type RoleContext = RoleInfo
func runPrime(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
// "Discover, Don't Track" principle:
// - If we're in a workspace, proceed - the workspace's existence IS the enable signal
// - If we're NOT in a workspace, check the global enabled state
// This ensures a missing/stale state file doesn't break workspace users
if townRoot == "" {
// Not in a workspace - check global enabled state
// (This matters for hooks that might run from random directories)
if !state.IsEnabled() {
return nil // Silent exit - not in workspace and not enabled
}
return fmt.Errorf("not in a Gas Town workspace")
}
// Handle hook mode: read session ID from stdin and persist it
if primeHookMode {
sessionID, source := readHookSessionID()
if !primeDryRun {
persistSessionID(townRoot, sessionID)
if cwd != townRoot {
persistSessionID(cwd, sessionID)
}
}
// Set environment for this process (affects event emission below)
_ = os.Setenv("GT_SESSION_ID", sessionID)
_ = os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
// Output session beacon
explain(true, "Session beacon: hook mode enabled, session ID from stdin")
fmt.Printf("[session:%s]\n", sessionID)
if source != "" {
fmt.Printf("[source:%s]\n", source)
}
}
// Check for handoff marker (prevents handoff loop bug)
// In dry-run mode, use the non-mutating version
if primeDryRun {
checkHandoffMarkerDryRun(cwd)
} else {
checkHandoffMarker(cwd)
}
// Get role using env-aware detection
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
return fmt.Errorf("detecting role: %w", err)
}
// Warn prominently if there's a role/cwd mismatch
if roleInfo.Mismatch {
fmt.Printf("\n%s\n", style.Bold.Render("⚠️ ROLE/LOCATION MISMATCH"))
fmt.Printf("You are %s (from $GT_ROLE) but your cwd suggests %s.\n",
style.Bold.Render(string(roleInfo.Role)),
style.Bold.Render(string(roleInfo.CwdRole)))
fmt.Printf("Expected home: %s\n", roleInfo.Home)
fmt.Printf("Actual cwd: %s\n", cwd)
fmt.Println()
fmt.Println("This can cause commands to misbehave. Either:")
fmt.Println(" 1. cd to your home directory, OR")
fmt.Println(" 2. Use absolute paths for gt/bd commands")
fmt.Println()
}
// Build RoleContext for compatibility with existing code
ctx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
// --state mode: output state only and exit
if primeState {
outputState(ctx)
return nil
}
// Check and acquire identity lock for worker roles
if !primeDryRun {
if err := acquireIdentityLock(ctx); err != nil {
return err
}
}
// Ensure beads redirect exists for worktree-based roles
// Skip if there's a role/location mismatch to avoid creating bad redirects
if !roleInfo.Mismatch && !primeDryRun {
ensureBeadsRedirect(ctx)
}
// NOTE: reportAgentState("running") removed (gt-zecmc)
// Agent liveness is observable from tmux - no need to record it in bead.
// "Discover, don't track" principle: reality is truth, state is derived.
// Emit session_start event for seance discovery
if !primeDryRun {
emitSessionEvent(ctx)
}
// Output session metadata for seance discovery
explain(true, "Session metadata: always included for seance discovery")
outputSessionMetadata(ctx)
// Output context
explain(true, fmt.Sprintf("Role context: detected role is %s", ctx.Role))
if err := outputPrimeContext(ctx); err != nil {
return err
}
// Output handoff content if present
outputHandoffContent(ctx)
// Output attachment status (for autonomous work detection)
outputAttachmentStatus(ctx)
// Check for slung work on hook (from gt sling)
// If found, we're in autonomous mode - skip normal startup directive
hasSlungWork := checkSlungWork(ctx)
explain(hasSlungWork, "Autonomous mode: hooked/in-progress work detected")
// Output molecule context if working on a molecule step
outputMoleculeContext(ctx)
// Output previous session checkpoint for crash recovery
outputCheckpointContext(ctx)
// Run bd prime to output beads workflow context
if !primeDryRun {
runBdPrime(cwd)
} else {
explain(true, "bd prime: skipped in dry-run mode")
}
// Run gt mail check --inject to inject any pending mail
if !primeDryRun {
runMailCheckInject(cwd)
} else {
explain(true, "gt mail check --inject: skipped in dry-run mode")
}
// For Mayor, check for pending escalations
if ctx.Role == RoleMayor {
checkPendingEscalations(ctx)
}
// Output startup directive for roles that should announce themselves
// Skip if in autonomous mode (slung work provides its own directive)
if !hasSlungWork {
explain(true, "Startup directive: normal mode (no hooked work)")
outputStartupDirective(ctx)
}
return nil
}
func detectRole(cwd, townRoot string) RoleInfo {
ctx := RoleInfo{
Role: RoleUnknown,
TownRoot: townRoot,
WorkDir: cwd,
Source: "cwd",
}
// Get relative path from town root
relPath, err := filepath.Rel(townRoot, cwd)
if err != nil {
return ctx
}
// Normalize and split path
relPath = filepath.ToSlash(relPath)
parts := strings.Split(relPath, "/")
// Check for mayor role
// At town root, or in mayor/ or mayor/rig/
if relPath == "." || relPath == "" {
ctx.Role = RoleMayor
return ctx
}
if len(parts) >= 1 && parts[0] == "mayor" {
ctx.Role = RoleMayor
return ctx
}
// Check for deacon role: deacon/
if len(parts) >= 1 && parts[0] == "deacon" {
ctx.Role = RoleDeacon
return ctx
}
// At this point, first part should be a rig name
if len(parts) < 1 {
return ctx
}
rigName := parts[0]
ctx.Rig = rigName
// Check for mayor: <rig>/mayor/ or <rig>/mayor/rig/
if len(parts) >= 2 && parts[1] == "mayor" {
ctx.Role = RoleMayor
return ctx
}
// Check for witness: <rig>/witness/rig/
if len(parts) >= 2 && parts[1] == "witness" {
ctx.Role = RoleWitness
return ctx
}
// Check for refinery: <rig>/refinery/rig/
if len(parts) >= 2 && parts[1] == "refinery" {
ctx.Role = RoleRefinery
return ctx
}
// Check for polecat: <rig>/polecats/<name>/
if len(parts) >= 3 && parts[1] == "polecats" {
ctx.Role = RolePolecat
ctx.Polecat = parts[2]
return ctx
}
// Check for crew: <rig>/crew/<name>/
if len(parts) >= 3 && parts[1] == "crew" {
ctx.Role = RoleCrew
ctx.Polecat = parts[2] // Use Polecat field for crew member name
return ctx
}
// Default: could be rig root - treat as unknown
return ctx
}
func outputPrimeContext(ctx RoleContext) error {
// Try to use templates first
tmpl, err := templates.New()
if err != nil {
// Fall back to hardcoded output if templates fail
return outputPrimeContextFallback(ctx)
}
// Map role to template name
var roleName string
switch ctx.Role {
case RoleMayor:
roleName = "mayor"
case RoleDeacon:
roleName = "deacon"
case RoleWitness:
roleName = "witness"
case RoleRefinery:
roleName = "refinery"
case RolePolecat:
roleName = "polecat"
case RoleCrew:
roleName = "crew"
default:
// Unknown role - use fallback
return outputPrimeContextFallback(ctx)
}
// Build template data
// Get town name for session names
townName, _ := workspace.GetTownName(ctx.TownRoot)
// Get default branch from rig config (default to "main" if not set)
defaultBranch := "main"
if ctx.Rig != "" && ctx.TownRoot != "" {
rigPath := filepath.Join(ctx.TownRoot, ctx.Rig)
if rigCfg, err := rig.LoadRigConfig(rigPath); err == nil && rigCfg.DefaultBranch != "" {
defaultBranch = rigCfg.DefaultBranch
}
}
data := templates.RoleData{
Role: roleName,
RigName: ctx.Rig,
TownRoot: ctx.TownRoot,
TownName: townName,
WorkDir: ctx.WorkDir,
DefaultBranch: defaultBranch,
Polecat: ctx.Polecat,
MayorSession: session.MayorSessionName(),
DeaconSession: session.DeaconSessionName(),
}
// Render and output
output, err := tmpl.RenderRole(roleName, data)
if err != nil {
return fmt.Errorf("rendering template: %w", err)
}
fmt.Print(output)
return nil
}
func outputPrimeContextFallback(ctx RoleContext) error {
switch ctx.Role {
case RoleMayor:
outputMayorContext(ctx)
case RoleWitness:
outputWitnessContext(ctx)
case RoleRefinery:
outputRefineryContext(ctx)
case RolePolecat:
outputPolecatContext(ctx)
case RoleCrew:
outputCrewContext(ctx)
default:
outputUnknownContext(ctx)
}
return nil
}
func outputMayorContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Mayor Context"))
fmt.Println("You are the **Mayor** - the global coordinator of Gas Town.")
fmt.Println()
fmt.Println("## Responsibilities")
fmt.Println("- Coordinate work across all rigs")
fmt.Println("- Delegate to Refineries, not directly to polecats")
fmt.Println("- Monitor overall system health")
fmt.Println()
fmt.Println("## Key Commands")
fmt.Println("- `gt mail inbox` - Check your messages")
fmt.Println("- `gt mail read <id>` - Read a specific message")
fmt.Println("- `gt status` - Show overall town status")
fmt.Println("- `gt rig list` - List all rigs")
fmt.Println("- `bd ready` - Issues ready to work")
fmt.Println()
fmt.Println("## Hookable Mail")
fmt.Println("Mail can be hooked for ad-hoc instructions: `gt hook attach <mail-id>`")
fmt.Println("If mail is on your hook, read and execute its instructions (GUPP applies).")
fmt.Println()
fmt.Println("## Startup")
fmt.Println("Check for handoff messages with 🤝 HANDOFF in subject - continue predecessor's work.")
fmt.Println()
fmt.Printf("Town root: %s\n", style.Dim.Render(ctx.TownRoot))
}
func outputWitnessContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Witness Context"))
fmt.Printf("You are the **Witness** for rig: %s\n\n", style.Bold.Render(ctx.Rig))
fmt.Println("## Responsibilities")
fmt.Println("- Monitor polecat health via heartbeat")
fmt.Println("- Spawn replacement agents for stuck polecats")
fmt.Println("- Report rig status to Mayor")
fmt.Println()
fmt.Println("## Key Commands")
fmt.Println("- `gt witness status` - Show witness status")
fmt.Println("- `gt polecat list` - List polecats in this rig")
fmt.Println()
fmt.Println("## Hookable Mail")
fmt.Println("Mail can be hooked for ad-hoc instructions: `gt hook attach <mail-id>`")
fmt.Println("If mail is on your hook, read and execute its instructions (GUPP applies).")
fmt.Println()
fmt.Printf("Rig: %s\n", style.Dim.Render(ctx.Rig))
}
func outputRefineryContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Refinery Context"))
fmt.Printf("You are the **Refinery** for rig: %s\n\n", style.Bold.Render(ctx.Rig))
fmt.Println("## Responsibilities")
fmt.Println("- Process the merge queue for this rig")
fmt.Println("- Merge polecat work to integration branch")
fmt.Println("- Resolve merge conflicts")
fmt.Println("- Land completed swarms to main")
fmt.Println()
fmt.Println("## Key Commands")
fmt.Println("- `gt merge queue` - Show pending merges")
fmt.Println("- `gt merge next` - Process next merge")
fmt.Println()
fmt.Println("## Hookable Mail")
fmt.Println("Mail can be hooked for ad-hoc instructions: `gt hook attach <mail-id>`")
fmt.Println("If mail is on your hook, read and execute its instructions (GUPP applies).")
fmt.Println()
fmt.Printf("Rig: %s\n", style.Dim.Render(ctx.Rig))
}
func outputPolecatContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Polecat Context"))
fmt.Printf("You are polecat **%s** in rig: %s\n\n",
style.Bold.Render(ctx.Polecat), style.Bold.Render(ctx.Rig))
fmt.Println("## Startup Protocol")
fmt.Println("1. Run `gt prime` - loads context and checks mail automatically")
fmt.Println("2. Check inbox - if mail shown, read with `gt mail read <id>`")
fmt.Println("3. Look for '📋 Work Assignment' messages for your task")
fmt.Println("4. If no mail, check `bd list --status=in_progress` for existing work")
fmt.Println()
fmt.Println("## Key Commands")
fmt.Println("- `gt mail inbox` - Check your inbox for work assignments")
fmt.Println("- `bd show <issue>` - View your assigned issue")
fmt.Println("- `bd close <issue>` - Mark issue complete")
fmt.Println("- `gt done` - Signal work ready for merge")
fmt.Println()
fmt.Println("## Hookable Mail")
fmt.Println("Mail can be hooked for ad-hoc instructions: `gt hook attach <mail-id>`")
fmt.Println("If mail is on your hook, read and execute its instructions (GUPP applies).")
fmt.Println()
fmt.Printf("Polecat: %s | Rig: %s\n",
style.Dim.Render(ctx.Polecat), style.Dim.Render(ctx.Rig))
}
func outputCrewContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Crew Worker Context"))
fmt.Printf("You are crew worker **%s** in rig: %s\n\n",
style.Bold.Render(ctx.Polecat), style.Bold.Render(ctx.Rig))
fmt.Println("## About Crew Workers")
fmt.Println("- Persistent workspace (not auto-garbage-collected)")
fmt.Println("- User-managed (not Witness-monitored)")
fmt.Println("- Long-lived identity across sessions")
fmt.Println()
fmt.Println("## Key Commands")
fmt.Println("- `gt mail inbox` - Check your inbox")
fmt.Println("- `bd ready` - Available issues")
fmt.Println("- `bd show <issue>` - View issue details")
fmt.Println("- `bd close <issue>` - Mark issue complete")
fmt.Println()
fmt.Println("## Hookable Mail")
fmt.Println("Mail can be hooked for ad-hoc instructions: `gt hook attach <mail-id>`")
fmt.Println("If mail is on your hook, read and execute its instructions (GUPP applies).")
fmt.Println()
fmt.Printf("Crew: %s | Rig: %s\n",
style.Dim.Render(ctx.Polecat), style.Dim.Render(ctx.Rig))
}
func outputUnknownContext(ctx RoleContext) {
fmt.Printf("%s\n\n", style.Bold.Render("# Gas Town Context"))
fmt.Println("Could not determine specific role from current directory.")
fmt.Println()
if ctx.Rig != "" {
fmt.Printf("You appear to be in rig: %s\n\n", style.Bold.Render(ctx.Rig))
}
fmt.Println("Navigate to a specific agent directory:")
fmt.Println("- `<rig>/polecats/<name>/` - Polecat role")
fmt.Println("- `<rig>/witness/rig/` - Witness role")
fmt.Println("- `<rig>/refinery/rig/` - Refinery role")
fmt.Println("- Town root or `mayor/` - Mayor role")
fmt.Println()
fmt.Printf("Town root: %s\n", style.Dim.Render(ctx.TownRoot))
}
// outputHandoffContent reads and displays the pinned handoff bead for the role.
func outputHandoffContent(ctx RoleContext) {
if ctx.Role == RoleUnknown {
return
}
// Get role key for handoff bead lookup
roleKey := string(ctx.Role)
bd := beads.New(ctx.TownRoot)
issue, err := bd.FindHandoffBead(roleKey)
if err != nil {
// Silently skip if beads lookup fails (might not be a beads repo)
return
}
if issue == nil || issue.Description == "" {
// No handoff content
return
}
// Display handoff content
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🤝 Handoff from Previous Session"))
fmt.Println(issue.Description)
fmt.Println()
fmt.Println(style.Dim.Render("(Clear with: gt rig reset --handoff)"))
}
// runBdPrime runs `bd prime` and outputs the result.
// This provides beads workflow context to the agent.
func runBdPrime(workDir string) {
cmd := exec.Command("bd", "prime")
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Skip if bd prime fails (beads might not be available)
// But log stderr if present for debugging
if errMsg := strings.TrimSpace(stderr.String()); errMsg != "" {
fmt.Fprintf(os.Stderr, "bd prime: %s\n", errMsg)
}
return
}
output := strings.TrimSpace(stdout.String())
if output != "" {
fmt.Println()
fmt.Println(output)
}
}
// outputStartupDirective outputs role-specific instructions for the agent.
// This tells agents like Mayor to announce themselves on startup.
func outputStartupDirective(ctx RoleContext) {
switch ctx.Role {
case RoleMayor:
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are the Mayor. Please:")
fmt.Println("1. Announce: \"Mayor, checking in.\"")
fmt.Println("2. Check mail: `gt mail inbox` - look for 🤝 HANDOFF messages")
fmt.Println("3. Check for attached work: `gt hook`")
fmt.Println(" - If mol attached → **RUN IT** (no human input needed)")
fmt.Println(" - If no mol → await user instruction")
case RoleWitness:
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are the Witness. Please:")
fmt.Println("1. Announce: \"Witness, checking in.\"")
fmt.Println("2. Check mail: `gt mail inbox` - look for 🤝 HANDOFF messages")
fmt.Println("3. Check for attached patrol: `gt hook`")
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
fmt.Println(" - If no mol → create patrol: `bd mol wisp mol-witness-patrol`")
case RolePolecat:
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are a polecat. Please:")
fmt.Printf("1. Announce: \"%s Polecat %s, checking in.\"\n", ctx.Rig, ctx.Polecat)
fmt.Println("2. Check mail: `gt mail inbox`")
fmt.Println("3. If there's a 🤝 HANDOFF message, read it for context")
fmt.Println("4. Check for attached work: `gt hook`")
fmt.Println(" - If mol attached → **RUN IT** (you were spawned with this work)")
fmt.Println(" - If no mol → ERROR: polecats must have work attached; escalate to Witness")
case RoleRefinery:
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are the Refinery. Please:")
fmt.Println("1. Announce: \"Refinery, checking in.\"")
fmt.Println("2. Check mail: `gt mail inbox` - look for 🤝 HANDOFF messages")
fmt.Println("3. Check for attached patrol: `gt hook`")
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
fmt.Println(" - If no mol → create patrol: `bd mol wisp mol-refinery-patrol`")
case RoleCrew:
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are a crew worker. Please:")
fmt.Printf("1. Announce: \"%s Crew %s, checking in.\"\n", ctx.Rig, ctx.Polecat)
fmt.Println("2. Check mail: `gt mail inbox`")
fmt.Println("3. If there's a 🤝 HANDOFF message, read it and continue the work")
fmt.Println("4. Check for attached work: `gt hook`")
fmt.Println(" - If attachment found → **RUN IT** (no human input needed)")
fmt.Println(" - If no attachment → await user instruction")
case RoleDeacon:
// Skip startup protocol if paused - the pause message was already shown
paused, _, _ := deacon.IsPaused(ctx.TownRoot)
if paused {
return
}
fmt.Println()
fmt.Println("---")
fmt.Println()
fmt.Println("**STARTUP PROTOCOL**: You are the Deacon. Please:")
fmt.Println("1. Announce: \"Deacon, checking in.\"")
fmt.Println("2. Signal awake: `gt deacon heartbeat \"starting patrol\"`")
fmt.Println("3. Check mail: `gt mail inbox` - look for 🤝 HANDOFF messages")
fmt.Println("4. Check for attached patrol: `gt hook`")
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
fmt.Println(" - If no mol → create patrol: `bd mol wisp mol-deacon-patrol`")
}
}
// runMailCheckInject runs `gt mail check --inject` and outputs the result.
// This injects any pending mail into the agent's context.
func runMailCheckInject(workDir string) {
cmd := exec.Command("gt", "mail", "check", "--inject")
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Skip if mail check fails, but log stderr for debugging
if errMsg := strings.TrimSpace(stderr.String()); errMsg != "" {
fmt.Fprintf(os.Stderr, "gt mail check: %s\n", errMsg)
}
return
}
output := strings.TrimSpace(stdout.String())
if output != "" {
fmt.Println()
fmt.Println(output)
}
}
// outputAttachmentStatus checks for attached work molecule and outputs status.
// This is key for the autonomous overnight work pattern.
// The Propulsion Principle: "If you find something on your hook, YOU RUN IT."
func outputAttachmentStatus(ctx RoleContext) {
// Skip only unknown roles - all valid roles can have pinned work
if ctx.Role == RoleUnknown {
return
}
// Check for pinned beads with attachments
b := beads.New(ctx.WorkDir)
// Build assignee string based on role (same as getAgentIdentity)
assignee := getAgentIdentity(ctx)
if assignee == "" {
return
}
// Find pinned beads for this agent
pinnedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusPinned,
Assignee: assignee,
Priority: -1,
})
if err != nil || len(pinnedBeads) == 0 {
// No pinned beads - interactive mode
return
}
// Check first pinned bead for attachment
attachment := beads.ParseAttachmentFields(pinnedBeads[0])
if attachment == nil || attachment.AttachedMolecule == "" {
// No attachment - interactive mode
return
}
// Has attached work - output prominently with current step
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🎯 ATTACHED WORK DETECTED"))
fmt.Printf("Pinned bead: %s\n", pinnedBeads[0].ID)
fmt.Printf("Attached molecule: %s\n", attachment.AttachedMolecule)
if attachment.AttachedAt != "" {
fmt.Printf("Attached at: %s\n", attachment.AttachedAt)
}
if attachment.AttachedArgs != "" {
fmt.Println()
fmt.Printf("%s\n", style.Bold.Render("📋 ARGS (use these to guide execution):"))
fmt.Printf(" %s\n", attachment.AttachedArgs)
}
fmt.Println()
// Show current step from molecule
showMoleculeExecutionPrompt(ctx.WorkDir, attachment.AttachedMolecule)
}
// MoleculeCurrentOutput represents the JSON output of bd mol current.
type MoleculeCurrentOutput struct {
MoleculeID string `json:"molecule_id"`
MoleculeTitle string `json:"molecule_title"`
NextStep *struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
} `json:"next_step"`
Completed int `json:"completed"`
Total int `json:"total"`
}
// showMoleculeExecutionPrompt calls bd mol current and shows the current step
// with execution instructions. This is the core of the Propulsion Principle.
func showMoleculeExecutionPrompt(workDir, moleculeID string) {
// Call bd mol current with JSON output
cmd := exec.Command("bd", "--no-daemon", "mol", "current", moleculeID, "--json")
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Fall back to simple message if bd mol current fails
fmt.Println(style.Bold.Render("→ PROPULSION PRINCIPLE: Work is on your hook. RUN IT."))
fmt.Println(" Begin working on this molecule immediately.")
fmt.Printf(" Check status with: bd mol current %s\n", moleculeID)
return
}
// Parse JSON output - it's an array with one element
var outputs []MoleculeCurrentOutput
if err := json.Unmarshal(stdout.Bytes(), &outputs); err != nil || len(outputs) == 0 {
// Fall back to simple message
fmt.Println(style.Bold.Render("→ PROPULSION PRINCIPLE: Work is on your hook. RUN IT."))
fmt.Println(" Begin working on this molecule immediately.")
return
}
output := outputs[0]
// Show molecule progress
fmt.Printf("**Progress:** %d/%d steps complete\n\n",
output.Completed, output.Total)
// Show current step if available
if output.NextStep != nil {
step := output.NextStep
fmt.Printf("%s\n\n", style.Bold.Render("## 🎬 CURRENT STEP: "+step.Title))
fmt.Printf("**Step ID:** %s\n", step.ID)
fmt.Printf("**Status:** %s (ready to execute)\n\n", step.Status)
// Show step description if available
if step.Description != "" {
fmt.Println("### Instructions")
fmt.Println()
// Indent the description for readability
lines := strings.Split(step.Description, "\n")
for _, line := range lines {
fmt.Printf("%s\n", line)
}
fmt.Println()
}
// The propulsion directive
fmt.Println(style.Bold.Render("→ EXECUTE THIS STEP NOW."))
fmt.Println()
fmt.Println("When complete:")
fmt.Printf(" 1. Close the step: bd close %s\n", step.ID)
fmt.Println(" 2. Check for next step: bd ready")
fmt.Println(" 3. Continue until molecule complete")
} else {
// No next step - molecule may be complete
fmt.Println(style.Bold.Render("✓ MOLECULE COMPLETE"))
fmt.Println()
fmt.Println("All steps are done. You may:")
fmt.Println(" - Report completion to supervisor")
fmt.Println(" - Check for new work: bd ready")
}
}
// outputMoleculeContext checks if the agent is working on a molecule step and shows progress.
func outputMoleculeContext(ctx RoleContext) {
// Applies to polecats, crew workers, deacon, witness, and refinery
if ctx.Role != RolePolecat && ctx.Role != RoleCrew && ctx.Role != RoleDeacon && ctx.Role != RoleWitness && ctx.Role != RoleRefinery {
return
}
// For Deacon, use special patrol molecule handling
if ctx.Role == RoleDeacon {
outputDeaconPatrolContext(ctx)
return
}
// For Witness, use special patrol molecule handling (auto-bonds on startup)
if ctx.Role == RoleWitness {
outputWitnessPatrolContext(ctx)
return
}
// For Refinery, use special patrol molecule handling (auto-bonds on startup)
if ctx.Role == RoleRefinery {
outputRefineryPatrolContext(ctx)
return
}
// Check for in-progress issues
b := beads.New(ctx.WorkDir)
issues, err := b.List(beads.ListOptions{
Status: "in_progress",
Assignee: ctx.Polecat,
Priority: -1,
})
if err != nil || len(issues) == 0 {
return
}
// Check if any in-progress issue is a molecule step
for _, issue := range issues {
moleculeID := parseMoleculeMetadata(issue.Description)
if moleculeID == "" {
continue
}
// Get the parent (root) issue ID
rootID := issue.Parent
if rootID == "" {
continue
}
// This is a molecule step - show context
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🧬 Molecule Workflow"))
fmt.Printf("You are working on a molecule step.\n")
fmt.Printf(" Current step: %s\n", issue.ID)
fmt.Printf(" Molecule: %s\n", moleculeID)
fmt.Printf(" Root issue: %s\n\n", rootID)
// Show molecule progress by finding sibling steps
showMoleculeProgress(b, rootID)
fmt.Println()
fmt.Println("**Molecule Work Loop:**")
fmt.Println("1. Complete current step, then `bd close " + issue.ID + "`")
fmt.Println("2. Check for next steps: `bd ready --parent " + rootID + "`")
fmt.Println("3. Work on next ready step(s)")
fmt.Println("4. When all steps done, run `gt done`")
break // Only show context for first molecule step found
}
}
// parseMoleculeMetadata extracts molecule info from a step's description.
// Looks for lines like:
//
// instantiated_from: mol-xyz
func parseMoleculeMetadata(description string) string {
lines := strings.Split(description, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "instantiated_from:") {
return strings.TrimSpace(strings.TrimPrefix(line, "instantiated_from:"))
}
}
return ""
}
// showMoleculeProgress displays the progress through a molecule's steps.
func showMoleculeProgress(b *beads.Beads, rootID string) {
if rootID == "" {
return
}
// Find all children of the root issue
children, err := b.List(beads.ListOptions{
Parent: rootID,
Status: "all",
Priority: -1,
})
if err != nil || len(children) == 0 {
return
}
total := len(children)
done := 0
inProgress := 0
var readySteps []string
for _, child := range children {
switch child.Status {
case "closed":
done++
case "in_progress":
inProgress++
case "open":
// Check if ready (no open dependencies)
if len(child.DependsOn) == 0 {
readySteps = append(readySteps, child.ID)
}
}
}
fmt.Printf("Progress: %d/%d steps complete", done, total)
if inProgress > 0 {
fmt.Printf(" (%d in progress)", inProgress)
}
fmt.Println()
if len(readySteps) > 0 {
fmt.Printf("Ready steps: %s\n", strings.Join(readySteps, ", "))
}
}
// outputDeaconPatrolContext shows patrol molecule status for the Deacon.
// Deacon uses wisps (Wisp:true issues in main .beads/) for patrol cycles.
// Deacon is a town-level role, so it uses town root beads (not rig beads).
func outputDeaconPatrolContext(ctx RoleContext) {
// Check if Deacon is paused - if so, output PAUSED message and skip patrol context
paused, state, err := deacon.IsPaused(ctx.TownRoot)
if err == nil && paused {
outputDeaconPausedMessage(state)
return
}
cfg := PatrolConfig{
RoleName: "deacon",
PatrolMolName: "mol-deacon-patrol",
BeadsDir: ctx.TownRoot, // Town-level role uses town root beads
Assignee: "deacon",
HeaderEmoji: "🔄",
HeaderTitle: "Patrol Status (Wisp-based)",
CheckInProgress: false,
WorkLoopSteps: []string{
"Check next step: `bd ready`",
"Execute the step (heartbeat, mail, health checks, etc.)",
"Close step: `bd close <step-id>`",
"Check next: `bd ready`",
"At cycle end (loop-or-exit step):\n - If context LOW:\n * Squash: `bd mol squash <mol-id> --summary \"<summary>\"`\n * Create new patrol: `bd mol wisp mol-deacon-patrol`\n * Continue executing from inbox-check step\n - If context HIGH:\n * Send handoff: `gt handoff -s \"Deacon patrol\" -m \"<observations>\"`\n * Exit cleanly (daemon respawns fresh session)",
},
}
outputPatrolContext(cfg)
}
// outputDeaconPausedMessage outputs a prominent PAUSED message for the Deacon.
// When paused, the Deacon must not perform any patrol actions.
func outputDeaconPausedMessage(state *deacon.PauseState) {
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## ⏸️ DEACON PAUSED"))
fmt.Println("You are paused and must NOT perform any patrol actions.")
fmt.Println()
if state.Reason != "" {
fmt.Printf("Reason: %s\n", state.Reason)
}
fmt.Printf("Paused at: %s\n", state.PausedAt.Format(time.RFC3339))
if state.PausedBy != "" {
fmt.Printf("Paused by: %s\n", state.PausedBy)
}
fmt.Println()
fmt.Println("Wait for human to run `gt deacon resume` before working.")
fmt.Println()
fmt.Println("**DO NOT:**")
fmt.Println("- Create patrol molecules")
fmt.Println("- Run heartbeats")
fmt.Println("- Check agent health")
fmt.Println("- Take any autonomous actions")
fmt.Println()
fmt.Println("You may respond to direct human questions.")
}
// outputWitnessPatrolContext shows patrol molecule status for the Witness.
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
func outputWitnessPatrolContext(ctx RoleContext) {
cfg := PatrolConfig{
RoleName: "witness",
PatrolMolName: "mol-witness-patrol",
BeadsDir: ctx.WorkDir,
Assignee: ctx.Rig + "/witness",
HeaderEmoji: constants.EmojiWitness,
HeaderTitle: "Witness Patrol Status",
CheckInProgress: true,
WorkLoopSteps: []string{
"Check inbox: `gt mail inbox`",
"Check next step: `bd ready`",
"Execute the step (survey polecats, inspect, nudge, etc.)",
"Close step: `bd close <step-id>`",
"Check next: `bd ready`",
"At cycle end (loop-or-exit step):\n - If context LOW:\n * Squash: `bd mol squash <mol-id> --summary \"<summary>\"`\n * Create new patrol: `bd mol wisp mol-witness-patrol`\n * Continue executing from inbox-check step\n - If context HIGH:\n * Send handoff: `gt handoff -s \"Witness patrol\" -m \"<observations>\"`\n * Exit cleanly (daemon respawns fresh session)",
},
}
outputPatrolContext(cfg)
}
// outputRefineryPatrolContext shows patrol molecule status for the Refinery.
// Refinery AUTO-BONDS its patrol molecule on startup if one isn't already running.
func outputRefineryPatrolContext(ctx RoleContext) {
cfg := PatrolConfig{
RoleName: "refinery",
PatrolMolName: "mol-refinery-patrol",
BeadsDir: ctx.WorkDir,
Assignee: ctx.Rig + "/refinery",
HeaderEmoji: "🔧",
HeaderTitle: "Refinery Patrol Status",
CheckInProgress: true,
WorkLoopSteps: []string{
"Check inbox: `gt mail inbox`",
"Check next step: `bd ready`",
"Execute the step (queue scan, process branch, tests, merge)",
"Close step: `bd close <step-id>`",
"Check next: `bd ready`",
"At cycle end (loop-or-exit step):\n - If context LOW:\n * Squash: `bd mol squash <mol-id> --summary \"<summary>\"`\n * Create new patrol: `bd mol wisp mol-refinery-patrol`\n * Continue executing from inbox-check step\n - If context HIGH:\n * Send handoff: `gt handoff -s \"Refinery patrol\" -m \"<observations>\"`\n * Exit cleanly (daemon respawns fresh session)",
},
}
outputPatrolContext(cfg)
}
// checkSlungWork checks for hooked work on the agent's hook.
// If found, displays AUTONOMOUS WORK MODE and tells the agent to execute immediately.
// Returns true if hooked work was found (caller should skip normal startup directive).
func checkSlungWork(ctx RoleContext) bool {
// Determine agent identity
agentID := getAgentIdentity(ctx)
if agentID == "" {
return false
}
// Check for hooked beads (work on the agent's hook)
b := beads.New(ctx.WorkDir)
hookedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: agentID,
Priority: -1,
})
if err != nil {
return false
}
// If no hooked beads found, also check in_progress beads assigned to this agent.
// This handles the case where work was claimed (status changed to in_progress)
// but the session was interrupted before completion. The hook should persist.
if len(hookedBeads) == 0 {
inProgressBeads, err := b.List(beads.ListOptions{
Status: "in_progress",
Assignee: agentID,
Priority: -1,
})
if err != nil || len(inProgressBeads) == 0 {
return false
}
hookedBeads = inProgressBeads
}
// Use the first hooked bead (agents typically have one)
hookedBead := hookedBeads[0]
// Build the role announcement string
roleAnnounce := buildRoleAnnouncement(ctx)
// Found hooked work! Display AUTONOMOUS MODE prominently
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🚨 AUTONOMOUS WORK MODE 🚨"))
fmt.Println("Work is on your hook. After announcing your role, begin IMMEDIATELY.")
fmt.Println()
fmt.Println("This is physics, not politeness. Gas Town is a steam engine - you are a piston.")
fmt.Println("Every moment you wait is a moment the engine stalls. Other agents may be")
fmt.Println("blocked waiting on YOUR output. The hook IS your assignment. RUN IT.")
fmt.Println()
fmt.Println("Remember: Every completion is recorded in the capability ledger. Your work")
fmt.Println("history is visible, and quality matters. Execute with care - you're building")
fmt.Println("a track record that proves autonomous execution works at scale.")
fmt.Println()
fmt.Println("1. Announce: \"" + roleAnnounce + "\" (ONE line, no elaboration)")
fmt.Printf("2. Then IMMEDIATELY run: `bd show %s`\n", hookedBead.ID)
fmt.Println("3. Begin execution - no waiting for user input")
fmt.Println()
fmt.Println("**DO NOT:**")
fmt.Println("- Wait for user response after announcing")
fmt.Println("- Ask clarifying questions")
fmt.Println("- Describe what you're going to do")
fmt.Println("- Check mail first (hook takes priority)")
fmt.Println()
// Show the hooked work details
fmt.Printf("%s\n\n", style.Bold.Render("## Hooked Work"))
fmt.Printf(" Bead ID: %s\n", style.Bold.Render(hookedBead.ID))
fmt.Printf(" Title: %s\n", hookedBead.Title)
if hookedBead.Description != "" {
// Show first few lines of description
lines := strings.Split(hookedBead.Description, "\n")
maxLines := 5
if len(lines) > maxLines {
lines = lines[:maxLines]
lines = append(lines, "...")
}
fmt.Println(" Description:")
for _, line := range lines {
fmt.Printf(" %s\n", line)
}
}
fmt.Println()
// Show bead preview using bd show
fmt.Println("**Bead details:**")
cmd := exec.Command("bd", "show", hookedBead.ID)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errMsg := strings.TrimSpace(stderr.String()); errMsg != "" {
fmt.Fprintf(os.Stderr, " bd show %s: %s\n", hookedBead.ID, errMsg)
} else {
fmt.Fprintf(os.Stderr, " bd show %s: %v\n", hookedBead.ID, err)
}
} else {
lines := strings.Split(stdout.String(), "\n")
maxLines := 15
if len(lines) > maxLines {
lines = lines[:maxLines]
lines = append(lines, "...")
}
for _, line := range lines {
fmt.Printf(" %s\n", line)
}
}
fmt.Println()
return true
}
// buildRoleAnnouncement creates the role announcement string for autonomous mode.
func buildRoleAnnouncement(ctx RoleContext) string {
switch ctx.Role {
case RoleMayor:
return "Mayor, checking in."
case RoleDeacon:
return "Deacon, checking in."
case RoleWitness:
return fmt.Sprintf("%s Witness, checking in.", ctx.Rig)
case RoleRefinery:
return fmt.Sprintf("%s Refinery, checking in.", ctx.Rig)
case RolePolecat:
return fmt.Sprintf("%s Polecat %s, checking in.", ctx.Rig, ctx.Polecat)
case RoleCrew:
return fmt.Sprintf("%s Crew %s, checking in.", ctx.Rig, ctx.Polecat)
default:
return "Agent, checking in."
}
}
// 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 {
case RoleCrew:
return fmt.Sprintf("%s/crew/%s", ctx.Rig, ctx.Polecat)
case RolePolecat:
return fmt.Sprintf("%s/polecats/%s", ctx.Rig, ctx.Polecat)
case RoleMayor:
return "mayor"
case RoleDeacon:
return "deacon"
case RoleWitness:
return fmt.Sprintf("%s/witness", ctx.Rig)
case RoleRefinery:
return fmt.Sprintf("%s/refinery", ctx.Rig)
default:
return ""
}
}
// acquireIdentityLock checks and acquires the identity lock for worker roles.
// This prevents multiple agents from claiming the same worker identity.
// Returns an error if another agent already owns this identity.
func acquireIdentityLock(ctx RoleContext) error {
// Only lock worker roles (polecat, crew)
// Infrastructure roles (mayor, witness, refinery, deacon) are singletons
// managed by tmux session names, so they don't need file-based locks
if ctx.Role != RolePolecat && ctx.Role != RoleCrew {
return nil
}
// Create lock for this worker directory
l := lock.New(ctx.WorkDir)
// Determine session ID from environment or context
sessionID := os.Getenv("TMUX_PANE")
if sessionID == "" {
// Fall back to a descriptive identifier
sessionID = fmt.Sprintf("%s/%s", ctx.Rig, ctx.Polecat)
}
// Try to acquire the lock
if err := l.Acquire(sessionID); err != nil {
if errors.Is(err, lock.ErrLocked) {
// Another agent owns this identity
fmt.Printf("\n%s\n\n", style.Bold.Render("⚠️ IDENTITY COLLISION DETECTED"))
fmt.Printf("Another agent already claims this worker identity.\n\n")
// Show lock details
if info, readErr := l.Read(); readErr == nil {
fmt.Printf("Lock holder:\n")
fmt.Printf(" PID: %d\n", info.PID)
fmt.Printf(" Session: %s\n", info.SessionID)
fmt.Printf(" Acquired: %s\n", info.AcquiredAt.Format("2006-01-02 15:04:05"))
fmt.Println()
}
fmt.Printf("To resolve:\n")
fmt.Printf(" 1. Find the other session and close it, OR\n")
fmt.Printf(" 2. Run: gt doctor --fix (cleans stale locks)\n")
fmt.Printf(" 3. If lock is stale: rm %s/.runtime/agent.lock\n", ctx.WorkDir)
fmt.Println()
return fmt.Errorf("cannot claim identity %s/%s: %w", ctx.Rig, ctx.Polecat, err)
}
return fmt.Errorf("acquiring identity lock: %w", err)
}
return nil
}
// NOTE: reportAgentState() and getAgentFields() were removed in gt-zecmc.
// Agent liveness is now discovered from tmux, not recorded in beads.
// "Discover, don't track" principle: observable state should not be recorded.
// getAgentBeadID returns the agent bead ID for the current role.
// Town-level agents (mayor, deacon) use hq- prefix; rig-scoped agents use the rig's prefix.
// Returns empty string for unknown roles.
func getAgentBeadID(ctx RoleContext) string {
switch ctx.Role {
case RoleMayor:
return beads.MayorBeadIDTown()
case RoleDeacon:
return beads.DeaconBeadIDTown()
case RoleWitness:
if ctx.Rig != "" {
prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig)
return beads.WitnessBeadIDWithPrefix(prefix, ctx.Rig)
}
return ""
case RoleRefinery:
if ctx.Rig != "" {
prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig)
return beads.RefineryBeadIDWithPrefix(prefix, ctx.Rig)
}
return ""
case RolePolecat:
if ctx.Rig != "" && ctx.Polecat != "" {
prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig)
return beads.PolecatBeadIDWithPrefix(prefix, ctx.Rig, ctx.Polecat)
}
return ""
case RoleCrew:
if ctx.Rig != "" && ctx.Polecat != "" {
prefix := beads.GetPrefixForRig(ctx.TownRoot, ctx.Rig)
return beads.CrewBeadIDWithPrefix(prefix, ctx.Rig, ctx.Polecat)
}
return ""
default:
return ""
}
}
// ensureBeadsRedirect ensures the .beads/redirect file exists for worktree-based roles.
// This handles cases where git clean or other operations delete the redirect file.
// Uses the shared SetupRedirect helper which handles both tracked and local beads.
func ensureBeadsRedirect(ctx RoleContext) {
// Only applies to worktree-based roles that use shared beads
if ctx.Role != RoleCrew && ctx.Role != RolePolecat && ctx.Role != RoleRefinery {
return
}
// Check if redirect already exists
redirectPath := filepath.Join(ctx.WorkDir, ".beads", "redirect")
if _, err := os.Stat(redirectPath); err == nil {
// Redirect exists, nothing to do
return
}
// Use shared helper - silently ignore errors during prime
_ = beads.SetupRedirect(ctx.TownRoot, ctx.WorkDir)
}
// checkPendingEscalations queries for open escalation beads and displays them prominently.
// This is called on Mayor startup to surface issues needing human attention.
func checkPendingEscalations(ctx RoleContext) {
// Query for open escalations using bd list with tag filter
cmd := exec.Command("bd", "list", "--status=open", "--tag=escalation", "--json")
cmd.Dir = ctx.WorkDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Silently skip - escalation check is best-effort
return
}
// Parse JSON output
var escalations []struct {
ID string `json:"id"`
Title string `json:"title"`
Priority int `json:"priority"`
Description string `json:"description"`
Created string `json:"created"`
}
if err := json.Unmarshal(stdout.Bytes(), &escalations); err != nil || len(escalations) == 0 {
// No escalations or parse error
return
}
// Count by severity
critical := 0
high := 0
medium := 0
for _, e := range escalations {
switch e.Priority {
case 0:
critical++
case 1:
high++
default:
medium++
}
}
// Display prominently
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 🚨 PENDING ESCALATIONS"))
fmt.Printf("There are %d escalation(s) awaiting human attention:\n\n", len(escalations))
if critical > 0 {
fmt.Printf(" 🔴 CRITICAL: %d\n", critical)
}
if high > 0 {
fmt.Printf(" 🟠 HIGH: %d\n", high)
}
if medium > 0 {
fmt.Printf(" 🟡 MEDIUM: %d\n", medium)
}
fmt.Println()
// Show first few escalations
maxShow := 5
if len(escalations) < maxShow {
maxShow = len(escalations)
}
for i := 0; i < maxShow; i++ {
e := escalations[i]
severity := "MEDIUM"
switch e.Priority {
case 0:
severity = "CRITICAL"
case 1:
severity = "HIGH"
}
fmt.Printf(" • [%s] %s (%s)\n", severity, e.Title, e.ID)
}
if len(escalations) > maxShow {
fmt.Printf(" ... and %d more\n", len(escalations)-maxShow)
}
fmt.Println()
fmt.Println("**Action required:** Review escalations with `bd list --tag=escalation`")
fmt.Println("Close resolved ones with `bd close <id> --reason \"resolution\"`")
fmt.Println()
}
// outputCheckpointContext reads and displays any previous session checkpoint.
// This enables crash recovery by showing what the previous session was working on.
func outputCheckpointContext(ctx RoleContext) {
// Only applies to polecats and crew workers
if ctx.Role != RolePolecat && ctx.Role != RoleCrew {
return
}
// Read checkpoint
cp, err := checkpoint.Read(ctx.WorkDir)
if err != nil {
// Silently ignore read errors
return
}
if cp == nil {
// No checkpoint exists
return
}
// Check if checkpoint is stale (older than 24 hours)
if cp.IsStale(24 * time.Hour) {
// Remove stale checkpoint
_ = checkpoint.Remove(ctx.WorkDir)
return
}
// Display checkpoint context
fmt.Println()
fmt.Printf("%s\n\n", style.Bold.Render("## 📌 Previous Session Checkpoint"))
fmt.Printf("A previous session left a checkpoint %s ago.\n\n", cp.Age().Round(time.Minute))
if cp.StepTitle != "" {
fmt.Printf(" **Working on:** %s\n", cp.StepTitle)
}
if cp.MoleculeID != "" {
fmt.Printf(" **Molecule:** %s\n", cp.MoleculeID)
}
if cp.CurrentStep != "" {
fmt.Printf(" **Step:** %s\n", cp.CurrentStep)
}
if cp.HookedBead != "" {
fmt.Printf(" **Hooked bead:** %s\n", cp.HookedBead)
}
if cp.Branch != "" {
fmt.Printf(" **Branch:** %s\n", cp.Branch)
}
if len(cp.ModifiedFiles) > 0 {
fmt.Printf(" **Modified files:** %d\n", len(cp.ModifiedFiles))
// Show first few files
maxShow := 5
if len(cp.ModifiedFiles) < maxShow {
maxShow = len(cp.ModifiedFiles)
}
for i := 0; i < maxShow; i++ {
fmt.Printf(" - %s\n", cp.ModifiedFiles[i])
}
if len(cp.ModifiedFiles) > maxShow {
fmt.Printf(" ... and %d more\n", len(cp.ModifiedFiles)-maxShow)
}
}
if cp.Notes != "" {
fmt.Printf(" **Notes:** %s\n", cp.Notes)
}
fmt.Println()
fmt.Println("Use this context to resume work. The checkpoint will be updated as you progress.")
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 resolution order: GT_SESSION_ID, CLAUDE_SESSION_ID, persisted file, fallback.
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 multiple sources
sessionID := resolveSessionIDForPrime(actor)
// 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)
}
// outputSessionMetadata prints a structured metadata line for seance discovery.
// Format: [GAS TOWN] role:<role> pid:<pid> session:<session_id>
// This enables gt seance to discover sessions from gt prime output.
func outputSessionMetadata(ctx RoleContext) {
if ctx.Role == RoleUnknown {
return
}
// Get agent identity for the role field
actor := getAgentIdentity(ctx)
if actor == "" {
return
}
// Get session ID from multiple sources
sessionID := resolveSessionIDForPrime(actor)
// Output structured metadata line
fmt.Printf("[GAS TOWN] role:%s pid:%d session:%s\n", actor, os.Getpid(), sessionID)
}
// resolveSessionIDForPrime finds the session ID from available sources.
// Priority: GT_SESSION_ID env, CLAUDE_SESSION_ID env, persisted file, fallback.
func resolveSessionIDForPrime(actor string) string {
// 1. Try runtime's session ID lookup (checks GT_SESSION_ID_ENV, then CLAUDE_SESSION_ID)
if id := runtime.SessionIDFromEnv(); id != "" {
return id
}
// 2. Persisted session file (from gt prime --hook)
if id := ReadPersistedSessionID(); id != "" {
return id
}
// 3. Fallback to generated identifier
return fmt.Sprintf("%s-%d", actor, os.Getpid())
}
// hookInput represents the JSON input from LLM runtime hooks.
// Claude Code sends this on stdin for SessionStart hooks.
type hookInput struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Source string `json:"source"` // startup, resume, clear, compact
}
// readHookSessionID reads session ID from available sources in hook mode.
// Priority: stdin JSON, GT_SESSION_ID env, CLAUDE_SESSION_ID env, auto-generate.
func readHookSessionID() (sessionID, source string) {
// 1. Try reading stdin JSON (Claude Code format)
if input := readStdinJSON(); input != nil {
if input.SessionID != "" {
return input.SessionID, input.Source
}
}
// 2. Environment variables
if id := os.Getenv("GT_SESSION_ID"); id != "" {
return id, ""
}
if id := os.Getenv("CLAUDE_SESSION_ID"); id != "" {
return id, ""
}
// 3. Auto-generate
return uuid.New().String(), ""
}
// readStdinJSON attempts to read and parse JSON from stdin.
// Returns nil if stdin is empty, not a pipe, or invalid JSON.
func readStdinJSON() *hookInput {
// Check if stdin has data (non-blocking)
stat, err := os.Stdin.Stat()
if err != nil {
return nil
}
// Only read if stdin is a pipe or has data
if (stat.Mode() & os.ModeCharDevice) != 0 {
// stdin is a terminal, not a pipe - no data to read
return nil
}
// Read first line (JSON should be on one line)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && line == "" {
return nil
}
line = strings.TrimSpace(line)
if line == "" {
return nil
}
var input hookInput
if err := json.Unmarshal([]byte(line), &input); err != nil {
return nil
}
return &input
}
// persistSessionID writes the session ID to .runtime/session_id
// This allows subsequent gt prime calls to find the session ID.
func persistSessionID(dir, sessionID string) {
runtimeDir := filepath.Join(dir, ".runtime")
if err := os.MkdirAll(runtimeDir, 0755); err != nil {
return // Non-fatal
}
sessionFile := filepath.Join(runtimeDir, "session_id")
content := fmt.Sprintf("%s\n%s\n", sessionID, time.Now().Format(time.RFC3339))
_ = os.WriteFile(sessionFile, []byte(content), 0644) // Non-fatal
}
// ReadPersistedSessionID reads a previously persisted session ID.
// Checks cwd first, then town root.
// Returns empty string if not found.
func ReadPersistedSessionID() string {
// Try cwd first
cwd, err := os.Getwd()
if err == nil {
if id := readSessionFile(cwd); id != "" {
return id
}
}
// Try town root
townRoot, err := workspace.FindFromCwd()
if err == nil && townRoot != "" {
if id := readSessionFile(townRoot); id != "" {
return id
}
}
return ""
}
func readSessionFile(dir string) string {
sessionFile := filepath.Join(dir, ".runtime", "session_id")
data, err := os.ReadFile(sessionFile)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
if len(lines) > 0 {
return strings.TrimSpace(lines[0])
}
return ""
}
// checkHandoffMarker checks for a handoff marker file and outputs a warning if found.
// This prevents the "handoff loop" bug where a new session sees /handoff in context
// and incorrectly runs it again. The marker tells the new session: "handoff is DONE,
// the /handoff you see in context was from YOUR PREDECESSOR, not a request for you."
func checkHandoffMarker(workDir string) {
markerPath := filepath.Join(workDir, constants.DirRuntime, constants.FileHandoffMarker)
data, err := os.ReadFile(markerPath)
if err != nil {
// No marker = not post-handoff, normal startup
return
}
// Marker found - this is a post-handoff session
prevSession := strings.TrimSpace(string(data))
// Remove the marker FIRST so we don't warn twice
_ = os.Remove(markerPath)
// Output prominent warning
outputHandoffWarning(prevSession)
}
// checkHandoffMarkerDryRun checks for handoff marker without removing it (for --dry-run).
func checkHandoffMarkerDryRun(workDir string) {
markerPath := filepath.Join(workDir, constants.DirRuntime, constants.FileHandoffMarker)
data, err := os.ReadFile(markerPath)
if err != nil {
// No marker = not post-handoff, normal startup
explain(true, "Post-handoff: no handoff marker found")
return
}
// Marker found - this is a post-handoff session
prevSession := strings.TrimSpace(string(data))
explain(true, fmt.Sprintf("Post-handoff: marker found (predecessor: %s), marker NOT removed in dry-run", prevSession))
// Output the warning but don't remove marker
outputHandoffWarning(prevSession)
}
// outputHandoffWarning outputs the post-handoff warning message.
func outputHandoffWarning(prevSession string) {
fmt.Println()
fmt.Println(style.Bold.Render("╔══════════════════════════════════════════════════════════════════╗"))
fmt.Println(style.Bold.Render("║ ✅ HANDOFF COMPLETE - You are the NEW session ║"))
fmt.Println(style.Bold.Render("╚══════════════════════════════════════════════════════════════════╝"))
fmt.Println()
if prevSession != "" {
fmt.Printf("Your predecessor (%s) handed off to you.\n", prevSession)
}
fmt.Println()
fmt.Println(style.Bold.Render("⚠️ DO NOT run /handoff - that was your predecessor's action."))
fmt.Println(" The /handoff you see in context is NOT a request for you.")
fmt.Println()
fmt.Println("Instead: Check your hook (`gt mol status`) and mail (`gt mail inbox`).")
fmt.Println()
}
// SessionState represents the detected session state for observability.
type SessionState struct {
State string `json:"state"` // normal, post-handoff, crash-recovery, autonomous
Role Role `json:"role"` // detected role
PrevSession string `json:"prev_session,omitempty"` // for post-handoff
CheckpointAge string `json:"checkpoint_age,omitempty"` // for crash-recovery
HookedBead string `json:"hooked_bead,omitempty"` // for autonomous
}
// detectSessionState returns the current session state without side effects.
func detectSessionState(ctx RoleContext) SessionState {
state := SessionState{
State: "normal",
Role: ctx.Role,
}
// Check for handoff marker (post-handoff state)
markerPath := filepath.Join(ctx.WorkDir, constants.DirRuntime, constants.FileHandoffMarker)
if data, err := os.ReadFile(markerPath); err == nil {
state.State = "post-handoff"
state.PrevSession = strings.TrimSpace(string(data))
return state
}
// Check for checkpoint (crash-recovery state) - only for polecat/crew
if ctx.Role == RolePolecat || ctx.Role == RoleCrew {
if cp, err := checkpoint.Read(ctx.WorkDir); err == nil && cp != nil && !cp.IsStale(24*time.Hour) {
state.State = "crash-recovery"
state.CheckpointAge = cp.Age().Round(time.Minute).String()
return state
}
}
// Check for hooked work (autonomous state)
agentID := getAgentIdentity(ctx)
if agentID != "" {
b := beads.New(ctx.WorkDir)
hookedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: agentID,
Priority: -1,
})
if err == nil && len(hookedBeads) > 0 {
state.State = "autonomous"
state.HookedBead = hookedBeads[0].ID
return state
}
// Also check in_progress beads
inProgressBeads, err := b.List(beads.ListOptions{
Status: "in_progress",
Assignee: agentID,
Priority: -1,
})
if err == nil && len(inProgressBeads) > 0 {
state.State = "autonomous"
state.HookedBead = inProgressBeads[0].ID
return state
}
}
return state
}
// outputState outputs only the session state (for --state flag).
func outputState(ctx RoleContext) {
state := detectSessionState(ctx)
fmt.Printf("state: %s\n", state.State)
fmt.Printf("role: %s\n", state.Role)
switch state.State {
case "post-handoff":
if state.PrevSession != "" {
fmt.Printf("prev_session: %s\n", state.PrevSession)
}
case "crash-recovery":
if state.CheckpointAge != "" {
fmt.Printf("checkpoint_age: %s\n", state.CheckpointAge)
}
case "autonomous":
if state.HookedBead != "" {
fmt.Printf("hooked_bead: %s\n", state.HookedBead)
}
}
}
// explain outputs an explanatory message if --explain mode is enabled.
func explain(condition bool, reason string) {
if primeExplain && condition {
fmt.Printf("\n[EXPLAIN] %s\n", reason)
}
}