refactor(prime): split 1833-line file into logical modules
Extract prime.go into focused files: - prime_session.go: session ID handling, hooks, persistence - prime_output.go: all output/rendering functions - prime_molecule.go: molecule workflow context - prime_state.go: handoff markers, session state detection Main prime.go now ~730 lines with core flow visible as "table of contents". No behavior changes - pure file organization following Go idioms. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
303
internal/cmd/prime_molecule.go
Normal file
303
internal/cmd/prime_molecule.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
527
internal/cmd/prime_output.go
Normal file
527
internal/cmd/prime_output.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/checkpoint"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/session"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/templates"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// outputPrimeContext outputs the role-specific context using templates or fallback.
|
||||
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)"))
|
||||
}
|
||||
|
||||
// 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`")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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.")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
197
internal/cmd/prime_session.go
Normal file
197
internal/cmd/prime_session.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
116
internal/cmd/prime_state.go
Normal file
116
internal/cmd/prime_state.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/checkpoint"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user