feat: Add mol_type schema field for molecule type classification (bd-oxgi)

Add mol_type field to beads for swarm coordination:
- Values: 'swarm' (multi-polecat), 'patrol' (recurring ops), 'work' (default)
- Nullable, defaults to empty string (treated as 'work')

Changes:
- Add mol_type column to SQLite schema and migration 031
- Add MolType type with IsValid() validation in types.go
- Update insertIssue/GetIssue to handle mol_type
- Add --mol-type flag to create command
- Add mol_type filtering to list and ready commands
- Update RPC protocol for daemon mode support
- Update test schema in migrations_test.go

🤝 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-28 19:50:55 -08:00
parent 77ba8f3d10
commit f3dcafca66
13 changed files with 167 additions and 8 deletions

View File

@@ -95,6 +95,8 @@ type CreateArgs struct {
// ID generation
IDPrefix string `json:"id_prefix,omitempty"` // Override prefix for ID generation (mol, eph, etc.)
CreatedBy string `json:"created_by,omitempty"` // Who created the issue
// Molecule type (for swarm coordination)
MolType string `json:"mol_type,omitempty"` // swarm, patrol, or work (default)
}
// UpdateArgs represents arguments for the update operation
@@ -203,6 +205,9 @@ type ListArgs struct {
// Ephemeral filtering
Ephemeral *bool `json:"ephemeral,omitempty"`
// Molecule type filtering
MolType string `json:"mol_type,omitempty"`
}
// CountArgs represents arguments for the count operation
@@ -264,6 +269,7 @@ type ReadyArgs struct {
Labels []string `json:"labels,omitempty"`
LabelsAny []string `json:"labels_any,omitempty"`
ParentID string `json:"parent_id,omitempty"` // Filter to descendants of this bead/epic
MolType string `json:"mol_type,omitempty"` // Filter by molecule type: swarm, patrol, or work
}
// BlockedArgs represents arguments for the blocked operation

View File

@@ -196,6 +196,8 @@ func (s *Server) handleCreate(req *Request) Response {
// ID generation
IDPrefix: createArgs.IDPrefix,
CreatedBy: createArgs.CreatedBy,
// Molecule type
MolType: types.MolType(createArgs.MolType),
}
// Check if any dependencies are discovered-from type
@@ -918,6 +920,12 @@ func (s *Server) handleList(req *Request) Response {
// Ephemeral filtering
filter.Ephemeral = listArgs.Ephemeral
// Molecule type filtering
if listArgs.MolType != "" {
molType := types.MolType(listArgs.MolType)
filter.MolType = &molType
}
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
if len(filter.IDs) > maxIDs {
@@ -1353,6 +1361,10 @@ func (s *Server) handleReady(req *Request) Response {
if readyArgs.ParentID != "" {
wf.ParentID = &readyArgs.ParentID
}
if readyArgs.MolType != "" {
molType := types.MolType(readyArgs.MolType)
wf.MolType = &molType
}
ctx := s.reqCtx(req)
issues, err := store.GetReadyWork(ctx, wf)

View File

@@ -47,8 +47,8 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
await_type, await_id, timeout_ns, waiters, mol_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
@@ -58,6 +58,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
string(issue.MolType),
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error
@@ -79,8 +80,8 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
await_type, await_id, timeout_ns, waiters, mol_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
@@ -115,6 +116,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters),
string(issue.MolType),
)
if err != nil {
// INSERT OR IGNORE should handle duplicates, but driver may still return error

View File

@@ -47,6 +47,7 @@ var migrationsList = []Migration{
{"tombstone_closed_at", migrations.MigrateTombstoneClosedAt},
{"created_by_column", migrations.MigrateCreatedByColumn},
{"agent_fields", migrations.MigrateAgentFields},
{"mol_type_column", migrations.MigrateMolTypeColumn},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -98,8 +99,12 @@ func getMigrationDescription(name string) string {
"remove_depends_on_fk": "Removes FK constraint on depends_on_id to allow external references",
"additional_indexes": "Adds performance optimization indexes for common query patterns",
"gate_columns": "Adds gate columns (await_type, await_id, timeout_ns, waiters) for async coordination",
"tombstone_closed_at": "Preserves closed_at timestamp when issues become tombstones",
"created_by_column": "Adds created_by column to track issue creator",
"agent_fields": "Adds agent identity fields (hook_bead, role_bead, agent_state, etc.) for agent-as-bead pattern",
"mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)",
}
if desc, ok := descriptions[name]; ok {
return desc
}

View File

@@ -0,0 +1,34 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateMolTypeColumn adds mol_type column to the issues table.
// This field distinguishes molecule types (swarm/patrol/work) for swarm coordination.
// Values: 'swarm' (multi-polecat coordination), 'patrol' (recurring ops), 'work' (regular, default)
func MigrateMolTypeColumn(db *sql.DB) error {
// Check if column already exists
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'mol_type'
`).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check mol_type column: %w", err)
}
if columnExists {
return nil
}
// Add the column
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN mol_type TEXT DEFAULT ''`)
if err != nil {
return fmt.Errorf("failed to add mol_type column: %w", err)
}
return nil
}

View File

@@ -502,9 +502,10 @@ func TestMigrateContentHashColumn(t *testing.T) {
last_activity DATETIME,
role_type TEXT DEFAULT '',
rig TEXT DEFAULT '',
mol_type TEXT DEFAULT '',
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '' FROM issues_backup;
INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '' FROM issues_backup;
DROP TABLE issues_backup;
`)
if err != nil {

View File

@@ -279,6 +279,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var lastActivity sql.NullTime
var roleType sql.NullString
var rig sql.NullString
// Molecule type field
var molType sql.NullString
var contentHash sql.NullString
var compactedAtCommit sql.NullString
@@ -290,7 +292,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters,
hook_bead, role_bead, agent_state, last_activity, role_type, rig
hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type
FROM issues
WHERE id = ?
`, id).Scan(
@@ -302,7 +304,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,
&hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType,
)
if err == sql.ErrNoRows {
@@ -400,6 +402,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if rig.Valid {
issue.Rig = rig.String
}
// Molecule type field
if molType.Valid {
issue.MolType = types.MolType(molType.String)
}
// Fetch labels for this issue
labels, err := s.GetLabels(ctx, issue.ID)
@@ -652,6 +658,8 @@ var allowedUpdateFields = map[string]bool{
"last_activity": true,
"role_type": true,
"rig": true,
// Molecule type field
"mol_type": true,
}
// validatePriority validates a priority value
@@ -1719,6 +1727,12 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
args = append(args, *filter.ParentID)
}
// Molecule type filtering
if filter.MolType != nil {
whereClauses = append(whereClauses, "mol_type = ?")
args = append(args, string(*filter.MolType))
}
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")

View File

@@ -106,6 +106,12 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
args = append(args, *filter.ParentID)
}
// Molecule type filtering
if filter.MolType != nil {
whereClauses = append(whereClauses, "i.mol_type = ?")
args = append(args, string(*filter.MolType))
}
// Build WHERE clause properly
whereSQL := strings.Join(whereClauses, " AND ")

View File

@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS issues (
pinned INTEGER DEFAULT 0,
-- Template field (beads-1ra)
is_template INTEGER DEFAULT 0,
-- Molecule type field (bd-oxgi)
mol_type TEXT DEFAULT '',
-- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004
-- These relationships are now stored in the dependencies table
-- closed_at constraint: closed issues must have it, tombstones may retain it from before deletion

View File

@@ -97,6 +97,9 @@ type Issue struct {
LastActivity *time.Time `json:"last_activity,omitempty"` // Updated on each action (timeout detection)
RoleType string `json:"role_type,omitempty"` // Role: polecat|crew|witness|refinery|mayor|deacon
Rig string `json:"rig,omitempty"` // Rig name (empty for town-level agents)
// ===== Molecule Type Fields (swarm coordination) =====
MolType MolType `json:"mol_type,omitempty"` // Molecule type: swarm|patrol|work (empty = work)
}
// ComputeContentHash creates a deterministic hash of the issue's content.
@@ -156,6 +159,9 @@ func (i *Issue) ComputeContentHash() string {
w.str(i.RoleType)
w.str(i.Rig)
// Molecule type
w.str(string(i.MolType))
return fmt.Sprintf("%x", h.Sum(nil))
}
@@ -422,6 +428,25 @@ func (s AgentState) IsValid() bool {
return false
}
// MolType categorizes the molecule type for swarm coordination
type MolType string
// MolType constants
const (
MolTypeSwarm MolType = "swarm" // Swarm molecule: coordinated multi-polecat work
MolTypePatrol MolType = "patrol" // Patrol molecule: recurring operational work (Witness, Deacon, etc.)
MolTypeWork MolType = "work" // Work molecule: regular polecat work (default)
)
// IsValid checks if the mol type value is valid
func (m MolType) IsValid() bool {
switch m {
case MolTypeSwarm, MolTypePatrol, MolTypeWork, "":
return true // empty is valid (defaults to work)
}
return false
}
// Dependency represents a relationship between issues
type Dependency struct {
IssueID string `json:"issue_id"`
@@ -680,6 +705,9 @@ type IssueFilter struct {
// Parent filtering: filter children by parent issue ID
ParentID *string // Filter by parent issue (via parent-child dependency)
// Molecule type filtering
MolType *MolType // Filter by molecule type (nil = any, swarm/patrol/work)
}
// SortPolicy determines how ready work is ordered
@@ -724,6 +752,9 @@ type WorkFilter struct {
// Parent filtering: filter to descendants of a bead/epic (recursive)
ParentID *string // Show all descendants of this issue
// Molecule type filtering
MolType *MolType // Filter by molecule type (nil = any, swarm/patrol/work)
}
// StaleFilter is used to filter stale issue queries