From ecff74e2affc32c2585284072293fbb688624b8c Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 00:11:22 -0800 Subject: [PATCH] feat: add bd slot commands for agent bead slot management (gt-h5sza) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add slot management commands: - bd slot set - set slot (error if occupied) - bd slot clear - clear slot - bd slot show - show all slots These enforce cardinality constraints for agent bead slots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/slot.go | 396 ++++++++++++++++++ internal/rpc/protocol.go | 3 + internal/rpc/server_issues_epics.go | 7 + internal/storage/sqlite/migrations.go | 1 + .../sqlite/migrations/030_agent_fields.go | 53 +++ internal/storage/sqlite/queries.go | 37 +- 6 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 cmd/bd/slot.go create mode 100644 internal/storage/sqlite/migrations/030_agent_fields.go diff --git a/cmd/bd/slot.go b/cmd/bd/slot.go new file mode 100644 index 00000000..66446b6b --- /dev/null +++ b/cmd/bd/slot.go @@ -0,0 +1,396 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" + "github.com/steveyegge/beads/internal/utils" +) + +// Valid slot names for agent beads (gt-h5sza) +var validSlots = map[string]bool{ + "hook": true, // hook_bead field - current work (0..1) + "role": true, // role_bead field - role definition (required) +} + +var slotCmd = &cobra.Command{ + Use: "slot", + Short: "Manage agent bead slots", + Long: `Manage slots on agent beads. + +Agent beads have named slots that reference other beads: + hook - Current work attached to agent's hook (0..1 cardinality) + role - Role definition bead (required for agents) + +Slots enforce cardinality constraints - the hook slot can only hold one bead. + +Examples: + bd slot show gt-mayor # Show all slots for mayor agent + bd slot set gt-emma hook bd-xyz # Attach work bd-xyz to emma's hook + bd slot clear gt-emma hook # Clear emma's hook (detach work)`, +} + +var slotSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set a slot on an agent bead", + Long: `Set a slot on an agent bead. + +The slot command enforces cardinality: if the hook slot is already occupied, +the command will error. Use 'bd slot clear' first to detach existing work. + +Examples: + bd slot set gt-emma hook bd-xyz # Attach bd-xyz to emma's hook + bd slot set gt-mayor role gt-role # Set mayor's role bead`, + Args: cobra.ExactArgs(3), + RunE: runSlotSet, +} + +var slotClearCmd = &cobra.Command{ + Use: "clear ", + Short: "Clear a slot on an agent bead", + Long: `Clear a slot on an agent bead. + +This detaches whatever bead is currently in the slot. + +Examples: + bd slot clear gt-emma hook # Detach work from emma's hook + bd slot clear gt-mayor role # Clear mayor's role (not recommended)`, + Args: cobra.ExactArgs(2), + RunE: runSlotClear, +} + +var slotShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show all slots on an agent bead", + Long: `Show all slots on an agent bead. + +Displays the current values of all slot fields. + +Examples: + bd slot show gt-emma # Show emma's slots + bd slot show gt-mayor # Show mayor's slots`, + Args: cobra.ExactArgs(1), + RunE: runSlotShow, +} + +func init() { + slotCmd.AddCommand(slotSetCmd) + slotCmd.AddCommand(slotClearCmd) + slotCmd.AddCommand(slotShowCmd) + rootCmd.AddCommand(slotCmd) +} + +func runSlotSet(cmd *cobra.Command, args []string) error { + CheckReadonly("slot set") + + agentArg := args[0] + slotName := strings.ToLower(args[1]) + beadArg := args[2] + + // Validate slot name + if !validSlots[slotName] { + return fmt.Errorf("invalid slot name %q; valid slots: hook, role", slotName) + } + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Resolve bead ID + var beadID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: beadArg}) + if err != nil { + return fmt.Errorf("failed to resolve bead %s: %w", beadArg, err) + } + if err := json.Unmarshal(resp.Data, &beadID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + beadID, err = utils.ResolvePartialID(ctx, store, beadArg) + if err != nil { + return fmt.Errorf("failed to resolve bead %s: %w", beadArg, err) + } + } + + // Get current agent bead to check cardinality + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + // Check cardinality - error if slot is already occupied (for hook) + if slotName == "hook" && agent.HookBead != "" { + return fmt.Errorf("hook slot already occupied by %s; use 'bd slot clear %s hook' first", agent.HookBead, agentID) + } + + // Update the slot + if daemonClient != nil { + updateArgs := &rpc.UpdateArgs{ID: agentID} + switch slotName { + case "hook": + updateArgs.HookBead = &beadID + case "role": + updateArgs.RoleBead = &beadID + } + _, err := daemonClient.Update(updateArgs) + if err != nil { + return fmt.Errorf("failed to set slot: %w", err) + } + } else { + updates := map[string]interface{}{} + switch slotName { + case "hook": + updates["hook_bead"] = beadID + case "role": + updates["role_bead"] = beadID + } + if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + return fmt.Errorf("failed to set slot: %w", err) + } + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "agent": agentID, + "slot": slotName, + "bead": beadID, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s Set %s.%s = %s\n", ui.RenderPass("✓"), agentID, slotName, beadID) + return nil +} + +func runSlotClear(cmd *cobra.Command, args []string) error { + CheckReadonly("slot clear") + + agentArg := args[0] + slotName := strings.ToLower(args[1]) + + // Validate slot name + if !validSlots[slotName] { + return fmt.Errorf("invalid slot name %q; valid slots: hook, role", slotName) + } + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Get current agent bead to verify it's an agent + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + // Clear the slot (set to empty string) + emptyStr := "" + if daemonClient != nil { + updateArgs := &rpc.UpdateArgs{ID: agentID} + switch slotName { + case "hook": + updateArgs.HookBead = &emptyStr + case "role": + updateArgs.RoleBead = &emptyStr + } + _, err := daemonClient.Update(updateArgs) + if err != nil { + return fmt.Errorf("failed to clear slot: %w", err) + } + } else { + updates := map[string]interface{}{} + switch slotName { + case "hook": + updates["hook_bead"] = "" + case "role": + updates["role_bead"] = "" + } + if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + return fmt.Errorf("failed to clear slot: %w", err) + } + } + + // Trigger auto-flush + if flushManager != nil { + flushManager.MarkDirty(false) + } + + if jsonOutput { + result := map[string]interface{}{ + "agent": agentID, + "slot": slotName, + "bead": nil, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + fmt.Printf("%s Cleared %s.%s\n", ui.RenderPass("✓"), agentID, slotName) + return nil +} + +func runSlotShow(cmd *cobra.Command, args []string) error { + agentArg := args[0] + + ctx := rootCtx + + // Resolve agent ID + var agentID string + if daemonClient != nil { + resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if err := json.Unmarshal(resp.Data, &agentID); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agentID, err = utils.ResolvePartialID(ctx, store, agentArg) + if err != nil { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } + + // Get agent bead + var agent *types.Issue + if daemonClient != nil { + resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) + if err != nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + if err := json.Unmarshal(resp.Data, &agent); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + } else { + var err error + agent, err = store.GetIssue(ctx, agentID) + if err != nil || agent == nil { + return fmt.Errorf("agent bead not found: %s", agentID) + } + } + + // Verify agent bead is actually an agent + if agent.IssueType != "agent" { + return fmt.Errorf("%s is not an agent bead (type=%s)", agentID, agent.IssueType) + } + + if jsonOutput { + result := map[string]interface{}{ + "agent": agentID, + "slots": map[string]interface{}{ + "hook": emptyToNil(agent.HookBead), + "role": emptyToNil(agent.RoleBead), + }, + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(result) + } + + // Human-readable output + fmt.Printf("Agent: %s\n", agentID) + fmt.Println("Slots:") + if agent.HookBead != "" { + fmt.Printf(" hook: %s\n", agent.HookBead) + } else { + fmt.Println(" hook: (empty)") + } + if agent.RoleBead != "" { + fmt.Printf(" role: %s\n", agent.RoleBead) + } else { + fmt.Println(" role: (empty)") + } + + return nil +} + +// emptyToNil converts empty string to nil for JSON output +func emptyToNil(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index becf0700..c262f860 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -126,6 +126,9 @@ type UpdateArgs struct { Pinned *bool `json:"pinned,omitempty"` // If true, issue is a persistent context marker // Reparenting field (bd-cj2e) Parent *string `json:"parent,omitempty"` // New parent issue ID (reparents the issue) + // Agent slot fields (gt-h5sza) + HookBead *string `json:"hook_bead,omitempty"` // Current work on agent's hook (0..1) + RoleBead *string `json:"role_bead,omitempty"` // Role definition bead for agent } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index 6763e77e..806cf27e 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -101,6 +101,13 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.Pinned != nil { u["pinned"] = *a.Pinned } + // Agent slot fields (gt-h5sza) + if a.HookBead != nil { + u["hook_bead"] = *a.HookBead + } + if a.RoleBead != nil { + u["role_bead"] = *a.RoleBead + } return u } diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index de2409f5..0d2d9399 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -46,6 +46,7 @@ var migrationsList = []Migration{ {"gate_columns", migrations.MigrateGateColumns}, {"tombstone_closed_at", migrations.MigrateTombstoneClosedAt}, {"created_by_column", migrations.MigrateCreatedByColumn}, + {"agent_fields", migrations.MigrateAgentFields}, } // MigrationInfo contains metadata about a migration for inspection diff --git a/internal/storage/sqlite/migrations/030_agent_fields.go b/internal/storage/sqlite/migrations/030_agent_fields.go new file mode 100644 index 00000000..5ab220b4 --- /dev/null +++ b/internal/storage/sqlite/migrations/030_agent_fields.go @@ -0,0 +1,53 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateAgentFields adds agent-specific fields to the issues table. +// These fields support the agent-as-bead pattern (gt-v2gkv, gt-h5sza): +// - hook_bead: current work attached to agent's hook (0..1 cardinality) +// - role_bead: reference to role definition bead +// - agent_state: agent-reported state (idle|running|stuck|stopped) +// - last_activity: timestamp for timeout detection +// - role_type: agent role (polecat|crew|witness|refinery|mayor|deacon) +// - rig: rig name (empty for town-level agents) +func MigrateAgentFields(db *sql.DB) error { + columns := []struct { + name string + sqlType string + }{ + {"hook_bead", "TEXT DEFAULT ''"}, + {"role_bead", "TEXT DEFAULT ''"}, + {"agent_state", "TEXT DEFAULT ''"}, + {"last_activity", "DATETIME"}, + {"role_type", "TEXT DEFAULT ''"}, + {"rig", "TEXT DEFAULT ''"}, + } + + for _, col := range columns { + // Check if column already exists + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = ? + `, col.name).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check %s column: %w", col.name, err) + } + + if columnExists { + continue + } + + // Add the column + _, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.sqlType)) + if err != nil { + return fmt.Errorf("failed to add %s column: %w", col.name, err) + } + } + + return nil +} diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index f11fe85f..e134185f 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -272,6 +272,13 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var awaitID sql.NullString var timeoutNs sql.NullInt64 var waiters sql.NullString + // Agent fields (gt-h5sza) + var hookBead sql.NullString + var roleBead sql.NullString + var agentState sql.NullString + var lastActivity sql.NullTime + var roleType sql.NullString + var rig sql.NullString var contentHash sql.NullString var compactedAtCommit sql.NullString @@ -282,7 +289,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason, deleted_at, deleted_by, delete_reason, original_type, sender, ephemeral, pinned, is_template, - await_type, await_id, timeout_ns, waiters + await_type, await_id, timeout_ns, waiters, + hook_bead, role_bead, agent_state, last_activity, role_type, rig FROM issues WHERE id = ? `, id).Scan( @@ -294,6 +302,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &awaitType, &awaitID, &timeoutNs, &waiters, + &hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, ) if err == sql.ErrNoRows { @@ -372,6 +381,25 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if waiters.Valid && waiters.String != "" { issue.Waiters = parseJSONStringArray(waiters.String) } + // Agent fields (gt-h5sza) + if hookBead.Valid { + issue.HookBead = hookBead.String + } + if roleBead.Valid { + issue.RoleBead = roleBead.String + } + if agentState.Valid { + issue.AgentState = types.AgentState(agentState.String) + } + if lastActivity.Valid { + issue.LastActivity = &lastActivity.Time + } + if roleType.Valid { + issue.RoleType = roleType.String + } + if rig.Valid { + issue.Rig = rig.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) @@ -617,6 +645,13 @@ var allowedUpdateFields = map[string]bool{ "pinned": true, // NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004 // Use AddDependency() to create graph edges instead + // Agent slot fields (gt-h5sza) + "hook_bead": true, + "role_bead": true, + "agent_state": true, + "last_activity": true, + "role_type": true, + "rig": true, } // validatePriority validates a priority value