#!/usr/bin/env python3 """ bttask — Group Task Manager for BTerminal Mission Control. Hierarchical task management for multi-agent orchestration. Tasks stored in SQLite, role-based visibility. Agent identity set via BTMSG_AGENT_ID environment variable. Usage: bttask [args] Commands: list [--all] Show tasks (filtered by role visibility) add Create task (Manager/Architect only) assign <id> <agent> Assign task to agent (Manager only) status <id> <state> Set task status (todo/progress/review/done/blocked) comment <id> <text> Add comment to task show <id> Show task details with comments board Kanban board view delete <id> Delete task (Manager only) priorities Reorder tasks by priority """ import sqlite3 import sys import os import uuid from pathlib import Path from datetime import datetime DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db" TASK_STATES = ['todo', 'progress', 'review', 'done', 'blocked'] # Roles that can create tasks CREATOR_ROLES = {'manager', 'architect'} # Roles that can assign tasks ASSIGNER_ROLES = {'manager'} # Roles that can see full task list VIEWER_ROLES = {'manager', 'architect', 'tester'} # Colors C_RESET = "\033[0m" C_BOLD = "\033[1m" C_DIM = "\033[2m" C_RED = "\033[31m" C_GREEN = "\033[32m" C_YELLOW = "\033[33m" C_BLUE = "\033[34m" C_MAGENTA = "\033[35m" C_CYAN = "\033[36m" C_WHITE = "\033[37m" STATE_COLORS = { 'todo': C_WHITE, 'progress': C_CYAN, 'review': C_YELLOW, 'done': C_GREEN, 'blocked': C_RED, } STATE_ICONS = { 'todo': '○', 'progress': '◐', 'review': '◑', 'done': '●', 'blocked': '✗', } PRIORITY_COLORS = { 'critical': C_RED, 'high': C_YELLOW, 'medium': C_WHITE, 'low': C_DIM, } def get_db(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) db = sqlite3.connect(str(DB_PATH)) db.row_factory = sqlite3.Row db.execute("PRAGMA journal_mode=WAL") return db def init_db(): db = get_db() db.executescript(""" CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '', status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium', assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL, parent_task_id TEXT, sort_order INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (assigned_to) REFERENCES agents(id), FOREIGN KEY (created_by) REFERENCES agents(id) ); CREATE TABLE IF NOT EXISTS task_comments ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (task_id) REFERENCES tasks(id), FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to); CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id); """) db.commit() db.close() def get_agent_id(): agent_id = os.environ.get("BTMSG_AGENT_ID") if not agent_id: print(f"{C_RED}Error: BTMSG_AGENT_ID not set.{C_RESET}") sys.exit(1) return agent_id def get_agent(db, agent_id): return db.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)).fetchone() def short_id(task_id): return task_id[:8] if task_id else "?" def format_time(ts_str): if not ts_str: return "?" try: dt = datetime.fromisoformat(ts_str) return dt.strftime("%m-%d %H:%M") except (ValueError, TypeError): return ts_str[:16] def format_state(state): icon = STATE_ICONS.get(state, '?') color = STATE_COLORS.get(state, C_RESET) return f"{color}{icon} {state}{C_RESET}" def format_priority(priority): color = PRIORITY_COLORS.get(priority, C_RESET) return f"{color}{priority}{C_RESET}" def check_role(db, agent_id, allowed_roles, action="do this"): agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") return None if agent['role'] not in allowed_roles: print(f"{C_RED}Permission denied: {agent['role']} cannot {action}.{C_RESET}") print(f"{C_DIM}Required roles: {', '.join(allowed_roles)}{C_RESET}") return None return agent def find_task(db, task_id_prefix, group_id=None): """Find task by ID prefix, optionally filtered by group.""" if group_id: return db.execute( "SELECT * FROM tasks WHERE id LIKE ? AND group_id = ?", (task_id_prefix + "%", group_id) ).fetchone() return db.execute( "SELECT * FROM tasks WHERE id LIKE ?", (task_id_prefix + "%",) ).fetchone() # ─── Commands ──────────────────────────────────────────────── def cmd_list(args): """List tasks visible to current agent.""" agent_id = get_agent_id() show_all = "--all" in args db = get_db() agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") db.close() return # Tier 2 agents cannot see task list if agent['role'] not in VIEWER_ROLES: print(f"{C_RED}Access denied: project agents don't see the task list.{C_RESET}") print(f"{C_DIM}Tasks are assigned to you via btmsg messages.{C_RESET}") db.close() return group_id = agent['group_id'] if show_all: rows = db.execute( "SELECT t.*, a.name as assignee_name FROM tasks t " "LEFT JOIN agents a ON t.assigned_to = a.id " "WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at", (group_id,) ).fetchall() else: rows = db.execute( "SELECT t.*, a.name as assignee_name FROM tasks t " "LEFT JOIN agents a ON t.assigned_to = a.id " "WHERE t.group_id = ? AND t.status != 'done' " "ORDER BY t.sort_order, t.created_at", (group_id,) ).fetchall() if not rows: print(f"{C_DIM}No tasks.{C_RESET}") db.close() return label = "All tasks" if show_all else "Active tasks" print(f"\n{C_BOLD}📋 {label} ({len(rows)}):{C_RESET}\n") for row in rows: state_str = format_state(row['status']) priority_str = format_priority(row['priority']) assignee = row['assignee_name'] or f"{C_DIM}unassigned{C_RESET}" print(f" {state_str} [{short_id(row['id'])}] {C_BOLD}{row['title']}{C_RESET}") print(f" {priority_str} → {assignee} {C_DIM}{format_time(row['updated_at'])}{C_RESET}") # Show comment count count = db.execute( "SELECT COUNT(*) FROM task_comments WHERE task_id = ?", (row['id'],) ).fetchone()[0] if count > 0: print(f" {C_DIM}💬 {count} comment{'s' if count != 1 else ''}{C_RESET}") print() db.close() def cmd_add(args): """Create a new task.""" if not args: print(f"{C_RED}Usage: bttask add <title> [--desc TEXT] [--priority critical|high|medium|low] [--assign AGENT] [--parent TASK_ID]{C_RESET}") return agent_id = get_agent_id() db = get_db() agent = check_role(db, agent_id, CREATOR_ROLES, "create tasks") if not agent: db.close() return # Parse args title_parts = [] description = "" priority = "medium" assign_to = None parent_id = None i = 0 while i < len(args): if args[i] == "--desc" and i + 1 < len(args): description = args[i + 1] i += 2 elif args[i] == "--priority" and i + 1 < len(args): priority = args[i + 1] if priority not in PRIORITY_COLORS: print(f"{C_RED}Invalid priority: {priority}. Use: critical, high, medium, low{C_RESET}") db.close() return i += 2 elif args[i] == "--assign" and i + 1 < len(args): assign_to = args[i + 1] i += 2 elif args[i] == "--parent" and i + 1 < len(args): parent_id = args[i + 1] i += 2 else: title_parts.append(args[i]) i += 1 title = " ".join(title_parts) if not title: print(f"{C_RED}Title is required.{C_RESET}") db.close() return # Verify assignee if specified if assign_to: assignee = get_agent(db, assign_to) if not assignee: # prefix match row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assign_to + "%",)).fetchone() if row: assign_to = row['id'] else: print(f"{C_RED}Agent '{assign_to}' not found.{C_RESET}") db.close() return # Resolve parent task if parent_id: parent = find_task(db, parent_id, agent['group_id']) if not parent: print(f"{C_RED}Parent task '{parent_id}' not found.{C_RESET}") db.close() return parent_id = parent['id'] # Get max sort_order max_order = db.execute( "SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE group_id = ?", (agent['group_id'],) ).fetchone()[0] task_id = str(uuid.uuid4()) db.execute( "INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, " "group_id, parent_task_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (task_id, title, description, priority, assign_to, agent_id, agent['group_id'], parent_id, max_order + 1) ) db.commit() db.close() print(f"{C_GREEN}✓ Created: {title}{C_RESET} [{short_id(task_id)}]") if assign_to: print(f" {C_DIM}Assigned to: {assign_to}{C_RESET}") def cmd_assign(args): """Assign task to an agent.""" if len(args) < 2: print(f"{C_RED}Usage: bttask assign <task-id> <agent-id>{C_RESET}") return agent_id = get_agent_id() db = get_db() agent = check_role(db, agent_id, ASSIGNER_ROLES, "assign tasks") if not agent: db.close() return task = find_task(db, args[0], agent['group_id']) if not task: print(f"{C_RED}Task not found.{C_RESET}") db.close() return assignee_id = args[1] assignee = get_agent(db, assignee_id) if not assignee: row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assignee_id + "%",)).fetchone() if row: assignee = row assignee_id = row['id'] else: print(f"{C_RED}Agent '{assignee_id}' not found.{C_RESET}") db.close() return db.execute( "UPDATE tasks SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?", (assignee_id, task['id']) ) db.commit() db.close() print(f"{C_GREEN}✓ Assigned [{short_id(task['id'])}] to {assignee['name']}{C_RESET}") def cmd_status(args): """Change task status.""" if len(args) < 2: print(f"{C_RED}Usage: bttask status <task-id> <{'/'.join(TASK_STATES)}>{C_RESET}") return agent_id = get_agent_id() new_status = args[1] if new_status not in TASK_STATES: print(f"{C_RED}Invalid status: {new_status}. Use: {', '.join(TASK_STATES)}{C_RESET}") return db = get_db() agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") db.close() return task = find_task(db, args[0], agent['group_id']) if not task: print(f"{C_RED}Task not found.{C_RESET}") db.close() return # Tier 2 can only update tasks assigned to them if agent['role'] not in VIEWER_ROLES and task['assigned_to'] != agent_id: print(f"{C_RED}Cannot update task not assigned to you.{C_RESET}") db.close() return old_status = task['status'] db.execute( "UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?", (new_status, task['id']) ) # Auto-add comment for status change comment_id = str(uuid.uuid4()) db.execute( "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)", (comment_id, task['id'], agent_id, f"Status: {old_status} → {new_status}") ) db.commit() db.close() print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)}{C_RESET}") def cmd_comment(args): """Add comment to a task.""" if len(args) < 2: print(f"{C_RED}Usage: bttask comment <task-id> <text>{C_RESET}") return agent_id = get_agent_id() db = get_db() agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") db.close() return task = find_task(db, args[0], agent['group_id']) if not task: print(f"{C_RED}Task not found.{C_RESET}") db.close() return content = " ".join(args[1:]) comment_id = str(uuid.uuid4()) db.execute( "INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)", (comment_id, task['id'], agent_id, content) ) db.execute( "UPDATE tasks SET updated_at = datetime('now') WHERE id = ?", (task['id'],) ) db.commit() db.close() print(f"{C_GREEN}✓ Comment added to [{short_id(task['id'])}]{C_RESET}") def cmd_show(args): """Show task details with comments.""" if not args: print(f"{C_RED}Usage: bttask show <task-id>{C_RESET}") return agent_id = get_agent_id() db = get_db() agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") db.close() return task = find_task(db, args[0], agent['group_id']) if not task: print(f"{C_RED}Task not found.{C_RESET}") db.close() return # Get assignee name assignee_name = "unassigned" if task['assigned_to']: assignee = get_agent(db, task['assigned_to']) if assignee: assignee_name = assignee['name'] # Get creator name creator = get_agent(db, task['created_by']) creator_name = creator['name'] if creator else task['created_by'] print(f"\n{C_BOLD}{'─' * 60}{C_RESET}") print(f" {format_state(task['status'])} {C_BOLD}{task['title']}{C_RESET}") print(f"{C_BOLD}{'─' * 60}{C_RESET}") print(f" {C_DIM}ID:{C_RESET} {task['id']}") print(f" {C_DIM}Priority:{C_RESET} {format_priority(task['priority'])}") print(f" {C_DIM}Assigned:{C_RESET} {assignee_name}") print(f" {C_DIM}Created:{C_RESET} {creator_name} @ {format_time(task['created_at'])}") print(f" {C_DIM}Updated:{C_RESET} {format_time(task['updated_at'])}") if task['description']: print(f"\n {task['description']}") if task['parent_task_id']: parent = find_task(db, task['parent_task_id']) if parent: print(f" {C_DIM}Parent:{C_RESET} [{short_id(parent['id'])}] {parent['title']}") # Subtasks subtasks = db.execute( "SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY sort_order", (task['id'],) ).fetchall() if subtasks: print(f"\n {C_BOLD}Subtasks:{C_RESET}") for st in subtasks: print(f" {format_state(st['status'])} [{short_id(st['id'])}] {st['title']}") # Comments comments = db.execute( "SELECT c.*, a.name as agent_name, a.role as agent_role " "FROM task_comments c JOIN agents a ON c.agent_id = a.id " "WHERE c.task_id = ? ORDER BY c.created_at ASC", (task['id'],) ).fetchall() if comments: print(f"\n {C_BOLD}Comments ({len(comments)}):{C_RESET}") for c in comments: time_str = format_time(c['created_at']) print(f" {C_DIM}{time_str}{C_RESET} {C_BOLD}{c['agent_name']}{C_RESET}: {c['content']}") print(f"\n{C_BOLD}{'─' * 60}{C_RESET}\n") db.close() def cmd_board(args): """Kanban board view.""" agent_id = get_agent_id() db = get_db() agent = get_agent(db, agent_id) if not agent: print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}") db.close() return if agent['role'] not in VIEWER_ROLES: print(f"{C_RED}Access denied: project agents don't see the task board.{C_RESET}") db.close() return group_id = agent['group_id'] # Get all tasks grouped by status all_tasks = db.execute( "SELECT t.*, a.name as assignee_name FROM tasks t " "LEFT JOIN agents a ON t.assigned_to = a.id " "WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at", (group_id,) ).fetchall() columns = {} for state in TASK_STATES: columns[state] = [t for t in all_tasks if t['status'] == state] # Calculate column width col_width = 20 # Header print(f"\n{C_BOLD} 📋 Task Board{C_RESET}\n") # Column headers header_line = " " for state in TASK_STATES: icon = STATE_ICONS[state] color = STATE_COLORS[state] count = len(columns[state]) col_header = f"{color}{icon} {state.upper()} ({count}){C_RESET}" header_line += col_header.ljust(col_width + len(color) + len(C_RESET) + 5) print(header_line) print(f" {'─' * (col_width * len(TASK_STATES) + 10)}") # Find max rows max_rows = max(len(columns[s]) for s in TASK_STATES) if all_tasks else 0 for row_idx in range(max_rows): line = " " for state in TASK_STATES: tasks_in_col = columns[state] if row_idx < len(tasks_in_col): t = tasks_in_col[row_idx] title = t['title'][:col_width - 2] assignee = (t['assignee_name'] or "?")[:8] priority_c = PRIORITY_COLORS.get(t['priority'], C_RESET) cell = f"{priority_c}{short_id(t['id'])}{C_RESET} {title}" # Pad to column width (accounting for color codes) visible_len = len(short_id(t['id'])) + 1 + len(title) padding = max(0, col_width - visible_len) line += cell + " " * padding + " " else: line += " " * (col_width + 2) print(line) # Second line with assignee line2 = " " for state in TASK_STATES: tasks_in_col = columns[state] if row_idx < len(tasks_in_col): t = tasks_in_col[row_idx] assignee = (t['assignee_name'] or "unassigned")[:col_width - 2] cell = f"{C_DIM} → {assignee}{C_RESET}" visible_len = 4 + len(assignee) padding = max(0, col_width - visible_len) line2 += cell + " " * padding + " " else: line2 += " " * (col_width + 2) print(line2) print() if not all_tasks: print(f" {C_DIM}No tasks. Create one: bttask add \"Task title\"{C_RESET}") print() db.close() def cmd_delete(args): """Delete a task.""" if not args: print(f"{C_RED}Usage: bttask delete <task-id>{C_RESET}") return agent_id = get_agent_id() db = get_db() agent = check_role(db, agent_id, ASSIGNER_ROLES, "delete tasks") if not agent: db.close() return task = find_task(db, args[0], agent['group_id']) if not task: print(f"{C_RED}Task not found.{C_RESET}") db.close() return title = task['title'] db.execute("DELETE FROM task_comments WHERE task_id = ?", (task['id'],)) db.execute("DELETE FROM tasks WHERE id = ?", (task['id'],)) db.commit() db.close() print(f"{C_GREEN}✓ Deleted: {title}{C_RESET}") def cmd_help(args=None): """Show help.""" print(__doc__) # ─── Main dispatch ─────────────────────────────────────────── COMMANDS = { 'list': cmd_list, 'add': cmd_add, 'assign': cmd_assign, 'status': cmd_status, 'comment': cmd_comment, 'show': cmd_show, 'board': cmd_board, 'delete': cmd_delete, 'help': cmd_help, '--help': cmd_help, '-h': cmd_help, } def main(): init_db() if len(sys.argv) < 2: cmd_help() sys.exit(0) command = sys.argv[1] args = sys.argv[2:] handler = COMMANDS.get(command) if not handler: print(f"{C_RED}Unknown command: {command}{C_RESET}") cmd_help() sys.exit(1) handler(args) if __name__ == "__main__": main()