refactor(prime): extract findHookedBead to eliminate duplication
detectSessionState() and checkSlungWork() both contained identical logic for finding hooked/in_progress beads assigned to an agent. Extracted this into findHookedBead() helper function. Also includes priming subsystem improvements from mayor: - Add --dry-run flag for testing without side effects - Add --state flag to output detected state only - Add --explain flag to show why sections are included - Add missing filepath import to beads.go Fixes: bd-hvwnb Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/runtime"
|
||||
|
||||
@@ -30,6 +30,9 @@ import (
|
||||
)
|
||||
|
||||
var primeHookMode bool
|
||||
var primeDryRun bool
|
||||
var primeState bool
|
||||
var primeExplain bool
|
||||
|
||||
// Role represents a detected agent role.
|
||||
type Role string
|
||||
@@ -75,6 +78,12 @@ HOOK MODE (--hook):
|
||||
func init() {
|
||||
primeCmd.Flags().BoolVar(&primeHookMode, "hook", false,
|
||||
"Hook mode: read session ID from stdin JSON (for LLM runtime hooks)")
|
||||
primeCmd.Flags().BoolVar(&primeDryRun, "dry-run", false,
|
||||
"Show what would be injected without side effects (no marker removal, no bd prime, no mail)")
|
||||
primeCmd.Flags().BoolVar(&primeState, "state", false,
|
||||
"Show detected session state only (normal/post-handoff/crash/autonomous)")
|
||||
primeCmd.Flags().BoolVar(&primeExplain, "explain", false,
|
||||
"Show why each section was included")
|
||||
rootCmd.AddCommand(primeCmd)
|
||||
}
|
||||
|
||||
@@ -109,14 +118,17 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
// Handle hook mode: read session ID from stdin and persist it
|
||||
if primeHookMode {
|
||||
sessionID, source := readHookSessionID()
|
||||
persistSessionID(townRoot, sessionID)
|
||||
if cwd != townRoot {
|
||||
persistSessionID(cwd, sessionID)
|
||||
if !primeDryRun {
|
||||
persistSessionID(townRoot, sessionID)
|
||||
if cwd != townRoot {
|
||||
persistSessionID(cwd, sessionID)
|
||||
}
|
||||
}
|
||||
// Set environment for this process (affects event emission below)
|
||||
_ = os.Setenv("GT_SESSION_ID", sessionID)
|
||||
_ = os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
|
||||
// Output session beacon
|
||||
explain(true, "Session beacon: hook mode enabled, session ID from stdin")
|
||||
fmt.Printf("[session:%s]\n", sessionID)
|
||||
if source != "" {
|
||||
fmt.Printf("[source:%s]\n", source)
|
||||
@@ -124,7 +136,12 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// Check for handoff marker (prevents handoff loop bug)
|
||||
checkHandoffMarker(cwd)
|
||||
// In dry-run mode, use the non-mutating version
|
||||
if primeDryRun {
|
||||
checkHandoffMarkerDryRun(cwd)
|
||||
} else {
|
||||
checkHandoffMarker(cwd)
|
||||
}
|
||||
|
||||
// Get role using env-aware detection
|
||||
roleInfo, err := GetRoleWithContext(cwd, townRoot)
|
||||
@@ -156,14 +173,22 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
WorkDir: cwd,
|
||||
}
|
||||
|
||||
// --state mode: output state only and exit
|
||||
if primeState {
|
||||
outputState(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check and acquire identity lock for worker roles
|
||||
if err := acquireIdentityLock(ctx); err != nil {
|
||||
return err
|
||||
if !primeDryRun {
|
||||
if err := acquireIdentityLock(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure beads redirect exists for worktree-based roles
|
||||
// Skip if there's a role/location mismatch to avoid creating bad redirects
|
||||
if !roleInfo.Mismatch {
|
||||
if !roleInfo.Mismatch && !primeDryRun {
|
||||
ensureBeadsRedirect(ctx)
|
||||
}
|
||||
|
||||
@@ -172,12 +197,16 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
// "Discover, don't track" principle: reality is truth, state is derived.
|
||||
|
||||
// Emit session_start event for seance discovery
|
||||
emitSessionEvent(ctx)
|
||||
if !primeDryRun {
|
||||
emitSessionEvent(ctx)
|
||||
}
|
||||
|
||||
// Output session metadata for seance discovery
|
||||
explain(true, "Session metadata: always included for seance discovery")
|
||||
outputSessionMetadata(ctx)
|
||||
|
||||
// Output context
|
||||
explain(true, fmt.Sprintf("Role context: detected role is %s", ctx.Role))
|
||||
if err := outputPrimeContext(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -191,6 +220,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
// Check for slung work on hook (from gt sling)
|
||||
// If found, we're in autonomous mode - skip normal startup directive
|
||||
hasSlungWork := checkSlungWork(ctx)
|
||||
explain(hasSlungWork, "Autonomous mode: hooked/in-progress work detected")
|
||||
|
||||
// Output molecule context if working on a molecule step
|
||||
outputMoleculeContext(ctx)
|
||||
@@ -199,10 +229,18 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
outputCheckpointContext(ctx)
|
||||
|
||||
// Run bd prime to output beads workflow context
|
||||
runBdPrime(cwd)
|
||||
if !primeDryRun {
|
||||
runBdPrime(cwd)
|
||||
} else {
|
||||
explain(true, "bd prime: skipped in dry-run mode")
|
||||
}
|
||||
|
||||
// Run gt mail check --inject to inject any pending mail
|
||||
runMailCheckInject(cwd)
|
||||
if !primeDryRun {
|
||||
runMailCheckInject(cwd)
|
||||
} else {
|
||||
explain(true, "gt mail check --inject: skipped in dry-run mode")
|
||||
}
|
||||
|
||||
// For Mayor, check for pending escalations
|
||||
if ctx.Role == RoleMayor {
|
||||
@@ -212,6 +250,7 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
||||
// Output startup directive for roles that should announce themselves
|
||||
// Skip if in autonomous mode (slung work provides its own directive)
|
||||
if !hasSlungWork {
|
||||
explain(true, "Startup directive: normal mode (no hooked work)")
|
||||
outputStartupDirective(ctx)
|
||||
}
|
||||
|
||||
@@ -1029,45 +1068,50 @@ func outputRefineryPatrolContext(ctx RoleContext) {
|
||||
outputPatrolContext(cfg)
|
||||
}
|
||||
|
||||
// checkSlungWork checks for hooked work on the agent's hook.
|
||||
// If found, displays AUTONOMOUS WORK MODE and tells the agent to execute immediately.
|
||||
// Returns true if hooked work was found (caller should skip normal startup directive).
|
||||
func checkSlungWork(ctx RoleContext) bool {
|
||||
// Determine agent identity
|
||||
// findHookedBead returns the bead currently on an agent's hook, if any.
|
||||
// It checks both "hooked" status and "in_progress" status (for interrupted sessions
|
||||
// where work was claimed but the session was interrupted before completion).
|
||||
// Returns nil if no hooked bead is found.
|
||||
func findHookedBead(ctx RoleContext) *beads.Issue {
|
||||
agentID := getAgentIdentity(ctx)
|
||||
if agentID == "" {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for hooked beads (work on the agent's hook)
|
||||
b := beads.New(ctx.WorkDir)
|
||||
|
||||
// Check for hooked beads
|
||||
hookedBeads, err := b.List(beads.ListOptions{
|
||||
Status: beads.StatusHooked,
|
||||
Assignee: agentID,
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil {
|
||||
if err == nil && len(hookedBeads) > 0 {
|
||||
return hookedBeads[0]
|
||||
}
|
||||
|
||||
// Also check in_progress beads (for interrupted sessions)
|
||||
inProgressBeads, err := b.List(beads.ListOptions{
|
||||
Status: "in_progress",
|
||||
Assignee: agentID,
|
||||
Priority: -1,
|
||||
})
|
||||
if err == nil && len(inProgressBeads) > 0 {
|
||||
return inProgressBeads[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSlungWork checks for hooked work on the agent's hook.
|
||||
// If found, displays AUTONOMOUS WORK MODE and tells the agent to execute immediately.
|
||||
// Returns true if hooked work was found (caller should skip normal startup directive).
|
||||
func checkSlungWork(ctx RoleContext) bool {
|
||||
hookedBead := findHookedBead(ctx)
|
||||
if hookedBead == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If no hooked beads found, also check in_progress beads assigned to this agent.
|
||||
// This handles the case where work was claimed (status changed to in_progress)
|
||||
// but the session was interrupted before completion. The hook should persist.
|
||||
if len(hookedBeads) == 0 {
|
||||
inProgressBeads, err := b.List(beads.ListOptions{
|
||||
Status: "in_progress",
|
||||
Assignee: agentID,
|
||||
Priority: -1,
|
||||
})
|
||||
if err != nil || len(inProgressBeads) == 0 {
|
||||
return false
|
||||
}
|
||||
hookedBeads = inProgressBeads
|
||||
}
|
||||
|
||||
// Use the first hooked bead (agents typically have one)
|
||||
hookedBead := hookedBeads[0]
|
||||
|
||||
// Build the role announcement string
|
||||
roleAnnounce := buildRoleAnnouncement(ctx)
|
||||
|
||||
@@ -1661,6 +1705,29 @@ func checkHandoffMarker(workDir string) {
|
||||
_ = os.Remove(markerPath)
|
||||
|
||||
// Output prominent warning
|
||||
outputHandoffWarning(prevSession)
|
||||
}
|
||||
|
||||
// checkHandoffMarkerDryRun checks for handoff marker without removing it (for --dry-run).
|
||||
func checkHandoffMarkerDryRun(workDir string) {
|
||||
markerPath := filepath.Join(workDir, constants.DirRuntime, constants.FileHandoffMarker)
|
||||
data, err := os.ReadFile(markerPath)
|
||||
if err != nil {
|
||||
// No marker = not post-handoff, normal startup
|
||||
explain(true, "Post-handoff: no handoff marker found")
|
||||
return
|
||||
}
|
||||
|
||||
// Marker found - this is a post-handoff session
|
||||
prevSession := strings.TrimSpace(string(data))
|
||||
explain(true, fmt.Sprintf("Post-handoff: marker found (predecessor: %s), marker NOT removed in dry-run", prevSession))
|
||||
|
||||
// Output the warning but don't remove marker
|
||||
outputHandoffWarning(prevSession)
|
||||
}
|
||||
|
||||
// outputHandoffWarning outputs the post-handoff warning message.
|
||||
func outputHandoffWarning(prevSession string) {
|
||||
fmt.Println()
|
||||
fmt.Println(style.Bold.Render("╔══════════════════════════════════════════════════════════════════╗"))
|
||||
fmt.Println(style.Bold.Render("║ ✅ HANDOFF COMPLETE - You are the NEW session ║"))
|
||||
@@ -1676,3 +1743,76 @@ func checkHandoffMarker(workDir string) {
|
||||
fmt.Println("Instead: Check your hook (`gt mol status`) and mail (`gt mail inbox`).")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// SessionState represents the detected session state for observability.
|
||||
type SessionState struct {
|
||||
State string `json:"state"` // normal, post-handoff, crash-recovery, autonomous
|
||||
Role Role `json:"role"` // detected role
|
||||
PrevSession string `json:"prev_session,omitempty"` // for post-handoff
|
||||
CheckpointAge string `json:"checkpoint_age,omitempty"` // for crash-recovery
|
||||
HookedBead string `json:"hooked_bead,omitempty"` // for autonomous
|
||||
}
|
||||
|
||||
// detectSessionState returns the current session state without side effects.
|
||||
func detectSessionState(ctx RoleContext) SessionState {
|
||||
state := SessionState{
|
||||
State: "normal",
|
||||
Role: ctx.Role,
|
||||
}
|
||||
|
||||
// Check for handoff marker (post-handoff state)
|
||||
markerPath := filepath.Join(ctx.WorkDir, constants.DirRuntime, constants.FileHandoffMarker)
|
||||
if data, err := os.ReadFile(markerPath); err == nil {
|
||||
state.State = "post-handoff"
|
||||
state.PrevSession = strings.TrimSpace(string(data))
|
||||
return state
|
||||
}
|
||||
|
||||
// Check for checkpoint (crash-recovery state) - only for polecat/crew
|
||||
if ctx.Role == RolePolecat || ctx.Role == RoleCrew {
|
||||
if cp, err := checkpoint.Read(ctx.WorkDir); err == nil && cp != nil && !cp.IsStale(24*time.Hour) {
|
||||
state.State = "crash-recovery"
|
||||
state.CheckpointAge = cp.Age().Round(time.Minute).String()
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Check for hooked work (autonomous state)
|
||||
if hookedBead := findHookedBead(ctx); hookedBead != nil {
|
||||
state.State = "autonomous"
|
||||
state.HookedBead = hookedBead.ID
|
||||
return state
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// outputState outputs only the session state (for --state flag).
|
||||
func outputState(ctx RoleContext) {
|
||||
state := detectSessionState(ctx)
|
||||
|
||||
fmt.Printf("state: %s\n", state.State)
|
||||
fmt.Printf("role: %s\n", state.Role)
|
||||
|
||||
switch state.State {
|
||||
case "post-handoff":
|
||||
if state.PrevSession != "" {
|
||||
fmt.Printf("prev_session: %s\n", state.PrevSession)
|
||||
}
|
||||
case "crash-recovery":
|
||||
if state.CheckpointAge != "" {
|
||||
fmt.Printf("checkpoint_age: %s\n", state.CheckpointAge)
|
||||
}
|
||||
case "autonomous":
|
||||
if state.HookedBead != "" {
|
||||
fmt.Printf("hooked_bead: %s\n", state.HookedBead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// explain outputs an explanatory message if --explain mode is enabled.
|
||||
func explain(condition bool, reason string) {
|
||||
if primeExplain && condition {
|
||||
fmt.Printf("\n[EXPLAIN] %s\n", reason)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user