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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user