* 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>
1036 lines
31 KiB
Go
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
|
|
}
|