From 344b5dcb0b8d236a25eb45e438eef2892261f560 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 12:15:23 -0400 Subject: [PATCH 1/4] Add assigned_gm field and DB plumbing for task ownership Introduces an AssignedGM string on Task: a free-form slug identifying the GM (manager session) a task belongs to. Threads it through the INSERT, UPDATE, and all SELECT/scan sites, adds the schema migration, and gives ListTasks --assigned-gm / --unassigned filtering via ListTasksOptions. ty stores the slug verbatim and does no validation; the slug-to-GM mapping is the client's concern. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/dependencies.go | 6 +- internal/db/sqlite.go | 2 + internal/db/tasks.go | 48 ++++++++----- internal/db/tasks_test.go | 135 ++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 22 deletions(-) diff --git a/internal/db/dependencies.go b/internal/db/dependencies.go index 22cbf9ee..34d81047 100644 --- a/internal/db/dependencies.go +++ b/internal/db/dependencies.go @@ -109,7 +109,7 @@ func (db *DB) GetBlockers(taskID int64) ([]*Task, error) { COALESCE(t.daemon_session, ''), COALESCE(t.tmux_window_id, ''), COALESCE(t.claude_pane_id, ''), COALESCE(t.shell_pane_id, ''), COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), - COALESCE(t.dangerous_mode, 0), COALESCE(t.permission_mode, ''), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), + COALESCE(t.dangerous_mode, 0), COALESCE(t.permission_mode, ''), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.assigned_gm, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, t.last_distilled_at, t.last_accessed_at FROM tasks t @@ -133,7 +133,7 @@ func (db *DB) GetBlockedBy(taskID int64) ([]*Task, error) { COALESCE(t.daemon_session, ''), COALESCE(t.tmux_window_id, ''), COALESCE(t.claude_pane_id, ''), COALESCE(t.shell_pane_id, ''), COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), - COALESCE(t.dangerous_mode, 0), COALESCE(t.permission_mode, ''), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), + COALESCE(t.dangerous_mode, 0), COALESCE(t.permission_mode, ''), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.assigned_gm, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, t.last_distilled_at, t.last_accessed_at FROM tasks t @@ -307,7 +307,7 @@ func scanTaskRows(rows *sql.Rows) ([]*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.Summary, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, ) diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 3473bfb2..af519ae7 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -275,6 +275,8 @@ func (db *DB) migrate() error { `ALTER TABLE projects ADD COLUMN default_permission_mode TEXT DEFAULT ''`, // Per-task Claude effort override ("" = use global/Claude default, otherwise low/medium/high/xhigh/max) `ALTER TABLE tasks ADD COLUMN effort_level TEXT DEFAULT ''`, + // Free-form slug identifying the GM (manager session) a task is assigned to ("" = unassigned) + `ALTER TABLE tasks ADD COLUMN assigned_gm TEXT DEFAULT ''`, } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index f21a30e5..9cb6feba 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -35,6 +35,7 @@ type Task struct { PermissionMode string // Permission mode for execution: "default" (prompt), "auto" (acceptEdits), "dangerous" (skip permissions). Empty falls back to DangerousMode/global default. Pinned bool // Whether the task is pinned to the top of its column Tags string // Comma-separated tags for categorization (e.g., "customer-support,email,influence-kit") + AssignedGM string // Free-form slug identifying the GM (manager session) this task is assigned to. Empty means unassigned. ty stores it verbatim; slug→GM mapping is the client's concern. SourceBranch string // Existing branch to checkout for worktree (e.g., "fix/ui-overflow") instead of creating new branch Summary string // Distilled summary of what was accomplished (for search and context) CreatedAt LocalTime @@ -235,9 +236,9 @@ func (db *DB) CreateTask(t *Task) error { t.DangerousMode = t.PermissionMode == PermissionModeDangerous result, err := db.Exec(` - INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, dangerous_mode, permission_mode, effort_level) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.EffortLevel) + INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, assigned_gm, source_branch, dangerous_mode, permission_mode, effort_level) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.AssignedGM, t.SourceBranch, t.DangerousMode, t.PermissionMode, t.EffortLevel) if err != nil { return fmt.Errorf("insert task: %w", err) } @@ -281,7 +282,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -293,7 +294,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -313,6 +314,8 @@ type ListTasksOptions struct { Status string Type string Project string + AssignedGM string // Filter to tasks assigned to this GM slug (exact match) + Unassigned bool // Filter to tasks with no assigned GM (assigned_gm = '') Limit int Offset int IncludeClosed bool // Include closed tasks even when Status is empty @@ -326,7 +329,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -353,6 +356,13 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { query += " AND project = ?" args = append(args, projectName) } + // Unassigned takes precedence over an explicit slug filter: "all tasks no GM owns". + if opts.Unassigned { + query += " AND assigned_gm = ''" + } else if opts.AssignedGM != "" { + query += " AND assigned_gm = ?" + args = append(args, opts.AssignedGM) + } // Exclude done and archived by default unless specifically querying for them or includeClosed is set if opts.Status == "" && !opts.IncludeClosed { @@ -386,7 +396,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -411,7 +421,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -425,7 +435,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -454,7 +464,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -487,7 +497,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -590,13 +600,13 @@ func (db *DB) UpdateTask(t *Task) error { title = ?, body = ?, status = ?, type = ?, project = ?, executor = ?, worktree_path = ?, branch_name = ?, port = ?, claude_session_id = ?, daemon_session = ?, pr_url = ?, pr_number = ?, pr_info_json = ?, dangerous_mode = ?, permission_mode = ?, - pinned = ?, tags = ?, source_branch = ?, effort_level = ?, + pinned = ?, tags = ?, assigned_gm = ?, source_branch = ?, effort_level = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.WorktreePath, t.BranchName, t.Port, t.ClaudeSessionID, t.DaemonSession, t.PRURL, t.PRNumber, t.PRInfoJSON, t.DangerousMode, t.PermissionMode, - t.Pinned, t.Tags, t.SourceBranch, t.EffortLevel, t.ID) + t.Pinned, t.Tags, t.AssignedGM, t.SourceBranch, t.EffortLevel, t.ID) if err != nil { return fmt.Errorf("update task: %w", err) } @@ -921,7 +931,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -936,7 +946,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -959,7 +969,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -982,7 +992,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, @@ -1901,7 +1911,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) { COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(pr_info_json, ''), - COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(dangerous_mode, 0), COALESCE(permission_mode, ''), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(assigned_gm, ''), COALESCE(source_branch, ''), COALESCE(summary, ''), COALESCE(effort_level, ''), created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, @@ -1928,7 +1938,7 @@ func (db *DB) GetStaleWorktreeTasks(maxAge time.Duration) ([]*Task, error) { &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, &t.PRURL, &t.PRNumber, &t.PRInfoJSON, - &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, + &t.DangerousMode, &t.PermissionMode, &t.Pinned, &t.Tags, &t.AssignedGM, &t.SourceBranch, &t.Summary, &t.EffortLevel, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index d2972bb3..5de72584 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -2500,3 +2500,138 @@ func TestGetStaleWorktreeTasks(t *testing.T) { t.Fatalf("expected 2 stale tasks, got %d", len(tasks)) } } + +func TestAssignedGMPersistence(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + defer os.Remove(dbPath) + + // A task created without an assignment is unassigned (empty string). + unassigned := &Task{ + Title: "Unassigned task", + Status: StatusBacklog, + Type: TypeCode, + Project: "personal", + } + if err := db.CreateTask(unassigned); err != nil { + t.Fatalf("failed to create task: %v", err) + } + retrieved, err := db.GetTask(unassigned.ID) + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if retrieved.AssignedGM != "" { + t.Errorf("expected empty assigned_gm by default, got %q", retrieved.AssignedGM) + } + + // A task created with an assignment round-trips through CreateTask/GetTask. + assigned := &Task{ + Title: "Assigned task", + Status: StatusBacklog, + Type: TypeCode, + Project: "personal", + AssignedGM: "cortex-gm", + } + if err := db.CreateTask(assigned); err != nil { + t.Fatalf("failed to create task: %v", err) + } + retrieved, err = db.GetTask(assigned.ID) + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if retrieved.AssignedGM != "cortex-gm" { + t.Errorf("expected assigned_gm %q, got %q", "cortex-gm", retrieved.AssignedGM) + } + + // Reassignment via UpdateTask persists. + retrieved.AssignedGM = "atlas-gm" + if err := db.UpdateTask(retrieved); err != nil { + t.Fatalf("failed to update task: %v", err) + } + updated, err := db.GetTask(assigned.ID) + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if updated.AssignedGM != "atlas-gm" { + t.Errorf("expected assigned_gm %q after update, got %q", "atlas-gm", updated.AssignedGM) + } + + // Clearing the assignment via UpdateTask unassigns the task. + updated.AssignedGM = "" + if err := db.UpdateTask(updated); err != nil { + t.Fatalf("failed to update task: %v", err) + } + cleared, err := db.GetTask(assigned.ID) + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if cleared.AssignedGM != "" { + t.Errorf("expected assigned_gm to be cleared, got %q", cleared.AssignedGM) + } +} + +func TestListTasksFilterByAssignedGM(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Open(dbPath) + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + defer os.Remove(dbPath) + + mk := func(title, gm string) int64 { + task := &Task{Title: title, Status: StatusBacklog, Type: TypeCode, Project: "personal", AssignedGM: gm} + if err := db.CreateTask(task); err != nil { + t.Fatalf("failed to create task: %v", err) + } + return task.ID + } + cortex := mk("Cortex task", "cortex-gm") + atlas := mk("Atlas task", "atlas-gm") + free := mk("Free task", "") + + // Filter by a specific GM slug. + tasks, err := db.ListTasks(ListTasksOptions{AssignedGM: "cortex-gm"}) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].ID != cortex { + t.Fatalf("expected only the cortex-gm task, got %d tasks", len(tasks)) + } + + // Filter to unassigned tasks only. + tasks, err = db.ListTasks(ListTasksOptions{Unassigned: true}) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].ID != free { + t.Fatalf("expected only the unassigned task, got %d tasks", len(tasks)) + } + + // Unassigned takes precedence over an explicit slug filter. + tasks, err = db.ListTasks(ListTasksOptions{AssignedGM: "atlas-gm", Unassigned: true}) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].ID != free { + t.Fatalf("expected Unassigned to override AssignedGM, got %d tasks", len(tasks)) + } + + // No filter returns all three. + tasks, err = db.ListTasks(ListTasksOptions{}) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 3 { + t.Fatalf("expected 3 tasks with no filter, got %d", len(tasks)) + } + _ = atlas +} From 8426ddf18bb738d7c5fef80e5539f48cd09df9cd Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 12:15:23 -0400 Subject: [PATCH 2/4] Expose assigned_gm via CLI, MCP, and event hooks Adds --assigned-gm to `ty create` / `ty update` / `ty list` and --unassigned to `ty list`, an assigned_gm property on the taskyou_create_task MCP tool, and a TASK_ASSIGNED_GM env var passed to event hook scripts so push-channel clients can scope events per GM. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/task/main.go | 14 ++++++++++++++ internal/events/events.go | 1 + internal/events/events_test.go | 25 +++++++++++++++++++++++++ internal/mcp/server.go | 6 ++++++ 4 files changed, 46 insertions(+) diff --git a/cmd/task/main.go b/cmd/task/main.go index d91c303a..7c05ba31 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -570,6 +570,7 @@ Examples: createDangerous, _ := cmd.Flags().GetBool("dangerous") permissionModeFlag, _ := cmd.Flags().GetString("permission-mode") tags, _ := cmd.Flags().GetString("tags") + assignedGM, _ := cmd.Flags().GetString("assigned-gm") pinned, _ := cmd.Flags().GetBool("pinned") branch, _ := cmd.Flags().GetString("branch") outputJSON, _ := cmd.Flags().GetBool("json") @@ -696,6 +697,7 @@ Examples: Executor: taskExecutor, EffortLevel: effortLevel, Tags: tags, + AssignedGM: assignedGM, Pinned: pinned, SourceBranch: branch, PermissionMode: permMode, @@ -748,6 +750,7 @@ Examples: createCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (alias for --permission-mode dangerous)") createCmd.Flags().String("permission-mode", "", "Permission mode: default (prompt), auto (auto-accept edits), dangerous (skip all). Defaults to the project's setting") createCmd.Flags().String("tags", "", "Task tags (comma-separated)") + createCmd.Flags().String("assigned-gm", "", "Slug of the GM (manager session) this task is assigned to") createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column") createCmd.Flags().StringP("branch", "b", "", "Existing branch to checkout for worktree (e.g., fix/ui-overflow)") createCmd.Flags().Bool("json", false, "Output in JSON format") @@ -775,6 +778,8 @@ Examples: status, _ := cmd.Flags().GetString("status") project, _ := cmd.Flags().GetString("project") taskType, _ := cmd.Flags().GetString("type") + assignedGM, _ := cmd.Flags().GetString("assigned-gm") + unassigned, _ := cmd.Flags().GetBool("unassigned") all, _ := cmd.Flags().GetBool("all") limit, _ := cmd.Flags().GetInt("limit") outputJSON, _ := cmd.Flags().GetBool("json") @@ -793,6 +798,8 @@ Examples: Status: status, Project: project, Type: taskType, + AssignedGM: assignedGM, + Unassigned: unassigned, Limit: limit, IncludeClosed: all, } @@ -931,6 +938,8 @@ Examples: listCmd.Flags().StringP("status", "s", "", "Filter by status: backlog, queued, processing, blocked, done") listCmd.Flags().StringP("project", "p", "", "Filter by project") listCmd.Flags().StringP("type", "t", "", "Filter by type: code, writing, thinking") + listCmd.Flags().String("assigned-gm", "", "Filter by assigned GM slug") + listCmd.Flags().Bool("unassigned", false, "Show only tasks with no assigned GM (overrides --assigned-gm)") listCmd.Flags().BoolP("all", "a", false, "Include completed tasks") listCmd.Flags().IntP("limit", "n", 50, "Maximum number of tasks to return") listCmd.Flags().Bool("json", false, "Output in JSON format") @@ -1306,6 +1315,7 @@ Examples: project, _ := cmd.Flags().GetString("project") taskExecutor, _ := cmd.Flags().GetString("executor") tags, _ := cmd.Flags().GetString("tags") + assignedGM, _ := cmd.Flags().GetString("assigned-gm") pinned, _ := cmd.Flags().GetBool("pinned") // Open database @@ -1383,6 +1393,9 @@ Examples: if cmd.Flags().Changed("tags") { task.Tags = tags } + if cmd.Flags().Changed("assigned-gm") { + task.AssignedGM = assignedGM + } if cmd.Flags().Changed("pinned") { task.Pinned = pinned } @@ -1401,6 +1414,7 @@ Examples: updateCmd.Flags().StringP("project", "p", "", "Update project name") updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, pi, opencode, openclaw") updateCmd.Flags().String("tags", "", "Update task tags (comma-separated)") + updateCmd.Flags().String("assigned-gm", "", "Update the GM (manager session) slug this task is assigned to (empty string unassigns)") updateCmd.Flags().Bool("pinned", false, "Pin or unpin the task") updateCmd.RegisterFlagCompletionFunc("project", completeFlagProjects) updateCmd.RegisterFlagCompletionFunc("type", completeFlagTypes) diff --git a/internal/events/events.go b/internal/events/events.go index bf2194bd..97a2c658 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -90,6 +90,7 @@ func (e *Emitter) runHook(event Event) { fmt.Sprintf("TASK_TITLE=%s", event.Task.Title), fmt.Sprintf("TASK_STATUS=%s", event.Task.Status), fmt.Sprintf("TASK_PROJECT=%s", event.Task.Project), + fmt.Sprintf("TASK_ASSIGNED_GM=%s", event.Task.AssignedGM), ) if event.Task.WorktreePath != "" { env = append(env, diff --git a/internal/events/events_test.go b/internal/events/events_test.go index 16dfc6c5..c265d4b5 100644 --- a/internal/events/events_test.go +++ b/internal/events/events_test.go @@ -78,6 +78,31 @@ echo "$TASK_STATUS:$TASK_PROJECT" > "` + markerFile + `" } } +func TestEmitterPassesAssignedGM(t *testing.T) { + hooksDir := t.TempDir() + markerFile := filepath.Join(hooksDir, "gm_marker") + hookScript := filepath.Join(hooksDir, TaskCompleted) + + script := `#!/bin/sh +echo "$TASK_ASSIGNED_GM" > "` + markerFile + `" +` + if err := os.WriteFile(hookScript, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + emitter := New(hooksDir) + task := &db.Task{ID: 1, Title: "Done", Status: "done", Project: "personal", AssignedGM: "cortex-gm"} + emitter.Emit(Event{Type: TaskCompleted, TaskID: task.ID, Task: task}) + + content, err := waitForFile(t, markerFile, 5*time.Second) + if err != nil { + t.Fatalf("hook didn't run: %v", err) + } + if string(content) != "cortex-gm\n" { + t.Errorf("unexpected hook output: %q", content) + } +} + func TestEmitterWorktreeReadyPassesEnv(t *testing.T) { hooksDir := t.TempDir() markerFile := filepath.Join(hooksDir, "wt_marker") diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 655785a6..6fab13a2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -245,6 +245,10 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "description": "Permission mode for execution: 'default' (prompt), 'auto' (auto-accept edits), or 'dangerous' (skip all prompts). Defaults to the project's configured default.", "enum": []string{"default", "auto", "dangerous"}, }, + "assigned_gm": map[string]interface{}{ + "type": "string", + "description": "Free-form slug of the GM (manager session) this task is assigned to. Used for multi-GM event scoping. Empty means unassigned.", + }, }, "required": []string{"title"}, }, @@ -556,6 +560,7 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { status, _ := params.Arguments["status"].(string) dangerousMode, _ := params.Arguments["dangerous_mode"].(bool) permissionMode, _ := params.Arguments["permission_mode"].(string) + assignedGM, _ := params.Arguments["assigned_gm"].(string) // Default project to current task's project if project == "" { @@ -583,6 +588,7 @@ func (s *Server) handleToolCall(id interface{}, params *toolCallParams) { Type: taskType, Status: status, PermissionMode: permissionMode, + AssignedGM: assignedGM, } if err := s.db.CreateTask(newTask); err != nil { From d725ad39d2b855d87f97b3a0f8d1a9330e709380 Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 12:17:35 -0400 Subject: [PATCH 3/4] Emit task.updated event when assigned_gm changes UpdateTask now records an assigned_gm change in the emitted task.updated event's metadata ({"old","new"}, matching the existing per-field convention), so per-GM channels can react to a task becoming theirs, being reassigned away, or returning to the unassigned pool. The event only fires when the value actually changed. runHook also exposes TASK_PREVIOUS_ASSIGNED_GM when the update carries an assignment change, so hooks can detect the direction of the change without parsing TASK_METADATA as JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/task_events_test.go | 57 +++++++++++++++++++++++++++++++++ internal/db/tasks.go | 5 +++ internal/events/events.go | 6 ++++ internal/events/events_test.go | 27 ++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/internal/db/task_events_test.go b/internal/db/task_events_test.go index f6499367..aa75dc5e 100644 --- a/internal/db/task_events_test.go +++ b/internal/db/task_events_test.go @@ -459,6 +459,63 @@ func TestUpdateTaskEmitsEventWithChanges(t *testing.T) { } } +func TestUpdateTaskEmitsAssignmentChange(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + mockEmitter := &MockEventEmitter{} + database.SetEventEmitter(mockEmitter) + + task := &Task{ + Title: "Assignable task", + Status: StatusBacklog, + Type: "code", + Project: "personal", + AssignedGM: "cortex-gm", + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Clear created events. + mockEmitter.UpdatedTasks = nil + mockEmitter.Changes = nil + + // Reassign to a different GM. + task.AssignedGM = "atlas-gm" + if err := database.UpdateTask(task); err != nil { + t.Fatalf("Failed to update task: %v", err) + } + + if len(mockEmitter.UpdatedTasks) != 1 { + t.Fatalf("Expected 1 updated task event, got %d", len(mockEmitter.UpdatedTasks)) + } + change, ok := mockEmitter.Changes[0]["assigned_gm"].(map[string]string) + if !ok { + t.Fatalf("Expected assigned_gm change in metadata, got %v", mockEmitter.Changes[0]) + } + if change["old"] != "cortex-gm" { + t.Errorf("Expected old assigned_gm 'cortex-gm', got '%s'", change["old"]) + } + if change["new"] != "atlas-gm" { + t.Errorf("Expected new assigned_gm 'atlas-gm', got '%s'", change["new"]) + } + + // An update that does not touch AssignedGM must not report an assignment change. + mockEmitter.UpdatedTasks = nil + mockEmitter.Changes = nil + task.Title = "Renamed task" + if err := database.UpdateTask(task); err != nil { + t.Fatalf("Failed to update task: %v", err) + } + if len(mockEmitter.Changes) != 1 { + t.Fatalf("Expected 1 change record for the title update, got %d", len(mockEmitter.Changes)) + } + if _, ok := mockEmitter.Changes[0]["assigned_gm"]; ok { + t.Errorf("Did not expect an assigned_gm change when only the title changed: %v", mockEmitter.Changes[0]) + } +} + func TestUpdateTaskStatusEmitsLifecycleEvents(t *testing.T) { database := setupTestDB(t) defer database.Close() diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 9cb6feba..c9d621d0 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -629,6 +629,11 @@ func (db *DB) UpdateTask(t *Task) error { if oldTask.Project != t.Project { changes["project"] = map[string]string{"old": oldTask.Project, "new": t.Project} } + // Emit assignment changes so per-GM channels can react to a task becoming + // theirs, being reassigned away, or returning to the unassigned pool. + if oldTask.AssignedGM != t.AssignedGM { + changes["assigned_gm"] = map[string]string{"old": oldTask.AssignedGM, "new": t.AssignedGM} + } if len(changes) > 0 { db.emitTaskUpdated(t, changes) } diff --git a/internal/events/events.go b/internal/events/events.go index 97a2c658..a2c07312 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -105,6 +105,12 @@ func (e *Emitter) runHook(event Event) { if data, err := json.Marshal(event.Metadata); err == nil { env = append(env, fmt.Sprintf("TASK_METADATA=%s", string(data))) } + // When an update carries an assignment change, expose the previous GM + // directly so hooks can tell the direction of the change (newly mine vs. + // reassigned away) without having to parse TASK_METADATA as JSON. + if change, ok := event.Metadata["assigned_gm"].(map[string]string); ok { + env = append(env, fmt.Sprintf("TASK_PREVIOUS_ASSIGNED_GM=%s", change["old"])) + } } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/internal/events/events_test.go b/internal/events/events_test.go index c265d4b5..f49c34fd 100644 --- a/internal/events/events_test.go +++ b/internal/events/events_test.go @@ -103,6 +103,33 @@ echo "$TASK_ASSIGNED_GM" > "` + markerFile + `" } } +func TestEmitterPassesPreviousAssignedGM(t *testing.T) { + hooksDir := t.TempDir() + markerFile := filepath.Join(hooksDir, "prev_gm_marker") + hookScript := filepath.Join(hooksDir, TaskUpdated) + + script := `#!/bin/sh +echo "$TASK_PREVIOUS_ASSIGNED_GM->$TASK_ASSIGNED_GM" > "` + markerFile + `" +` + if err := os.WriteFile(hookScript, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + emitter := New(hooksDir) + task := &db.Task{ID: 1, Title: "Reassigned", Status: "backlog", Project: "personal", AssignedGM: "atlas-gm"} + emitter.Emit(Event{Type: TaskUpdated, TaskID: task.ID, Task: task, Metadata: map[string]interface{}{ + "assigned_gm": map[string]string{"old": "cortex-gm", "new": "atlas-gm"}, + }}) + + content, err := waitForFile(t, markerFile, 5*time.Second) + if err != nil { + t.Fatalf("hook didn't run: %v", err) + } + if string(content) != "cortex-gm->atlas-gm\n" { + t.Errorf("unexpected hook output: %q", content) + } +} + func TestEmitterWorktreeReadyPassesEnv(t *testing.T) { hooksDir := t.TempDir() markerFile := filepath.Join(hooksDir, "wt_marker") From b6d73c9b63cc227cbaabe6bc7891c8f67e1a3bab Mon Sep 17 00:00:00 2001 From: Kyle Carbonneau Date: Sat, 6 Jun 2026 12:25:27 -0400 Subject: [PATCH 4/4] Preserve assigned_gm across task edit and project-move flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI edit form and the move-to-project clone paths reconstruct a task without an assignment field, which would silently drop assigned_gm and — now that UpdateTask emits an assignment-change event — fire a spurious "unassigned" event on an unrelated edit. Carry the existing assignment through all three paths (TUI edit-save, TUI project move, CLI move-to-project), mirroring how tags is already preserved on the CLI clone. Also drop a stray unused variable in the list-filter test. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/task/main.go | 15 ++++++++------- internal/db/tasks_test.go | 3 +-- internal/ui/app.go | 7 +++++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd/task/main.go b/cmd/task/main.go index 7c05ba31..089351a2 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -5232,13 +5232,14 @@ func moveTask(database *db.DB, oldTask *db.Task, targetProject string) (int64, e // Step 3: Create new task in target project // Reset execution-related fields but preserve content newTask := &db.Task{ - Title: oldTask.Title, - Body: oldTask.Body, - Type: oldTask.Type, - Tags: oldTask.Tags, - Project: targetProject, - Executor: oldTask.Executor, - Pinned: oldTask.Pinned, + Title: oldTask.Title, + Body: oldTask.Body, + Type: oldTask.Type, + Tags: oldTask.Tags, + AssignedGM: oldTask.AssignedGM, + Project: targetProject, + Executor: oldTask.Executor, + Pinned: oldTask.Pinned, // Reset execution state WorktreePath: "", BranchName: "", diff --git a/internal/db/tasks_test.go b/internal/db/tasks_test.go index 5de72584..ab98905d 100644 --- a/internal/db/tasks_test.go +++ b/internal/db/tasks_test.go @@ -2595,7 +2595,7 @@ func TestListTasksFilterByAssignedGM(t *testing.T) { return task.ID } cortex := mk("Cortex task", "cortex-gm") - atlas := mk("Atlas task", "atlas-gm") + mk("Atlas task", "atlas-gm") // background data: must be excluded by the filters below free := mk("Free task", "") // Filter by a specific GM slug. @@ -2633,5 +2633,4 @@ func TestListTasksFilterByAssignedGM(t *testing.T) { if len(tasks) != 3 { t.Fatalf("expected 3 tasks with no filter, got %d", len(tasks)) } - _ = atlas } diff --git a/internal/ui/app.go b/internal/ui/app.go index 554a350b..2b9e5d1f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2919,6 +2919,10 @@ func (m *AppModel) updateEditTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) { updatedTask.CreatedAt = m.editingTask.CreatedAt updatedTask.StartedAt = m.editingTask.StartedAt updatedTask.CompletedAt = m.editingTask.CompletedAt + // The edit form has no assignment field, so carry the existing value + // through; otherwise an unrelated edit would silently unassign the task + // and emit a spurious assignment-change event. + updatedTask.AssignedGM = m.editingTask.AssignedGM // Capture old title before clearing editingTask oldTitle := m.editingTask.Title @@ -4637,6 +4641,9 @@ func (m *AppModel) moveTaskToProject(newTaskData *db.Task, oldTask *db.Task) tea // Create the new task in the target project // Reset fields that should be fresh for the new task newTaskData.ID = 0 + // Assignment is independent of project; carry it across the move (the edit + // form that produced newTaskData has no assignment field). + newTaskData.AssignedGM = oldTask.AssignedGM newTaskData.WorktreePath = "" newTaskData.BranchName = "" newTaskData.Port = 0