Unified the two overlapping role detection structs: - RoleContext (prime.go) is now a type alias for RoleInfo - detectRole() now returns RoleInfo directly - Added WorkDir field to RoleInfo - GetRoleWithContext now populates WorkDir This eliminates code duplication between prime.go and role.go while maintaining backward compatibility through the type alias.
1564 lines
48 KiB
Go
1564 lines
48 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/lock"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/templates"
|
|
"github.com/steveyegge/gastown/internal/wisp"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// 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",
|
|
Short: "Output role context for current directory",
|
|
Long: `Detect the agent role from the current directory and output context.
|
|
|
|
Role detection:
|
|
- Town root or mayor/rig/ → 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.`,
|
|
RunE: runPrime,
|
|
}
|
|
|
|
func init() {
|
|
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)
|
|
}
|
|
|
|
// Find town root
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding workspace: %w", err)
|
|
}
|
|
if townRoot == "" {
|
|
return fmt.Errorf("not in a Gas Town workspace")
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Check and acquire identity lock for worker roles
|
|
if err := acquireIdentityLock(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure beads redirect exists for worktree-based roles
|
|
ensureBeadsRedirect(ctx)
|
|
|
|
// Output context
|
|
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)
|
|
|
|
// Output molecule context if working on a molecule step
|
|
outputMoleculeContext(ctx)
|
|
|
|
// Run bd prime to output beads workflow context
|
|
runBdPrime(cwd)
|
|
|
|
// Run gt mail check --inject to inject any pending mail
|
|
runMailCheckInject(cwd)
|
|
|
|
// Output startup directive for roles that should announce themselves
|
|
// Skip if in autonomous mode (slung work provides its own directive)
|
|
if !hasSlungWork {
|
|
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 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
|
|
data := templates.RoleData{
|
|
Role: roleName,
|
|
RigName: ctx.Rig,
|
|
TownRoot: ctx.TownRoot,
|
|
WorkDir: ctx.WorkDir,
|
|
Polecat: ctx.Polecat,
|
|
}
|
|
|
|
// 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 rigs` - List all rigs")
|
|
fmt.Println("- `bd ready` - Issues ready to work")
|
|
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 polecats` - List polecats in this rig")
|
|
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.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.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.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 bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = nil // Ignore stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Silently skip if bd prime fails (beads might not be available)
|
|
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 mol status`")
|
|
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 mol status`")
|
|
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
|
|
fmt.Println(" - If no mol → create patrol: `bd 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 mol status`")
|
|
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 mol status`")
|
|
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
|
|
fmt.Println(" - If no mol → create patrol: `bd 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 mol status`")
|
|
fmt.Println(" - If attachment found → **RUN IT** (no human input needed)")
|
|
fmt.Println(" - If no attachment → await user instruction")
|
|
case RoleDeacon:
|
|
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 mol status`")
|
|
fmt.Println(" - If mol attached → **RUN IT** (resume from current step)")
|
|
fmt.Println(" - If no mol → create patrol: `bd 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 bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = nil // Ignore stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
// Silently skip if mail check fails
|
|
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) {
|
|
if ctx.Role != RoleCrew && ctx.Role != RolePolecat {
|
|
return
|
|
}
|
|
|
|
// Check for pinned beads with attachments
|
|
b := beads.New(ctx.WorkDir)
|
|
|
|
// Build assignee string based on role
|
|
var assignee string
|
|
switch ctx.Role {
|
|
case RoleCrew:
|
|
assignee = fmt.Sprintf("%s/crew/%s", ctx.Rig, ctx.Polecat)
|
|
case RolePolecat:
|
|
assignee = fmt.Sprintf("%s/%s", ctx.Rig, ctx.Polecat)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
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.
|
|
// bd wisp creates wisp-marked issues that are auto-deleted on squash.
|
|
func outputDeaconPatrolContext(ctx RoleContext) {
|
|
fmt.Println()
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## 🔄 Patrol Status (Wisp-based)"))
|
|
|
|
// Check for active mol-deacon-patrol molecules in rig beads
|
|
// A patrol is "active" if it has open wisp children (steps to execute)
|
|
// After squash, the root stays open but has no open children - that's "completed"
|
|
rigBeadsDir := filepath.Join(ctx.TownRoot, "gastown", "mayor", "rig")
|
|
|
|
// First find mol-deacon-patrol molecules (exclude template)
|
|
cmdList := exec.Command("bd", "list", "--status=open", "--type=epic")
|
|
cmdList.Dir = rigBeadsDir
|
|
var stdoutList bytes.Buffer
|
|
cmdList.Stdout = &stdoutList
|
|
cmdList.Stderr = nil
|
|
errList := cmdList.Run()
|
|
|
|
// Find a patrol molecule with open children
|
|
hasPatrol := false
|
|
var patrolLine string
|
|
var patrolID string
|
|
if errList == nil {
|
|
lines := strings.Split(stdoutList.String(), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "mol-deacon-patrol") && !strings.Contains(line, "[template]") {
|
|
// Extract the ID (first word)
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
molID := parts[0]
|
|
// Check if this molecule has open children using bd show
|
|
cmdShow := exec.Command("bd", "show", molID)
|
|
cmdShow.Dir = rigBeadsDir
|
|
var stdoutShow bytes.Buffer
|
|
cmdShow.Stdout = &stdoutShow
|
|
cmdShow.Stderr = nil
|
|
if cmdShow.Run() == nil {
|
|
showOutput := stdoutShow.String()
|
|
// Check for "- open]" in children section (open child steps)
|
|
if strings.Contains(showOutput, "- open]") {
|
|
hasPatrol = true
|
|
patrolLine = line
|
|
patrolID = molID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasPatrol {
|
|
// No active patrol - AUTO-SPAWN one
|
|
fmt.Println("Status: **No active patrol** - creating mol-deacon-patrol...")
|
|
fmt.Println()
|
|
|
|
// Find the proto ID for mol-deacon-patrol
|
|
cmdCatalog := exec.Command("bd", "--no-daemon", "mol", "catalog")
|
|
cmdCatalog.Dir = rigBeadsDir
|
|
var stdoutCatalog bytes.Buffer
|
|
cmdCatalog.Stdout = &stdoutCatalog
|
|
cmdCatalog.Stderr = nil
|
|
|
|
if cmdCatalog.Run() != nil {
|
|
fmt.Println(style.Dim.Render("Failed to list molecule catalog. Run `bd mol catalog` to troubleshoot."))
|
|
return
|
|
}
|
|
|
|
// Find mol-deacon-patrol in catalog
|
|
var protoID string
|
|
catalogLines := strings.Split(stdoutCatalog.String(), "\n")
|
|
for _, line := range catalogLines {
|
|
if strings.Contains(line, "mol-deacon-patrol") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
// Strip trailing colon from ID (catalog format: "gt-xxx: title")
|
|
protoID = strings.TrimSuffix(parts[0], ":")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if protoID == "" {
|
|
fmt.Println(style.Dim.Render("Proto mol-deacon-patrol not found in catalog. Run `bd mol register` first."))
|
|
return
|
|
}
|
|
|
|
// Create the patrol wisp
|
|
cmdSpawn := exec.Command("bd", "--no-daemon", "wisp", protoID, "--assignee", "deacon")
|
|
cmdSpawn.Dir = rigBeadsDir
|
|
var stdoutSpawn, stderrSpawn bytes.Buffer
|
|
cmdSpawn.Stdout = &stdoutSpawn
|
|
cmdSpawn.Stderr = &stderrSpawn
|
|
|
|
if err := cmdSpawn.Run(); err != nil {
|
|
fmt.Printf("Failed to create patrol wisp: %s\n", stderrSpawn.String())
|
|
fmt.Println(style.Dim.Render("Run manually: bd --no-daemon wisp" + protoID))
|
|
return
|
|
}
|
|
|
|
// Parse the created molecule ID from output
|
|
spawnOutput := stdoutSpawn.String()
|
|
fmt.Printf("✓ Created patrol wisp\n")
|
|
|
|
// Extract molecule ID from output (format: "Created molecule: gt-xxxx")
|
|
for _, line := range strings.Split(spawnOutput, "\n") {
|
|
if strings.Contains(line, "molecule:") || strings.Contains(line, "Created") {
|
|
parts := strings.Fields(line)
|
|
for _, p := range parts {
|
|
if strings.HasPrefix(p, "gt-") {
|
|
patrolID = p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if patrolID != "" {
|
|
fmt.Printf("Patrol ID: %s\n\n", patrolID)
|
|
}
|
|
} else {
|
|
// Has active patrol - show status
|
|
fmt.Println("Status: **Patrol Active**")
|
|
fmt.Printf("Patrol: %s\n\n", strings.TrimSpace(patrolLine))
|
|
}
|
|
|
|
// Show patrol work loop instructions
|
|
fmt.Println("**Deacon Patrol Work Loop:**")
|
|
fmt.Println("Run from gastown/mayor/rig/:")
|
|
fmt.Println("1. Check next step: `bd ready`")
|
|
fmt.Println("2. Execute the step (heartbeat, mail, health checks, etc.)")
|
|
fmt.Println("3. Close step: `bd close <step-id>`")
|
|
fmt.Println("4. Check next: `bd ready`")
|
|
fmt.Println("5. At cycle end (loop-or-exit step):")
|
|
fmt.Println(" - Generate summary of patrol cycle")
|
|
fmt.Println(" - Squash: `bd --no-daemon mol squash <mol-id> --summary \"<summary>\"`")
|
|
fmt.Println(" - Loop back to create new wisp, or exit if context high")
|
|
if patrolID != "" {
|
|
fmt.Println()
|
|
fmt.Printf("Current patrol ID: %s\n", patrolID)
|
|
}
|
|
}
|
|
|
|
// outputWitnessPatrolContext shows patrol molecule status for the Witness.
|
|
// Witness AUTO-BONDS its patrol molecule on startup if one isn't already running.
|
|
// This ensures polecat health is always monitored.
|
|
func outputWitnessPatrolContext(ctx RoleContext) {
|
|
fmt.Println()
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## 👁 Witness Patrol Status"))
|
|
|
|
// Witness works from its own rig clone: <rig>/witness/rig/
|
|
// Beads are in the current WorkDir
|
|
witnessBeadsDir := ctx.WorkDir
|
|
|
|
// Find mol-witness-patrol molecules (exclude template)
|
|
// Look for in-progress patrol first (resumable)
|
|
cmdList := exec.Command("bd", "--no-daemon", "list", "--status=in_progress", "--type=epic")
|
|
cmdList.Dir = witnessBeadsDir
|
|
var stdoutList bytes.Buffer
|
|
cmdList.Stdout = &stdoutList
|
|
cmdList.Stderr = nil
|
|
errList := cmdList.Run()
|
|
|
|
hasPatrol := false
|
|
var patrolID string
|
|
var patrolLine string
|
|
|
|
if errList == nil {
|
|
lines := strings.Split(stdoutList.String(), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "mol-witness-patrol") && !strings.Contains(line, "[template]") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
patrolID = parts[0]
|
|
patrolLine = line
|
|
hasPatrol = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check for open patrols with open children (active wisp)
|
|
if !hasPatrol {
|
|
cmdOpen := exec.Command("bd", "--no-daemon", "list", "--status=open", "--type=epic")
|
|
cmdOpen.Dir = witnessBeadsDir
|
|
var stdoutOpen bytes.Buffer
|
|
cmdOpen.Stdout = &stdoutOpen
|
|
cmdOpen.Stderr = nil
|
|
if cmdOpen.Run() == nil {
|
|
lines := strings.Split(stdoutOpen.String(), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "mol-witness-patrol") && !strings.Contains(line, "[template]") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
molID := parts[0]
|
|
// Check if this molecule has open children
|
|
cmdShow := exec.Command("bd", "--no-daemon", "show", molID)
|
|
cmdShow.Dir = witnessBeadsDir
|
|
var stdoutShow bytes.Buffer
|
|
cmdShow.Stdout = &stdoutShow
|
|
cmdShow.Stderr = nil
|
|
if cmdShow.Run() == nil {
|
|
showOutput := stdoutShow.String()
|
|
if strings.Contains(showOutput, "- open]") || strings.Contains(showOutput, "- in_progress]") {
|
|
hasPatrol = true
|
|
patrolID = molID
|
|
patrolLine = line
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasPatrol {
|
|
// No active patrol - AUTO-SPAWN one
|
|
fmt.Println("Status: **No active patrol** - creating mol-witness-patrol...")
|
|
fmt.Println()
|
|
|
|
// Find the proto ID for mol-witness-patrol
|
|
cmdCatalog := exec.Command("bd", "--no-daemon", "mol", "catalog")
|
|
cmdCatalog.Dir = witnessBeadsDir
|
|
var stdoutCatalog bytes.Buffer
|
|
cmdCatalog.Stdout = &stdoutCatalog
|
|
cmdCatalog.Stderr = nil
|
|
|
|
if cmdCatalog.Run() != nil {
|
|
fmt.Println(style.Dim.Render("Failed to list molecule catalog. Run `bd mol catalog` to troubleshoot."))
|
|
return
|
|
}
|
|
|
|
// Find mol-witness-patrol in catalog
|
|
var protoID string
|
|
catalogLines := strings.Split(stdoutCatalog.String(), "\n")
|
|
for _, line := range catalogLines {
|
|
if strings.Contains(line, "mol-witness-patrol") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
// Strip trailing colon from ID (catalog format: "gt-xxx: title")
|
|
protoID = strings.TrimSuffix(parts[0], ":")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if protoID == "" {
|
|
fmt.Println(style.Dim.Render("Proto mol-witness-patrol not found in catalog. Run `bd mol register` first."))
|
|
return
|
|
}
|
|
|
|
// Create the patrol wisp
|
|
cmdSpawn := exec.Command("bd", "--no-daemon", "wisp", protoID, "--assignee", ctx.Rig+"/witness")
|
|
cmdSpawn.Dir = witnessBeadsDir
|
|
var stdoutSpawn, stderrSpawn bytes.Buffer
|
|
cmdSpawn.Stdout = &stdoutSpawn
|
|
cmdSpawn.Stderr = &stderrSpawn
|
|
|
|
if err := cmdSpawn.Run(); err != nil {
|
|
fmt.Printf("Failed to create patrol wisp: %s\n", stderrSpawn.String())
|
|
fmt.Println(style.Dim.Render("Run manually: bd --no-daemon wisp" + protoID))
|
|
return
|
|
}
|
|
|
|
// Parse the created molecule ID from output
|
|
spawnOutput := stdoutSpawn.String()
|
|
fmt.Printf("✓ Created patrol wisp\n")
|
|
|
|
// Extract molecule ID from output (format: "Created molecule: gt-xxxx")
|
|
for _, line := range strings.Split(spawnOutput, "\n") {
|
|
if strings.Contains(line, "molecule:") || strings.Contains(line, "Created") {
|
|
parts := strings.Fields(line)
|
|
for _, p := range parts {
|
|
if strings.HasPrefix(p, "gt-") {
|
|
patrolID = p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if patrolID != "" {
|
|
fmt.Printf("Patrol ID: %s\n\n", patrolID)
|
|
}
|
|
} else {
|
|
// Has active patrol - show status
|
|
fmt.Println("Status: **Patrol Active**")
|
|
fmt.Printf("Patrol: %s\n\n", strings.TrimSpace(patrolLine))
|
|
}
|
|
|
|
// Show patrol work loop instructions
|
|
fmt.Println("**Witness Patrol Work Loop:**")
|
|
fmt.Println("1. Check inbox: `gt mail inbox`")
|
|
fmt.Println("2. Check next step: `bd ready`")
|
|
fmt.Println("3. Execute the step (survey polecats, inspect, nudge, etc.)")
|
|
fmt.Println("4. Close step: `bd close <step-id>`")
|
|
fmt.Println("5. Check next: `bd ready`")
|
|
fmt.Println("6. At cycle end (burn-or-loop step):")
|
|
fmt.Println(" - Generate summary of patrol cycle")
|
|
fmt.Println(" - Squash: `bd --no-daemon mol squash <mol-id> --summary \"<summary>\"`")
|
|
fmt.Println(" - Loop back to create new wisp, or exit if context high")
|
|
if patrolID != "" {
|
|
fmt.Println()
|
|
fmt.Printf("Current patrol ID: %s\n", patrolID)
|
|
}
|
|
}
|
|
|
|
// outputRefineryPatrolContext shows patrol molecule status for the Refinery.
|
|
// Unlike other patrol roles, Refinery AUTO-BONDS its patrol molecule on startup
|
|
// if one isn't already running. This ensures the merge queue is always monitored.
|
|
func outputRefineryPatrolContext(ctx RoleContext) {
|
|
fmt.Println()
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## 🔧 Refinery Patrol Status"))
|
|
|
|
// Refinery works from its own rig clone: <rig>/refinery/rig/
|
|
// Beads are in the current WorkDir
|
|
refineryBeadsDir := ctx.WorkDir
|
|
|
|
// Find mol-refinery-patrol molecules (exclude template)
|
|
// Look for in-progress patrol first (resumable)
|
|
cmdList := exec.Command("bd", "--no-daemon", "list", "--status=in_progress", "--type=epic")
|
|
cmdList.Dir = refineryBeadsDir
|
|
var stdoutList bytes.Buffer
|
|
cmdList.Stdout = &stdoutList
|
|
cmdList.Stderr = nil
|
|
errList := cmdList.Run()
|
|
|
|
hasPatrol := false
|
|
var patrolID string
|
|
var patrolLine string
|
|
|
|
if errList == nil {
|
|
lines := strings.Split(stdoutList.String(), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "mol-refinery-patrol") && !strings.Contains(line, "[template]") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
patrolID = parts[0]
|
|
patrolLine = line
|
|
hasPatrol = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also check for open patrols with open children (active wisp)
|
|
if !hasPatrol {
|
|
cmdOpen := exec.Command("bd", "--no-daemon", "list", "--status=open", "--type=epic")
|
|
cmdOpen.Dir = refineryBeadsDir
|
|
var stdoutOpen bytes.Buffer
|
|
cmdOpen.Stdout = &stdoutOpen
|
|
cmdOpen.Stderr = nil
|
|
if cmdOpen.Run() == nil {
|
|
lines := strings.Split(stdoutOpen.String(), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "mol-refinery-patrol") && !strings.Contains(line, "[template]") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
molID := parts[0]
|
|
// Check if this molecule has open children
|
|
cmdShow := exec.Command("bd", "--no-daemon", "show", molID)
|
|
cmdShow.Dir = refineryBeadsDir
|
|
var stdoutShow bytes.Buffer
|
|
cmdShow.Stdout = &stdoutShow
|
|
cmdShow.Stderr = nil
|
|
if cmdShow.Run() == nil {
|
|
showOutput := stdoutShow.String()
|
|
if strings.Contains(showOutput, "- open]") || strings.Contains(showOutput, "- in_progress]") {
|
|
hasPatrol = true
|
|
patrolID = molID
|
|
patrolLine = line
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasPatrol {
|
|
// No active patrol - AUTO-SPAWN one
|
|
fmt.Println("Status: **No active patrol** - creating mol-refinery-patrol...")
|
|
fmt.Println()
|
|
|
|
// Find the proto ID for mol-refinery-patrol
|
|
cmdCatalog := exec.Command("bd", "--no-daemon", "mol", "catalog")
|
|
cmdCatalog.Dir = refineryBeadsDir
|
|
var stdoutCatalog bytes.Buffer
|
|
cmdCatalog.Stdout = &stdoutCatalog
|
|
cmdCatalog.Stderr = nil
|
|
|
|
if cmdCatalog.Run() != nil {
|
|
fmt.Println(style.Dim.Render("Failed to list molecule catalog. Run `bd mol catalog` to troubleshoot."))
|
|
return
|
|
}
|
|
|
|
// Find mol-refinery-patrol in catalog
|
|
var protoID string
|
|
catalogLines := strings.Split(stdoutCatalog.String(), "\n")
|
|
for _, line := range catalogLines {
|
|
if strings.Contains(line, "mol-refinery-patrol") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) > 0 {
|
|
// Strip trailing colon from ID (catalog format: "gt-xxx: title")
|
|
protoID = strings.TrimSuffix(parts[0], ":")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if protoID == "" {
|
|
fmt.Println(style.Dim.Render("Proto mol-refinery-patrol not found in catalog. Run `bd mol register` first."))
|
|
return
|
|
}
|
|
|
|
// Create the patrol wisp
|
|
cmdSpawn := exec.Command("bd", "--no-daemon", "wisp", protoID, "--assignee", ctx.Rig+"/refinery")
|
|
cmdSpawn.Dir = refineryBeadsDir
|
|
var stdoutSpawn, stderrSpawn bytes.Buffer
|
|
cmdSpawn.Stdout = &stdoutSpawn
|
|
cmdSpawn.Stderr = &stderrSpawn
|
|
|
|
if err := cmdSpawn.Run(); err != nil {
|
|
fmt.Printf("Failed to create patrol wisp: %s\n", stderrSpawn.String())
|
|
fmt.Println(style.Dim.Render("Run manually: bd --no-daemon wisp" + protoID))
|
|
return
|
|
}
|
|
|
|
// Parse the created molecule ID from output
|
|
spawnOutput := stdoutSpawn.String()
|
|
fmt.Printf("✓ Created patrol wisp\n")
|
|
|
|
// Extract molecule ID from output (format: "Created molecule: gt-xxxx")
|
|
for _, line := range strings.Split(spawnOutput, "\n") {
|
|
if strings.Contains(line, "molecule:") || strings.Contains(line, "Created") {
|
|
parts := strings.Fields(line)
|
|
for _, p := range parts {
|
|
if strings.HasPrefix(p, "gt-") {
|
|
patrolID = p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if patrolID != "" {
|
|
fmt.Printf("Patrol ID: %s\n\n", patrolID)
|
|
}
|
|
} else {
|
|
// Has active patrol - show status
|
|
fmt.Println("Status: **Patrol Active**")
|
|
fmt.Printf("Patrol: %s\n\n", strings.TrimSpace(patrolLine))
|
|
}
|
|
|
|
// Show patrol work loop instructions
|
|
fmt.Println("**Refinery Patrol Work Loop:**")
|
|
fmt.Println("1. Check inbox: `gt mail inbox`")
|
|
fmt.Println("2. Check next step: `bd ready`")
|
|
fmt.Println("3. Execute the step (queue scan, process branch, tests, merge)")
|
|
fmt.Println("4. Close step: `bd close <step-id>`")
|
|
fmt.Println("5. Check next: `bd ready`")
|
|
fmt.Println("6. At cycle end (burn-or-loop step):")
|
|
fmt.Println(" - Generate summary of patrol cycle")
|
|
fmt.Println(" - Squash: `bd --no-daemon mol squash <mol-id> --summary \"<summary>\"`")
|
|
fmt.Println(" - Loop back to create new wisp, or exit if context high")
|
|
if patrolID != "" {
|
|
fmt.Println()
|
|
fmt.Printf("Current patrol ID: %s\n", patrolID)
|
|
}
|
|
}
|
|
|
|
// checkSlungWork checks for slung work on the agent's hook.
|
|
// If found, displays it prominently and tells the agent to execute it.
|
|
// The wisp is burned after the agent acknowledges it.
|
|
// Returns true if slung work was found (caller should skip normal startup directive).
|
|
func checkSlungWork(ctx RoleContext) bool {
|
|
// Determine agent identity for hook lookup
|
|
agentID := getAgentIdentity(ctx)
|
|
if agentID == "" {
|
|
return false
|
|
}
|
|
|
|
// Get the git clone root (hooks are stored at clone root, not cwd)
|
|
cloneRoot, err := getGitRoot()
|
|
if err != nil {
|
|
// Not in a git repo - can't have hooks
|
|
return false
|
|
}
|
|
|
|
sw, err := wisp.ReadHook(cloneRoot, agentID)
|
|
if err != nil {
|
|
if errors.Is(err, wisp.ErrNoHook) {
|
|
// No hook - normal case, nothing to do
|
|
return false
|
|
}
|
|
// Log other errors (permission, corruption) but continue
|
|
fmt.Printf("%s Warning: error reading hook: %v\n", style.Dim.Render("⚠"), err)
|
|
return false
|
|
}
|
|
|
|
// Verify bead exists before showing autonomous mode
|
|
// Try multiple beads locations: cwd, clone root, and rig's beads dir
|
|
var stdout bytes.Buffer
|
|
beadExists := false
|
|
beadSearchDirs := []string{ctx.WorkDir, cloneRoot}
|
|
// For Mayor, also try the gastown rig's beads location
|
|
if ctx.Role == RoleMayor {
|
|
beadSearchDirs = append(beadSearchDirs, filepath.Join(ctx.TownRoot, "gastown", "mayor", "rig"))
|
|
}
|
|
for _, dir := range beadSearchDirs {
|
|
cmd := exec.Command("bd", "show", sw.BeadID)
|
|
cmd.Dir = dir
|
|
stdout.Reset()
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = nil
|
|
if cmd.Run() == nil {
|
|
beadExists = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !beadExists {
|
|
fmt.Println()
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## 🎯 SLUNG WORK ON HOOK"))
|
|
fmt.Printf(" Bead ID: %s\n", style.Bold.Render(sw.BeadID))
|
|
fmt.Printf(" %s Bead %s not found! It may have been deleted.\n",
|
|
style.Bold.Render("⚠ WARNING:"), sw.BeadID)
|
|
fmt.Println(" The hook will NOT be burned. Investigate this issue.")
|
|
fmt.Println()
|
|
// Don't burn - leave hook for debugging
|
|
return false
|
|
}
|
|
|
|
// Build the role announcement string
|
|
roleAnnounce := buildRoleAnnouncement(ctx)
|
|
|
|
// Found slung work! Display AUTONOMOUS MODE prominently
|
|
fmt.Println()
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## 🚨 AUTONOMOUS WORK MODE 🚨"))
|
|
fmt.Println("Work is slung on your hook. After announcing your role, begin IMMEDIATELY.")
|
|
fmt.Println()
|
|
fmt.Println("1. Announce: \"" + roleAnnounce + "\" (ONE line, no elaboration)")
|
|
fmt.Printf("2. Then IMMEDIATELY run: `bd show %s`\n", sw.BeadID)
|
|
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()
|
|
|
|
// Show the slung work details
|
|
fmt.Printf("%s\n\n", style.Bold.Render("## Slung Work"))
|
|
fmt.Printf(" Bead ID: %s\n", style.Bold.Render(sw.BeadID))
|
|
if sw.Subject != "" {
|
|
fmt.Printf(" Subject: %s\n", sw.Subject)
|
|
}
|
|
if sw.Context != "" {
|
|
fmt.Printf(" Context: %s\n", sw.Context)
|
|
}
|
|
fmt.Printf(" Slung by: %s at %s\n", sw.CreatedBy, sw.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
fmt.Println()
|
|
|
|
// Show bead preview (first 15 lines)
|
|
lines := strings.Split(stdout.String(), "\n")
|
|
maxLines := 15
|
|
if len(lines) > maxLines {
|
|
lines = lines[:maxLines]
|
|
lines = append(lines, "...")
|
|
}
|
|
fmt.Println("**Bead preview:**")
|
|
for _, line := range lines {
|
|
fmt.Printf(" %s\n", line)
|
|
}
|
|
fmt.Println()
|
|
|
|
// Burn the hook now that it's been read and verified
|
|
if err := wisp.BurnHook(cloneRoot, agentID); err != nil {
|
|
fmt.Printf("%s Warning: could not burn hook: %v\n", style.Dim.Render("⚠"), err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ensureBeadsRedirect ensures the .beads/redirect file exists for worktree-based roles.
|
|
// This handles cases where git clean or other operations delete the redirect file.
|
|
func ensureBeadsRedirect(ctx RoleContext) {
|
|
// Only applies to crew and polecat roles (they use shared beads)
|
|
if ctx.Role != RoleCrew && ctx.Role != RolePolecat {
|
|
return
|
|
}
|
|
|
|
// Check if redirect already exists
|
|
beadsDir := filepath.Join(ctx.WorkDir, ".beads")
|
|
redirectPath := filepath.Join(beadsDir, "redirect")
|
|
|
|
if _, err := os.Stat(redirectPath); err == nil {
|
|
// Redirect exists, nothing to do
|
|
return
|
|
}
|
|
|
|
// Determine the correct redirect path based on role and rig structure
|
|
var redirectContent string
|
|
|
|
// Get the rig root (parent of crew/ or polecats/)
|
|
var rigRoot string
|
|
relPath, err := filepath.Rel(ctx.TownRoot, ctx.WorkDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
|
if len(parts) >= 1 {
|
|
rigRoot = filepath.Join(ctx.TownRoot, parts[0])
|
|
} else {
|
|
return
|
|
}
|
|
|
|
// Check for shared beads locations in order of preference:
|
|
// 1. rig/mayor/rig/.beads/ (if mayor rig clone exists)
|
|
// 2. rig/.beads/ (rig root beads)
|
|
mayorRigBeads := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
|
rigRootBeads := filepath.Join(rigRoot, ".beads")
|
|
|
|
if _, err := os.Stat(mayorRigBeads); err == nil {
|
|
// Use mayor/rig/.beads
|
|
if ctx.Role == RoleCrew {
|
|
// crew/<name>/.beads -> ../../mayor/rig/.beads
|
|
redirectContent = "../../mayor/rig/.beads"
|
|
} else {
|
|
// polecats/<name>/.beads -> ../../mayor/rig/.beads
|
|
redirectContent = "../../mayor/rig/.beads"
|
|
}
|
|
} else if _, err := os.Stat(rigRootBeads); err == nil {
|
|
// Use rig root .beads
|
|
if ctx.Role == RoleCrew {
|
|
// crew/<name>/.beads -> ../../.beads
|
|
redirectContent = "../../.beads"
|
|
} else {
|
|
// polecats/<name>/.beads -> ../../.beads
|
|
redirectContent = "../../.beads"
|
|
}
|
|
} else {
|
|
// No shared beads found, nothing to redirect to
|
|
return
|
|
}
|
|
|
|
// Create .beads directory if needed
|
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
|
// Silently fail - not critical
|
|
return
|
|
}
|
|
|
|
// Write redirect file
|
|
if err := os.WriteFile(redirectPath, []byte(redirectContent+"\n"), 0644); err != nil {
|
|
// Silently fail - not critical
|
|
return
|
|
}
|
|
|
|
// Note: We don't print a message here to avoid cluttering prime output
|
|
// The redirect is silently restored
|
|
}
|