Files
gastown/internal/cmd/molecule_status.go
Julian Knutsen 3da0d5a7c8 fix(molecule): use Dependencies from bd show instead of empty DependsOn (#901)
* fix(molecule): use Dependencies from bd show instead of empty DependsOn

Bug: Molecule step dependency checking was broken because bd list
doesn't populate the DependsOn field (it's always empty). Only bd show
returns dependency info in the Dependencies field.

This caused all open steps to appear "ready" regardless of actual
dependencies - the polecat would start blocked steps prematurely.

Fix: Call ShowMultiple() after List() to fetch full issue details
including Dependencies, then check Dependencies instead of DependsOn.

Affected functions:
- findNextReadyStep() in molecule_step.go
- getMoleculeProgressInfo() in molecule_status.go
- runMoleculeCurrent() in molecule_status.go

Tests:
- Added TestFindNextReadyStepWithBdListBehavior to verify fix
- Added TestOldBuggyBehavior to demonstrate the bug
- Updated existing tests to use fixed algorithm

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(molecule): use Dependencies from bd show instead of empty DependsOn

Bug: Molecule step dependency checking was broken because bd list
doesn't populate the DependsOn field (it's always empty). Only bd show
returns dependency info in the Dependencies field.

This caused all open steps to appear "ready" regardless of actual
dependencies - the polecat would start blocked steps prematurely.

Fix: Call ShowMultiple() after List() to fetch full issue details
including Dependencies, then check Dependencies instead of DependsOn.
Also filter to only check "blocks" type dependencies - ignore "parent-child"
relationships which are just structural, not blocking.

Affected functions:
- findNextReadyStep() in molecule_step.go
- getMoleculeProgressInfo() in molecule_status.go
- runMoleculeCurrent() in molecule_status.go

Tests:
- Added TestFindNextReadyStepWithBdListBehavior to verify fix
- Added TestOldBuggyBehavior to demonstrate the bug
- Updated existing tests to use fixed algorithm

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:46:27 -08:00

1036 lines
31 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// Note: Agent field parsing is now in internal/beads/fields.go (AgentFields, ParseAgentFieldsFromDescription)
// buildAgentBeadID constructs the agent bead ID from an agent identity.
// Uses canonical naming: prefix-rig-role-name
// Town-level agents use hq- prefix; rig-level agents use rig's prefix.
// Examples:
// - "mayor" -> "hq-mayor"
// - "deacon" -> "hq-deacon"
// - "gastown/witness" -> "gt-gastown-witness"
// - "gastown/refinery" -> "gt-gastown-refinery"
// - "gastown/nux" (polecat) -> "gt-gastown-polecat-nux"
// - "gastown/crew/max" -> "gt-gastown-crew-max"
//
// If role is unknown, it tries to infer from the identity string.
// townRoot is needed to look up the rig's configured prefix.
func buildAgentBeadID(identity string, role Role, townRoot string) string {
parts := strings.Split(identity, "/")
// Helper to get prefix for a rig
getPrefix := func(rig string) string {
return config.GetRigPrefix(townRoot, rig)
}
// If role is unknown or empty, try to infer from identity
if role == RoleUnknown || role == Role("") {
switch {
case identity == "mayor":
return beads.MayorBeadIDTown()
case identity == "deacon":
return beads.DeaconBeadIDTown()
case len(parts) == 2 && parts[1] == "witness":
return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
case len(parts) == 2 && parts[1] == "refinery":
return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
case len(parts) == 2:
// Assume rig/name is a polecat
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1])
case len(parts) == 3 && parts[1] == "crew":
// rig/crew/name - crew member
return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
case len(parts) == 3 && parts[1] == "polecats":
// rig/polecats/name - explicit polecat
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
default:
return ""
}
}
switch role {
case RoleMayor:
return beads.MayorBeadIDTown()
case RoleDeacon:
return beads.DeaconBeadIDTown()
case RoleWitness:
if len(parts) >= 1 {
return beads.WitnessBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
}
return ""
case RoleRefinery:
if len(parts) >= 1 {
return beads.RefineryBeadIDWithPrefix(getPrefix(parts[0]), parts[0])
}
return ""
case RolePolecat:
// Handle both 2-part (rig/name) and 3-part (rig/polecats/name) formats
if len(parts) == 3 && parts[1] == "polecats" {
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
}
if len(parts) >= 2 {
return beads.PolecatBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[1])
}
return ""
case RoleCrew:
if len(parts) >= 3 && parts[1] == "crew" {
return beads.CrewBeadIDWithPrefix(getPrefix(parts[0]), parts[0], parts[2])
}
return ""
default:
return ""
}
}
// MoleculeProgressInfo contains progress information for a molecule instance.
type MoleculeProgressInfo struct {
RootID string `json:"root_id"`
RootTitle string `json:"root_title"`
MoleculeID string `json:"molecule_id,omitempty"`
TotalSteps int `json:"total_steps"`
DoneSteps int `json:"done_steps"`
InProgress int `json:"in_progress_steps"`
ReadySteps []string `json:"ready_steps"`
BlockedSteps []string `json:"blocked_steps"`
Percent int `json:"percent_complete"`
Complete bool `json:"complete"`
}
// MoleculeStatusInfo contains status information for an agent's work.
type MoleculeStatusInfo struct {
Target string `json:"target"`
Role string `json:"role"`
AgentBeadID string `json:"agent_bead_id,omitempty"` // The agent bead if found
HasWork bool `json:"has_work"`
PinnedBead *beads.Issue `json:"pinned_bead,omitempty"`
AttachedMolecule string `json:"attached_molecule,omitempty"`
AttachedAt string `json:"attached_at,omitempty"`
AttachedArgs string `json:"attached_args,omitempty"`
IsWisp bool `json:"is_wisp"`
Progress *MoleculeProgressInfo `json:"progress,omitempty"`
NextAction string `json:"next_action,omitempty"`
}
// MoleculeCurrentInfo contains info about what an agent should be working on.
type MoleculeCurrentInfo struct {
Identity string `json:"identity"`
HandoffID string `json:"handoff_id,omitempty"`
HandoffTitle string `json:"handoff_title,omitempty"`
MoleculeID string `json:"molecule_id,omitempty"`
MoleculeTitle string `json:"molecule_title,omitempty"`
StepsComplete int `json:"steps_complete"`
StepsTotal int `json:"steps_total"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentStep string `json:"current_step,omitempty"`
Status string `json:"status"` // "working", "naked", "complete", "blocked"
}
func runMoleculeProgress(cmd *cobra.Command, args []string) error {
rootID := args[0]
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Get the root issue
root, err := b.Show(rootID)
if err != nil {
return fmt.Errorf("getting root issue: %w", err)
}
// Find all children of the root issue
children, err := b.List(beads.ListOptions{
Parent: rootID,
Status: "all",
Priority: -1,
})
if err != nil {
return fmt.Errorf("listing children: %w", err)
}
if len(children) == 0 {
return fmt.Errorf("no steps found for %s (not a molecule root?)", rootID)
}
// Build progress info
progress := MoleculeProgressInfo{
RootID: rootID,
RootTitle: root.Title,
}
// Try to find molecule ID from first child's description
for _, child := range children {
if molID := extractMoleculeID(child.Description); molID != "" {
progress.MoleculeID = molID
break
}
}
// Build set of closed issue IDs and collect open step IDs for dependency checking
closedIDs := make(map[string]bool)
var openStepIDs []string
for _, child := range children {
if child.Status == "closed" {
closedIDs[child.ID] = true
} else if child.Status == "open" {
openStepIDs = append(openStepIDs, child.ID)
}
}
// Fetch full details for open steps to get dependency info.
// bd list doesn't return dependencies, but bd show does.
var openStepsMap map[string]*beads.Issue
if len(openStepIDs) > 0 {
openStepsMap, err = b.ShowMultiple(openStepIDs)
if err != nil {
// Non-fatal: continue without dependency info (all open steps will be "ready")
openStepsMap = make(map[string]*beads.Issue)
}
}
// Categorize steps
for _, child := range children {
progress.TotalSteps++
switch child.Status {
case "closed":
progress.DoneSteps++
case "in_progress":
progress.InProgress++
case "open":
// Get full step info with dependencies
step := openStepsMap[child.ID]
// Check if all dependencies are closed using Dependencies field
// (from bd show), not DependsOn (which is empty from bd list).
// Only "blocks" type dependencies block progress - ignore "parent-child".
allDepsClosed := true
hasBlockingDeps := false
var deps []beads.IssueDep
if step != nil {
deps = step.Dependencies
}
for _, dep := range deps {
if dep.DependencyType != "blocks" {
continue // Skip parent-child and other non-blocking relationships
}
hasBlockingDeps = true
if !closedIDs[dep.ID] {
allDepsClosed = false
break
}
}
if !hasBlockingDeps || allDepsClosed {
progress.ReadySteps = append(progress.ReadySteps, child.ID)
} else {
progress.BlockedSteps = append(progress.BlockedSteps, child.ID)
}
}
}
// Calculate completion percentage
if progress.TotalSteps > 0 {
progress.Percent = (progress.DoneSteps * 100) / progress.TotalSteps
}
progress.Complete = progress.DoneSteps == progress.TotalSteps
// JSON output
if moleculeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(progress)
}
// Human-readable output
fmt.Printf("\n%s %s\n\n", style.Bold.Render("🧬 Molecule Progress:"), root.Title)
fmt.Printf(" Root: %s\n", rootID)
if progress.MoleculeID != "" {
fmt.Printf(" Molecule: %s\n", progress.MoleculeID)
}
fmt.Println()
// Progress bar
barWidth := 20
filled := (progress.Percent * barWidth) / 100
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
fmt.Printf(" [%s] %d%% (%d/%d)\n\n", bar, progress.Percent, progress.DoneSteps, progress.TotalSteps)
// Step status
fmt.Printf(" Done: %d\n", progress.DoneSteps)
fmt.Printf(" In Progress: %d\n", progress.InProgress)
fmt.Printf(" Ready: %d", len(progress.ReadySteps))
if len(progress.ReadySteps) > 0 {
fmt.Printf(" (%s)", strings.Join(progress.ReadySteps, ", "))
}
fmt.Println()
fmt.Printf(" Blocked: %d\n", len(progress.BlockedSteps))
if progress.Complete {
fmt.Printf("\n %s\n", style.Bold.Render("✓ Molecule complete!"))
}
return nil
}
// extractMoleculeID extracts the molecule ID from an issue's description.
func extractMoleculeID(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 ""
}
func runMoleculeStatus(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")
}
// Determine target agent
var target string
var roleCtx RoleContext
if len(args) > 0 {
// Explicit target provided
target = args[0]
} else {
// Use cwd-based detection for status display
// This ensures we show the hook for the agent whose directory we're in,
// not the agent from the GT_ROLE env var (which might be different if
// we cd'd into another rig's crew/polecat directory)
roleCtx = detectRole(cwd, townRoot)
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role)
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Build status info
status := MoleculeStatusInfo{
Target: target,
Role: string(roleCtx.Role),
}
// Try to find agent bead and read hook slot
// This is the preferred method - agent beads have a hook_bead field
agentBeadID := buildAgentBeadID(target, roleCtx.Role, townRoot)
var hookBead *beads.Issue
if agentBeadID != "" {
// Try to fetch the agent bead
agentBead, err := b.Show(agentBeadID)
if err == nil && agentBead != nil && agentBead.Type == "agent" {
status.AgentBeadID = agentBeadID
// Read hook_bead from the agent bead's database field (not description!)
// The hook_bead column is updated by `bd slot set` in UpdateAgentState.
// IMPORTANT: Don't use ParseAgentFieldsFromDescription - the description
// field may contain stale data, causing the wrong issue to be hooked.
if agentBead.HookBead != "" {
// Fetch the bead on the hook
hookBead, err = b.Show(agentBead.HookBead)
if err != nil {
// Hook bead referenced but not found - report error but continue
hookBead = nil
}
}
}
// If agent bead not found or not an agent type, fall through to legacy approach
}
// If we found a hook bead via agent bead, use it
if hookBead != nil {
status.HasWork = true
status.PinnedBead = hookBead
// Check for attached molecule
attachment := beads.ParseAttachmentFields(hookBead)
if attachment != nil {
status.AttachedMolecule = attachment.AttachedMolecule
status.AttachedAt = attachment.AttachedAt
status.AttachedArgs = attachment.AttachedArgs
// Check if it's a wisp
status.IsWisp = strings.Contains(hookBead.Description, "wisp: true") ||
strings.Contains(hookBead.Description, "is_wisp: true")
// Get progress if there's an attached molecule
if attachment.AttachedMolecule != "" {
progress, _ := getMoleculeProgressInfo(b, attachment.AttachedMolecule)
status.Progress = progress
status.NextAction = determineNextAction(status)
}
}
} else {
// FALLBACK: Query for hooked beads (work on agent's hook)
// First try status=hooked (work that's been slung but not yet claimed)
hookedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: target,
Priority: -1,
})
if err != nil {
return fmt.Errorf("listing hooked beads: %w", err)
}
// 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: target,
Priority: -1,
})
if err == nil && len(inProgressBeads) > 0 {
// Use the first in_progress bead (should typically be only one)
hookedBeads = inProgressBeads
}
}
// For town-level roles (mayor, deacon), scan all rigs if nothing found locally
if len(hookedBeads) == 0 && isTownLevelRole(target) {
hookedBeads = scanAllRigsForHookedBeads(townRoot, target)
}
status.HasWork = len(hookedBeads) > 0
if len(hookedBeads) > 0 {
// Take the first hooked bead
status.PinnedBead = hookedBeads[0]
// Check for attached molecule
attachment := beads.ParseAttachmentFields(hookedBeads[0])
if attachment != nil {
status.AttachedMolecule = attachment.AttachedMolecule
status.AttachedAt = attachment.AttachedAt
status.AttachedArgs = attachment.AttachedArgs
// Check if it's a wisp
status.IsWisp = strings.Contains(hookedBeads[0].Description, "wisp: true") ||
strings.Contains(hookedBeads[0].Description, "is_wisp: true")
// Get progress if there's an attached molecule
if attachment.AttachedMolecule != "" {
progress, _ := getMoleculeProgressInfo(b, attachment.AttachedMolecule)
status.Progress = progress
status.NextAction = determineNextAction(status)
}
}
}
}
// Determine next action if no work is slung
if !status.HasWork {
status.NextAction = "Check inbox for work assignments: gt mail inbox"
} else if status.AttachedMolecule == "" {
status.NextAction = "Attach a molecule to start work: gt mol attach <bead-id> <molecule-id>"
}
// JSON output
if moleculeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(status)
}
// Human-readable output
return outputMoleculeStatus(status)
}
// buildAgentIdentity constructs the agent identity string from role context.
// Town-level agents (mayor, deacon) use trailing slash to match the format
// used when setting assignee on hooked beads (see resolveSelfTarget in sling.go).
func buildAgentIdentity(ctx RoleContext) string {
switch ctx.Role {
case RoleMayor:
return "mayor/"
case RoleDeacon:
return "deacon/"
case RoleWitness:
return ctx.Rig + "/witness"
case RoleRefinery:
return ctx.Rig + "/refinery"
case RolePolecat:
return ctx.Rig + "/polecats/" + ctx.Polecat
case RoleCrew:
return ctx.Rig + "/crew/" + ctx.Polecat
default:
return ""
}
}
// getMoleculeProgressInfo gets progress info for a molecule instance.
func getMoleculeProgressInfo(b *beads.Beads, moleculeRootID string) (*MoleculeProgressInfo, error) {
// Get the molecule root issue
root, err := b.Show(moleculeRootID)
if err != nil {
return nil, fmt.Errorf("getting molecule root: %w", err)
}
// Find all children of the root issue
children, err := b.List(beads.ListOptions{
Parent: moleculeRootID,
Status: "all",
Priority: -1,
})
if err != nil {
return nil, fmt.Errorf("listing children: %w", err)
}
if len(children) == 0 {
// No children - might be a simple issue, not a molecule
return nil, nil
}
// Build progress info
progress := &MoleculeProgressInfo{
RootID: moleculeRootID,
RootTitle: root.Title,
}
// Try to find molecule ID from first child's description
for _, child := range children {
if molID := extractMoleculeID(child.Description); molID != "" {
progress.MoleculeID = molID
break
}
}
// Build set of closed issue IDs and collect open step IDs for dependency checking
closedIDs := make(map[string]bool)
var openStepIDs []string
for _, child := range children {
if child.Status == "closed" {
closedIDs[child.ID] = true
} else if child.Status == "open" {
openStepIDs = append(openStepIDs, child.ID)
}
}
// Fetch full details for open steps to get dependency info.
// bd list doesn't return dependencies, but bd show does.
var openStepsMap map[string]*beads.Issue
if len(openStepIDs) > 0 {
openStepsMap, err = b.ShowMultiple(openStepIDs)
if err != nil {
// Non-fatal: continue without dependency info (all open steps will be "ready")
openStepsMap = make(map[string]*beads.Issue)
}
}
// Categorize steps
for _, child := range children {
progress.TotalSteps++
switch child.Status {
case "closed":
progress.DoneSteps++
case "in_progress":
progress.InProgress++
case "open":
// Get full step info with dependencies
step := openStepsMap[child.ID]
// Check if all dependencies are closed using Dependencies field
// (from bd show), not DependsOn (which is empty from bd list).
// Only "blocks" type dependencies block progress - ignore "parent-child".
allDepsClosed := true
hasBlockingDeps := false
var deps []beads.IssueDep
if step != nil {
deps = step.Dependencies
}
for _, dep := range deps {
if dep.DependencyType != "blocks" {
continue // Skip parent-child and other non-blocking relationships
}
hasBlockingDeps = true
if !closedIDs[dep.ID] {
allDepsClosed = false
break
}
}
if !hasBlockingDeps || allDepsClosed {
progress.ReadySteps = append(progress.ReadySteps, child.ID)
} else {
progress.BlockedSteps = append(progress.BlockedSteps, child.ID)
}
}
}
// Calculate completion percentage
if progress.TotalSteps > 0 {
progress.Percent = (progress.DoneSteps * 100) / progress.TotalSteps
}
progress.Complete = progress.DoneSteps == progress.TotalSteps
return progress, nil
}
// determineNextAction suggests the next action based on status.
func determineNextAction(status MoleculeStatusInfo) string {
if status.Progress == nil {
return ""
}
if status.Progress.Complete {
return "Molecule complete! Close the bead: bd close " + status.PinnedBead.ID
}
if status.Progress.InProgress > 0 {
return "Continue working on in-progress steps"
}
if len(status.Progress.ReadySteps) > 0 {
return fmt.Sprintf("Start next ready step: bd update %s --status=in_progress", status.Progress.ReadySteps[0])
}
if len(status.Progress.BlockedSteps) > 0 {
return "All remaining steps are blocked - waiting on dependencies"
}
return ""
}
// outputMoleculeStatus outputs human-readable status.
func outputMoleculeStatus(status MoleculeStatusInfo) error {
// Header with hook icon
fmt.Printf("\n%s Hook Status: %s\n", style.Bold.Render("🪝"), status.Target)
if status.Role != "" && status.Role != "unknown" {
fmt.Printf("Role: %s\n", status.Role)
}
fmt.Println()
if !status.HasWork {
fmt.Printf("%s\n", style.Dim.Render("Nothing on hook - no work slung"))
fmt.Printf("\n%s %s\n", style.Bold.Render("Next:"), status.NextAction)
return nil
}
// Show hooked bead info
if status.PinnedBead == nil {
fmt.Printf("%s\n", style.Dim.Render("Work indicated but no bead found"))
return nil
}
// AUTONOMOUS MODE banner - hooked work triggers autonomous execution
fmt.Println(style.Bold.Render("🚀 AUTONOMOUS MODE - Work on hook triggers immediate execution"))
fmt.Println()
// Check if the hooked bead is already closed (someone closed it externally)
if status.PinnedBead.Status == "closed" {
fmt.Printf("%s Hooked bead %s is already closed!\n", style.Bold.Render("⚠"), status.PinnedBead.ID)
fmt.Printf(" Title: %s\n", status.PinnedBead.Title)
fmt.Printf(" This work was completed elsewhere. Clear your hook with: gt unsling\n")
return nil
}
// Check if this is a mail bead - display mail-specific format
if status.PinnedBead.Type == "message" {
sender := extractMailSender(status.PinnedBead.Labels)
fmt.Printf("%s %s (mail)\n", style.Bold.Render("🪝 Hook:"), status.PinnedBead.ID)
if sender != "" {
fmt.Printf(" From: %s\n", sender)
}
fmt.Printf(" Subject: %s\n", status.PinnedBead.Title)
fmt.Printf(" Run: gt mail read %s\n", status.PinnedBead.ID)
return nil
}
fmt.Printf("%s %s: %s\n", style.Bold.Render("🪝 Hooked:"), status.PinnedBead.ID, status.PinnedBead.Title)
// Show attached molecule
if status.AttachedMolecule != "" {
molType := "Molecule"
if status.IsWisp {
molType = "Wisp"
}
fmt.Printf("%s %s: %s\n", style.Bold.Render("🧬 "+molType+":"), status.AttachedMolecule, "")
if status.AttachedAt != "" {
fmt.Printf(" Attached: %s\n", status.AttachedAt)
}
if status.AttachedArgs != "" {
fmt.Printf(" %s %s\n", style.Bold.Render("Args:"), status.AttachedArgs)
}
} else {
fmt.Printf("%s\n", style.Dim.Render("No molecule attached (hooked bead still triggers autonomous work)"))
}
// Show progress if available
if status.Progress != nil {
fmt.Println()
// Progress bar
barWidth := 20
filled := (status.Progress.Percent * barWidth) / 100
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
fmt.Printf("Progress: [%s] %d%% (%d/%d steps)\n",
bar, status.Progress.Percent, status.Progress.DoneSteps, status.Progress.TotalSteps)
// Step breakdown
fmt.Printf(" Done: %d\n", status.Progress.DoneSteps)
fmt.Printf(" In Progress: %d\n", status.Progress.InProgress)
fmt.Printf(" Ready: %d", len(status.Progress.ReadySteps))
if len(status.Progress.ReadySteps) > 0 && len(status.Progress.ReadySteps) <= 3 {
fmt.Printf(" (%s)", strings.Join(status.Progress.ReadySteps, ", "))
}
fmt.Println()
fmt.Printf(" Blocked: %d\n", len(status.Progress.BlockedSteps))
if status.Progress.Complete {
fmt.Printf("\n%s\n", style.Bold.Render("✓ Molecule complete!"))
}
}
// Next action hint
if status.NextAction != "" {
fmt.Printf("\n%s %s\n", style.Bold.Render("Next:"), status.NextAction)
}
return nil
}
func runMoleculeCurrent(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")
}
// Determine target agent identity
var target string
var roleCtx RoleContext
if len(args) > 0 {
// Explicit target provided
target = args[0]
} else {
// Use cwd-based detection for status display
// This ensures we show the hook for the agent whose directory we're in,
// not the agent from the GT_ROLE env var (which might be different if
// we cd'd into another rig's crew/polecat directory)
roleCtx = detectRole(cwd, townRoot)
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role)
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Extract role from target for handoff bead lookup
parts := strings.Split(target, "/")
role := parts[len(parts)-1]
// Find handoff bead for this identity
handoff, err := b.FindHandoffBead(role)
if err != nil {
return fmt.Errorf("finding handoff bead: %w", err)
}
// Build current info
info := MoleculeCurrentInfo{
Identity: target,
}
if handoff == nil {
info.Status = "naked"
return outputMoleculeCurrent(info)
}
info.HandoffID = handoff.ID
info.HandoffTitle = handoff.Title
// Check for attached molecule
attachment := beads.ParseAttachmentFields(handoff)
if attachment == nil || attachment.AttachedMolecule == "" {
info.Status = "naked"
return outputMoleculeCurrent(info)
}
info.MoleculeID = attachment.AttachedMolecule
// Get the molecule root to find its title and children
molRoot, err := b.Show(attachment.AttachedMolecule)
if err != nil {
// Molecule not found - might be a template ID, still report what we have
info.Status = "working"
return outputMoleculeCurrent(info)
}
info.MoleculeTitle = molRoot.Title
// Find all children (steps) of the molecule root
children, err := b.List(beads.ListOptions{
Parent: attachment.AttachedMolecule,
Status: "all",
Priority: -1,
})
if err != nil {
// No steps - just an issue, not a molecule instance
info.Status = "working"
return outputMoleculeCurrent(info)
}
info.StepsTotal = len(children)
// Build set of closed issue IDs and collect open step IDs for dependency checking
closedIDs := make(map[string]bool)
var inProgressSteps []*beads.Issue
var openStepIDs []string
for _, child := range children {
switch child.Status {
case "closed":
info.StepsComplete++
closedIDs[child.ID] = true
case "in_progress":
inProgressSteps = append(inProgressSteps, child)
case "open":
openStepIDs = append(openStepIDs, child.ID)
}
}
// Fetch full details for open steps to get dependency info.
// bd list doesn't return dependencies, but bd show does.
var openStepsMap map[string]*beads.Issue
if len(openStepIDs) > 0 {
openStepsMap, _ = b.ShowMultiple(openStepIDs)
if openStepsMap == nil {
openStepsMap = make(map[string]*beads.Issue)
}
}
// Find ready steps (open with all deps closed)
var readySteps []*beads.Issue
for _, stepID := range openStepIDs {
step := openStepsMap[stepID]
if step == nil {
continue
}
// Check dependencies using Dependencies field (from bd show),
// not DependsOn (which is empty from bd list).
// Only "blocks" type dependencies block progress - ignore "parent-child".
allDepsClosed := true
hasBlockingDeps := false
for _, dep := range step.Dependencies {
if dep.DependencyType != "blocks" {
continue // Skip parent-child and other non-blocking relationships
}
hasBlockingDeps = true
if !closedIDs[dep.ID] {
allDepsClosed = false
break
}
}
if !hasBlockingDeps || allDepsClosed {
readySteps = append(readySteps, step)
}
}
// Determine current step and status
if info.StepsComplete == info.StepsTotal && info.StepsTotal > 0 {
info.Status = "complete"
} else if len(inProgressSteps) > 0 {
// First in-progress step is the current one
info.Status = "working"
info.CurrentStepID = inProgressSteps[0].ID
info.CurrentStep = inProgressSteps[0].Title
} else if len(readySteps) > 0 {
// First ready step is the next to work on
info.Status = "working"
info.CurrentStepID = readySteps[0].ID
info.CurrentStep = readySteps[0].Title
} else if info.StepsTotal > 0 {
// Has steps but none ready or in-progress -> blocked
info.Status = "blocked"
} else {
info.Status = "working"
}
return outputMoleculeCurrent(info)
}
// outputMoleculeCurrent outputs the current info in the appropriate format.
func outputMoleculeCurrent(info MoleculeCurrentInfo) error {
if moleculeJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(info)
}
// Human-readable output matching spec format
fmt.Printf("Identity: %s\n", info.Identity)
if info.HandoffID != "" {
fmt.Printf("Handoff: %s (%s)\n", info.HandoffID, info.HandoffTitle)
} else {
fmt.Printf("Handoff: %s\n", style.Dim.Render("(none)"))
}
if info.MoleculeID != "" {
if info.MoleculeTitle != "" {
fmt.Printf("Molecule: %s (%s)\n", info.MoleculeID, info.MoleculeTitle)
} else {
fmt.Printf("Molecule: %s\n", info.MoleculeID)
}
} else {
fmt.Printf("Molecule: %s\n", style.Dim.Render("(none attached)"))
}
if info.StepsTotal > 0 {
fmt.Printf("Progress: %d/%d steps complete\n", info.StepsComplete, info.StepsTotal)
}
if info.CurrentStepID != "" {
fmt.Printf("Current: %s - %s\n", info.CurrentStepID, info.CurrentStep)
} else if info.Status == "naked" {
fmt.Printf("Status: %s\n", style.Dim.Render("naked - awaiting work assignment"))
} else if info.Status == "complete" {
fmt.Printf("Status: %s\n", style.Bold.Render("complete - molecule finished"))
} else if info.Status == "blocked" {
fmt.Printf("Status: %s\n", style.Dim.Render("blocked - waiting on dependencies"))
}
return nil
}
// getGitRootForMolStatus returns the git root for hook file lookup.
func getGitRootForMolStatus() (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
}
// isTownLevelRole returns true if the agent ID is a town-level role.
// Town-level roles (Mayor, Deacon) operate from the town root and may have
// pinned beads in any rig's beads directory.
// Accepts both "mayor" and "mayor/" formats for compatibility.
func isTownLevelRole(agentID string) bool {
return agentID == "mayor" || agentID == "mayor/" ||
agentID == "deacon" || agentID == "deacon/"
}
// extractMailSender extracts the sender from mail bead labels.
// Mail beads have a "from:X" label containing the sender address.
func extractMailSender(labels []string) string {
for _, label := range labels {
if strings.HasPrefix(label, "from:") {
return strings.TrimPrefix(label, "from:")
}
}
return ""
}
// scanAllRigsForHookedBeads scans all registered rigs for hooked beads
// assigned to the target agent. Used for town-level roles that may have
// work hooked in any rig.
func scanAllRigsForHookedBeads(townRoot, target string) []*beads.Issue {
// Load routes from town beads
townBeadsDir := filepath.Join(townRoot, ".beads")
routes, err := beads.LoadRoutes(townBeadsDir)
if err != nil {
return nil
}
// Scan each rig's beads directory
for _, route := range routes {
rigBeadsDir := filepath.Join(townRoot, route.Path)
if _, err := os.Stat(rigBeadsDir); os.IsNotExist(err) {
continue
}
b := beads.New(rigBeadsDir)
// First check for hooked beads
hookedBeads, err := b.List(beads.ListOptions{
Status: beads.StatusHooked,
Assignee: target,
Priority: -1,
})
if err != nil {
continue
}
if len(hookedBeads) > 0 {
return hookedBeads
}
// Also check for in_progress beads (work that was claimed but session interrupted)
inProgressBeads, err := b.List(beads.ListOptions{
Status: "in_progress",
Assignee: target,
Priority: -1,
})
if err != nil {
continue
}
if len(inProgressBeads) > 0 {
return inProgressBeads
}
}
return nil
}