Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -696,6 +697,7 @@ Examples:
Executor: taskExecutor,
EffortLevel: effortLevel,
Tags: tags,
AssignedGM: assignedGM,
Pinned: pinned,
SourceBranch: branch,
PermissionMode: permMode,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -793,6 +798,8 @@ Examples:
Status: status,
Project: project,
Type: taskType,
AssignedGM: assignedGM,
Unassigned: unassigned,
Limit: limit,
IncludeClosed: all,
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -5218,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: "",
Expand Down
6 changes: 3 additions & 3 deletions internal/db/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
2 changes: 2 additions & 0 deletions internal/db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions internal/db/task_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading