From 7d1ee6d3e99973b018440d919c42282839963685 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 28 Dec 2025 22:36:46 -0800 Subject: [PATCH] feat: Add 'hooked' status for GUPP work assignment (bd-s00m) Separates semantics of 'pinned' (identity records) from work-on-hook: - 'pinned' = domain table / identity record (agents, roles) - non-blocking - 'hooked' = work on agent's hook (GUPP-driven) - blocks dependents Changes: - Add StatusHooked constant to types.go - Update all blocking queries to include 'hooked' status - Add cyan styling for 'hooked' in UI output - Create migration 032 to convert pinned work items to hooked Generated with Claude Code Co-Authored-By: Claude Opus 4.5 --- internal/storage/memory/memory.go | 4 +- internal/storage/sqlite/blocked_cache.go | 2 +- internal/storage/sqlite/compact.go | 4 +- internal/storage/sqlite/events.go | 6 +-- internal/storage/sqlite/migrations.go | 1 + .../migrations/032_hooked_status_migration.go | 37 +++++++++++++++++++ internal/storage/sqlite/ready.go | 6 +-- internal/storage/sqlite/schema.go | 6 +-- internal/types/types.go | 3 +- internal/ui/styles.go | 7 ++++ 10 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 internal/storage/sqlite/migrations/032_hooked_status_migration.go diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 60f70a0a..35c76217 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -1066,7 +1066,7 @@ func (m *MemoryStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte return results, nil } -// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred. +// getOpenBlockers returns the IDs of blockers that are currently open/in_progress/blocked/deferred/hooked. // The caller must hold at least a read lock. func (m *MemoryStorage) getOpenBlockers(issueID string) []string { deps := m.dependencies[issueID] @@ -1086,7 +1086,7 @@ func (m *MemoryStorage) getOpenBlockers(issueID string) []string { continue } switch blocker.Status { - case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred: + case types.StatusOpen, types.StatusInProgress, types.StatusBlocked, types.StatusDeferred, types.StatusHooked: blockers = append(blockers, blocker.ID) } } diff --git a/internal/storage/sqlite/blocked_cache.go b/internal/storage/sqlite/blocked_cache.go index 93d63f03..c16ab2a3 100644 --- a/internal/storage/sqlite/blocked_cache.go +++ b/internal/storage/sqlite/blocked_cache.go @@ -146,7 +146,7 @@ func (s *SQLiteStorage) rebuildBlockedCache(ctx context.Context, exec execer) er FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') UNION diff --git a/internal/storage/sqlite/compact.go b/internal/storage/sqlite/compact.go index 9c1c2383..91292ec8 100644 --- a/internal/storage/sqlite/compact.go +++ b/internal/storage/sqlite/compact.go @@ -78,7 +78,7 @@ func (s *SQLiteStorage) GetTier1Candidates(ctx context.Context) ([]*CompactionCa COUNT(DISTINCT dt.dependent_id) as dependent_count FROM issues i LEFT JOIN dependent_tree dt ON i.id = dt.issue_id - AND dt.dependent_status IN ('open', 'in_progress', 'blocked', 'deferred') + AND dt.dependent_status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') AND dt.depth <= ? WHERE i.status = 'closed' AND i.closed_at IS NOT NULL @@ -163,7 +163,7 @@ func (s *SQLiteStorage) GetTier2Candidates(ctx context.Context) ([]*CompactionCa JOIN issues dep ON d.issue_id = dep.id WHERE d.depends_on_id = i.id AND d.type = 'blocks' - AND dep.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND dep.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') ) ORDER BY i.closed_at ASC ` diff --git a/internal/storage/sqlite/events.go b/internal/storage/sqlite/events.go index 3e24b692..c6432c4f 100644 --- a/internal/storage/sqlite/events.go +++ b/internal/storage/sqlite/events.go @@ -134,9 +134,9 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e FROM issues i JOIN dependencies d ON i.id = d.issue_id JOIN issues blocker ON d.depends_on_id = blocker.id - WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') + WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') AND d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') `).Scan(&stats.BlockedIssues) if err != nil { return nil, fmt.Errorf("failed to get blocked count: %w", err) @@ -152,7 +152,7 @@ func (s *SQLiteStorage) GetStatistics(ctx context.Context) (*types.Statistics, e JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.issue_id = i.id AND d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') ) `).Scan(&stats.ReadyIssues) if err != nil { diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index 182e616f..ceba5a79 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -48,6 +48,7 @@ var migrationsList = []Migration{ {"created_by_column", migrations.MigrateCreatedByColumn}, {"agent_fields", migrations.MigrateAgentFields}, {"mol_type_column", migrations.MigrateMolTypeColumn}, + {"hooked_status_migration", migrations.MigrateHookedStatus}, } // MigrationInfo contains metadata about a migration for inspection diff --git a/internal/storage/sqlite/migrations/032_hooked_status_migration.go b/internal/storage/sqlite/migrations/032_hooked_status_migration.go new file mode 100644 index 00000000..64b29daa --- /dev/null +++ b/internal/storage/sqlite/migrations/032_hooked_status_migration.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateHookedStatus converts pinned work items to hooked status. +// 'pinned' now means identity/domain records (agents, roles). +// 'hooked' means work actively attached to an agent's hook (GUPP). +func MigrateHookedStatus(db *sql.DB) error { + // Migrate pinned issues that represent work (not identity records) to hooked. + // Agent/role beads stay pinned; molecules and regular issues become hooked. + result, err := db.Exec(` + UPDATE issues + SET status = 'hooked' + WHERE status = 'pinned' + AND issue_type NOT IN ('agent', 'role') + `) + if err != nil { + return fmt.Errorf("failed to migrate pinned to hooked: %w", err) + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected > 0 { + // Log migration for audit trail (optional - no-op if table doesn't exist) + _, _ = db.Exec(` + INSERT INTO events (issue_id, event_type, actor, old_value, new_value, comment) + SELECT id, 'migration', 'system', 'pinned', 'hooked', 'bd-s00m: Semantic split of pinned vs hooked' + FROM issues + WHERE status = 'hooked' + AND issue_type NOT IN ('agent', 'role') + `) + } + + return nil +} diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index ad3f2ac0..edbededc 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -494,12 +494,12 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context, filter types.WorkF EXISTS ( SELECT 1 FROM issues blocker WHERE blocker.id = d.depends_on_id - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') ) -- External refs: always included (resolution happens at query time) OR d.depends_on_id LIKE 'external:%%' ) - WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') + WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') AND i.pinned = 0 AND ( i.status = 'blocked' @@ -510,7 +510,7 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context, filter types.WorkF JOIN issues blocker ON d2.depends_on_id = blocker.id WHERE d2.issue_id = i.id AND d2.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') ) -- Has external blockers (always considered blocking until resolved) OR EXISTS ( diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 059a750c..111194f9 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -214,7 +214,7 @@ WITH RECURSIVE FROM dependencies d JOIN issues blocker ON d.depends_on_id = blocker.id WHERE d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') ), -- Propagate blockage to all descendants via parent-child blocked_transitively AS ( @@ -245,8 +245,8 @@ SELECT FROM issues i JOIN dependencies d ON i.id = d.issue_id JOIN issues blocker ON d.depends_on_id = blocker.id -WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred') +WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') AND d.type = 'blocks' - AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred') + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') GROUP BY i.id; ` diff --git a/internal/types/types.go b/internal/types/types.go index 83538625..f3672451 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -350,12 +350,13 @@ const ( StatusClosed Status = "closed" StatusTombstone Status = "tombstone" // Soft-deleted issue StatusPinned Status = "pinned" // Persistent bead that stays open indefinitely + StatusHooked Status = "hooked" // Work attached to an agent's hook (GUPP) ) // IsValid checks if the status value is valid (built-in statuses only) func (s Status) IsValid() bool { switch s { - case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned: + case StatusOpen, StatusInProgress, StatusBlocked, StatusDeferred, StatusClosed, StatusTombstone, StatusPinned, StatusHooked: return true } return false diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 787f27f6..0803d5e8 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -58,6 +58,10 @@ var ( Light: "#d2a6ff", // purple - special/elevated Dark: "#d2a6ff", } + ColorStatusHooked = lipgloss.AdaptiveColor{ + Light: "#59c2ff", // cyan - actively worked by agent (GUPP) + Dark: "#59c2ff", + } // === Priority Colors === // Only P0/P1 get color - P2/P3/P4 match standard text @@ -141,6 +145,7 @@ var ( StatusClosedStyle = lipgloss.NewStyle().Foreground(ColorStatusClosed) StatusBlockedStyle = lipgloss.NewStyle().Foreground(ColorStatusBlocked) StatusPinnedStyle = lipgloss.NewStyle().Foreground(ColorStatusPinned) + StatusHookedStyle = lipgloss.NewStyle().Foreground(ColorStatusHooked) ) // Priority styles @@ -265,6 +270,8 @@ func RenderStatus(status string) string { return StatusBlockedStyle.Render(status) case "pinned": return StatusPinnedStyle.Render(status) + case "hooked": + return StatusHookedStyle.Render(status) case "closed": return StatusClosedStyle.Render(status) default: // open and others