From ef29076dea1befb23efbca0200c5218afba38934 Mon Sep 17 00:00:00 2001 From: 1Broseidon Date: Sat, 28 Feb 2026 16:35:48 -0600 Subject: [PATCH 1/2] feat: Replace todo.txt with persistent brainfile-backed task files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateTodoList now writes to structured task files in .cecli/tasks/board/ instead of a flat .cecli/todo.txt. The agent and user experience is identical — same tool, same prompt, same display — but todos now persist across sessions, maintain history in logs/, and support power-user access via /task commands. Key changes: - UpdateTodoList always routes through CecliTaskStore (single write path) - Session tasks auto-create on first UpdateTodoList call - get_todo_list() auto-resumes incomplete tasks from previous sessions - Task files never leak into agent context (only block visible) - Removed 7 board-only LLM tools (TaskCreate, TaskList, etc.) - Added /task new, removed /task promote - Session save/restore preserves active_task_id - Tasks-only type system (no epic/adr in initial version) - Cached tab completion for task IDs - Passes isort, black, flake8 pre-commit checks --- cecli/brainfile/__init__.py | 3 + cecli/brainfile/store.py | 671 +++++++++++++++++++++++++++ cecli/coders/agent_coder.py | 46 +- cecli/coders/base_coder.py | 11 +- cecli/commands/__init__.py | 3 + cecli/commands/task.py | 340 ++++++++++++++ cecli/prompts/agent.yml | 76 +-- cecli/sessions.py | 46 +- cecli/tools/__init__.py | 2 - cecli/tools/update_todo_list.py | 114 ++--- cecli/tools/utils/registry.py | 18 +- cecli/tui/app.py | 28 +- cecli/tui/io.py | 24 + cecli/tui/widgets/__init__.py | 2 + cecli/tui/widgets/active_task_bar.py | 50 ++ cecli/website/docs/sessions.md | 18 +- handoff.md | 239 ++++++++++ pyproject.toml | 3 + requirements/requirements.in | 3 +- tests/basic/test_cecli_task_store.py | 262 +++++++++++ tests/basic/test_sessions.py | 104 ++++- tests/basic/test_task_management.py | 209 +++++++++ 22 files changed, 2070 insertions(+), 202 deletions(-) create mode 100644 cecli/brainfile/__init__.py create mode 100644 cecli/brainfile/store.py create mode 100644 cecli/commands/task.py create mode 100644 cecli/tui/widgets/active_task_bar.py create mode 100644 handoff.md create mode 100644 tests/basic/test_cecli_task_store.py create mode 100644 tests/basic/test_task_management.py diff --git a/cecli/brainfile/__init__.py b/cecli/brainfile/__init__.py new file mode 100644 index 00000000000..3d841c75c71 --- /dev/null +++ b/cecli/brainfile/__init__.py @@ -0,0 +1,3 @@ +from cecli.brainfile.store import CecliTaskStore + +__all__ = ["CecliTaskStore"] diff --git a/cecli/brainfile/store.py b/cecli/brainfile/store.py new file mode 100644 index 00000000000..921b8706c97 --- /dev/null +++ b/cecli/brainfile/store.py @@ -0,0 +1,671 @@ +"""Thin adapter between cecli and the brainfile library. + +The brainfile library handles all protocol-level operations (task CRUD, +file I/O, ID generation, complete-to-logs). This adapter adds only +cecli-specific concerns: + +* Loading/dropping task files from the coder context +* Active-task state and TUI bar updates +* Rendering context blocks for the LLM prompt +* The `.cecli/tasks/` path convention (vs `.brainfile/`) +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from brainfile import ( + Subtask, + addTaskFile, + completeTaskFile, + deleteTaskFile, + ensureV2Dirs, + findV2Task, + getV2Dirs, + readTasksDir, + writeTaskFile, +) +from brainfile.workspace import V2Dirs + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BRAINFILE_NAME = "brainfile.md" +_DEFAULT_BOARD_YAML = """\ +--- +title: cecli tasks +type: board +schema: https://brainfile.md/v2/board.json +protocolVersion: 2.0.0 +strict: false +columns: + - id: in-progress + title: In Progress +--- +# cecli tasks + +Shared task board for this repository. +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_task_id(raw: str) -> str: + """Accept flexible task references: '11', 'task 11', 'task-11', 'task-11.md'.""" + normalized = raw.strip() + if not normalized: + return normalized + + normalized = Path(normalized).name + if normalized.endswith(".md"): + normalized = normalized[:-3] + + if normalized.isdigit(): + return f"task-{normalized}" + + m = re.fullmatch(r"([A-Za-z]+)[\s\-_]?(\d+)", normalized) + if m: + return f"{m.group(1).lower()}-{m.group(2)}" + + return normalized + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + + +class CecliTaskStore: + """Manages `.cecli/tasks/` using the brainfile library for all mutations.""" + + def __init__(self, repo_root: str | Path): + self.repo_root = Path(repo_root) + self._brainfile_path = self.repo_root / ".cecli" / "tasks" / _BRAINFILE_NAME + + # -- paths -------------------------------------------------------------- + + @property + def dirs(self) -> V2Dirs: + return getV2Dirs(str(self._brainfile_path)) + + def relpath(self, path: Path | str) -> str: + return str(Path(path).relative_to(self.repo_root)) + + def board_exists(self) -> bool: + return self._brainfile_path.is_file() + + # -- initialisation (only on explicit /task usage) ---------------------- + + def ensure_initialized(self) -> V2Dirs: + dirs = ensureV2Dirs(str(self._brainfile_path)) + if not self._brainfile_path.is_file(): + self._brainfile_path.write_text(_DEFAULT_BOARD_YAML, encoding="utf-8") + return dirs + + # -- task CRUD (delegates to brainfile) --------------------------------- + + def list_tasks(self, scope: str = "board") -> List[Dict[str, Any]]: + if not self.board_exists(): + return [] + dirs = self.dirs + scope = (scope or "board").strip().lower() + + entries: list[tuple[str, Any]] = [] + if scope in ("board", "all"): + for doc in readTasksDir(dirs.boardDir): + entries.append(("board", doc)) + if scope in ("logs", "all"): + for doc in readTasksDir(dirs.logsDir): + entries.append(("logs", doc)) + + tasks = [] + for location, doc in entries: + t = doc.task + subtasks = t.subtasks or [] + total = len(subtasks) + done = sum(1 for s in subtasks if s.completed) + tasks.append( + { + "id": t.id, + "title": t.title, + "column": t.column or ("logs" if location == "logs" else "in-progress"), + "location": location, + "subtasks_total": total, + "subtasks_done": done, + "relpath": self.relpath( + os.path.join( + dirs.boardDir if location == "board" else dirs.logsDir, + f"{t.id}.md", + ) + ), + } + ) + return tasks + + def find_task(self, task_id: str) -> Optional[Dict[str, Any]]: + if not self.board_exists(): + return None + dirs = self.dirs + normalized = _normalize_task_id(task_id) + result = findV2Task(dirs, normalized, searchLogs=True) + if not result: + return None + doc = result["doc"] + is_log = result["isLog"] + location = "logs" if is_log else "board" + return { + "id": doc.task.id, + "title": doc.task.title, + "location": location, + "path": Path(result["filePath"]), + "relpath": self.relpath(result["filePath"]), + "task": doc.task, + "body": doc.body, + } + + def create_task( + self, + title: str, + column: str = "in-progress", + description: Optional[str] = None, + subtasks: Optional[List[str]] = None, + ) -> Dict[str, Any]: + dirs = self.ensure_initialized() + result = addTaskFile( + dirs.boardDir, + { + "title": title, + "column": column, + "type": "task", + "description": description or "", + "subtasks": subtasks or [], + }, + body=f"## Description\n\n{description or ''}\n", + logsDir=dirs.logsDir, + ) + if not result.get("success"): + raise ValueError(result.get("error", "Failed to create task")) + task = result["task"] + return { + "id": task.id, + "title": task.title, + "column": task.column, + "location": "board", + "path": Path(result["filePath"]), + "relpath": self.relpath(result["filePath"]), + "task": task, + } + + def update_task( + self, + task_id: str, + title: Optional[str] = None, + column: Optional[str] = None, + ) -> Dict[str, Any]: + found = self.find_task(task_id) + if not found: + raise FileNotFoundError(f"Task not found: {task_id}") + if found["location"] != "board": + raise ValueError("Only active board tasks can be updated") + + task = found["task"] + changed = False + updates: dict[str, Any] = {} + + if title is not None and title.strip() and task.title != title.strip(): + updates["title"] = title.strip() + changed = True + if column is not None and column.strip() and task.column != column.strip(): + updates["column"] = column.strip() + changed = True + + if changed: + from datetime import datetime + + updates["updated_at"] = datetime.now().isoformat() + updated_task = task.model_copy(update=updates) + writeTaskFile(str(found["path"]), updated_task, found["body"]) + + final_task = task.model_copy(update=updates) if changed else task + return { + "id": final_task.id, + "title": final_task.title, + "column": final_task.column or "in-progress", + "changed": changed, + "location": "board", + "relpath": found["relpath"], + } + + def complete_task(self, task_id: str) -> Dict[str, Any]: + found = self.find_task(task_id) + if not found: + raise FileNotFoundError(f"Task not found: {task_id}") + if found["location"] != "board": + raise ValueError("Only active board tasks can be completed") + + dirs = self.dirs + result = completeTaskFile(str(found["path"]), dirs.logsDir) + if not result.get("success"): + raise ValueError(result.get("error", "Failed to complete task")) + return { + "id": found["id"], + "title": found["title"], + "location": "logs", + "relpath": self.relpath(result["filePath"]), + } + + def delete_task(self, task_id: str) -> Dict[str, Any]: + found = self.find_task(task_id) + if not found: + raise FileNotFoundError(f"Task not found: {task_id}") + result = deleteTaskFile(str(found["path"])) + if not result.get("success"): + raise ValueError(result.get("error", "Failed to delete task")) + return { + "id": found["id"], + "location": found["location"], + "relpath": found["relpath"], + } + + def next_task_id(self, current_task_id: str, scope: str = "board") -> Optional[str]: + """Get the next same-prefix ID by numeric order.""" + current = _normalize_task_id(current_task_id) + m = re.fullmatch(r"([a-zA-Z][a-zA-Z0-9\-]*)-(\d+)", current) + if not m: + return None + prefix = m.group(1).lower() + current_num = int(m.group(2)) + + entries = self.list_tasks(scope=scope) + candidates = [] + for entry in entries: + em = re.fullmatch(r"([a-zA-Z][a-zA-Z0-9\-]*)-(\d+)", entry["id"]) + if not em or em.group(1).lower() != prefix: + continue + num = int(em.group(2)) + if num > current_num: + candidates.append((num, entry["id"])) + + if not candidates: + return None + candidates.sort() + return candidates[0][1] + + # -- session task (invisible auto-create) -------------------------------- + + def get_or_create_session_task(self, coder) -> dict: + """Return an incomplete board task or create a new one. + + Called transparently by UpdateTodoList and get_todo_list() so the + agent never needs to know about brainfile / boards. + + 1. Ensures the board directory exists. + 2. Scans ``board/`` for the most recently updated incomplete task. + 3. If found → sets ``coder.active_task_id``, returns it. + 4. Otherwise → creates a new ``in-progress`` task, sets active, returns it. + + Does **not** add the task file to coder context — the agent sees + only the ```` block rendered by + ``render_task_todo_block()``. + """ + dirs = self.ensure_initialized() + + # Scan board for most recently updated task with incomplete subtasks + best: dict | None = None + best_mtime: float = 0.0 + + for doc in readTasksDir(dirs.boardDir): + t = doc.task + subtasks = t.subtasks or [] + has_incomplete = not subtasks or any(not s.completed for s in subtasks) + if not has_incomplete: + continue + file_path = os.path.join(dirs.boardDir, f"{t.id}.md") + try: + mtime = os.path.getmtime(file_path) + except OSError: + mtime = 0.0 + if mtime >= best_mtime: + best_mtime = mtime + best = { + "id": t.id, + "title": t.title, + "task": t, + "path": Path(file_path), + "relpath": self.relpath(file_path), + } + + if best: + task = best["task"] + task_id = best["id"] + else: + # Create a new task + title = self.auto_title(None) + created = self.create_task(title=title, column="in-progress") + task_id = created["id"] + task = created["task"] + + # Set active_task_id + update TUI — but do NOT add the file to + # coder context so the agent never sees raw YAML. + subtasks = task.subtasks or [] + done = sum(1 for s in subtasks if s.completed) + total = len(subtasks) + + setattr(coder, "active_task_id", task_id) + + io = getattr(coder, "io", None) + set_active_task = getattr(io, "set_active_task", None) + if callable(set_active_task): + set_active_task( + task_id=task_id, + title=task.title, + column=task.column or "in-progress", + subtasks_done=done, + subtasks_total=total, + mode="invisible", + location="board", + ) + + return { + "id": task_id, + "title": task.title, + "column": task.column or "in-progress", + "location": "board", + "mode": "invisible", + "subtasks_done": done, + "subtasks_total": total, + } + + @staticmethod + def auto_title(items: list[dict] | None) -> str: + """Derive a short title from the first non-done item, or a date fallback.""" + if items: + for item in items: + if not item.get("done", False): + text = str(item.get("task", "")).strip() + if text: + return text[:80] + from datetime import date + + return f"Session {date.today().isoformat()}" + + # -- subtask helpers (for UpdateTodoList bridge) ------------------------ + + def get_task_file_path(self, task_id: str) -> Optional[Path]: + found = self.find_task(task_id) + if not found: + return None + return found["path"] + + def update_task_subtasks( + self, + task_id: str, + items: List[Dict[str, Any]], + append: bool = False, + ) -> Path: + """Update a board task's subtasks from UpdateTodoList-style items.""" + found = self.find_task(task_id) + if not found: + raise FileNotFoundError(f"Task not found: {task_id}") + if found["location"] != "board": + raise ValueError("Only active board tasks can be updated") + + task = found["task"] + existing = list(task.subtasks or []) + + # Parse incoming items + parsed = [] + for item in items: + title = str(item.get("task", "")).strip() + if not title: + continue + parsed.append( + { + "title": title, + "completed": bool(item.get("done", False)), + "current": bool(item.get("current", False)), + } + ) + + if not append: + # Reorder: current first, then remaining, then done + remaining = [t for t in parsed if not t["completed"]] + done = [t for t in parsed if t["completed"]] + current = [t for t in remaining if t["current"]] + not_current = [t for t in remaining if not t["current"]] + ordered = current + not_current + done + else: + ordered = parsed + + # Build new subtask list + new_subtasks = list(existing) if append else [] + max_idx = 0 + for s in existing: + parts = s.id.split("-") + if parts: + try: + max_idx = max(max_idx, int(parts[-1])) + except ValueError: + pass + + for sub in ordered: + max_idx += 1 + new_subtasks.append( + Subtask( + id=f"{task.id}-{max_idx}", + title=sub["title"], + completed=sub["completed"], + ) + ) + + new_column = "in-progress" + + from datetime import datetime + + updated_task = task.model_copy( + update={ + "subtasks": new_subtasks, + "column": new_column, + "updated_at": datetime.now().isoformat(), + } + ) + + path = found["path"] + writeTaskFile(str(path), updated_task, found["body"]) + return path + + # -- context loading (cecli-specific) ----------------------------------- + + def open_task_in_context( + self, coder, task_id: str, mode: str = "auto", explicit: bool = True + ) -> Dict[str, Any]: + found = self.find_task(task_id) + if not found: + raise FileNotFoundError(f"Task not found: {task_id}") + + selected_mode = (mode or "auto").strip().lower() + if selected_mode not in {"auto", "editable", "view"}: + raise ValueError("mode must be one of: auto, editable, view") + + relpath = found["relpath"] + abs_path = coder.abs_root_path(relpath) + + if selected_mode == "auto": + selected_mode = "editable" if found["location"] == "board" else "view" + if found["location"] == "logs": + selected_mode = "view" + + if selected_mode == "editable": + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + if abs_path in coder.abs_read_only_stubs_fnames: + coder.abs_read_only_stubs_fnames.remove(abs_path) + if abs_path not in coder.abs_fnames: + content = coder.io.read_text(abs_path) + if content is None: + raise ValueError(f"Unable to read task file: {relpath}") + coder.abs_fnames.add(abs_path) + coder.check_added_files() + if explicit: + coder.io.tool_output(f"Opened task '{found['id']}' as editable: {relpath}") + else: + if abs_path in coder.abs_fnames: + coder.abs_fnames.remove(abs_path) + if abs_path not in coder.abs_read_only_fnames: + content = coder.io.read_text(abs_path) + if content is None: + raise ValueError(f"Unable to read task file: {relpath}") + coder.abs_read_only_fnames.add(abs_path) + if explicit: + coder.io.tool_output(f"Opened task '{found['id']}' as read-only: {relpath}") + + if hasattr(coder, "use_enhanced_context") and coder.use_enhanced_context: + if hasattr(coder, "_calculate_context_block_tokens"): + coder._calculate_context_block_tokens() + + task = found["task"] + subtasks = task.subtasks or [] + done = sum(1 for s in subtasks if s.completed) + total = len(subtasks) + + opened = { + "id": found["id"], + "title": task.title, + "column": task.column or "in-progress", + "location": found["location"], + "mode": selected_mode, + "relpath": relpath, + "subtasks_done": done, + "subtasks_total": total, + } + + setattr(coder, "active_task_id", opened["id"]) + + io = getattr(coder, "io", None) + set_active_task = getattr(io, "set_active_task", None) + if callable(set_active_task): + set_active_task( + task_id=opened["id"], + title=opened["title"], + column=opened["column"], + subtasks_done=opened["subtasks_done"], + subtasks_total=opened["subtasks_total"], + mode=opened["mode"], + location=opened["location"], + ) + + return opened + + def clear_active_task(self, coder) -> None: + setattr(coder, "active_task_id", None) + io = getattr(coder, "io", None) + set_active_task = getattr(io, "set_active_task", None) + if callable(set_active_task): + set_active_task( + task_id="", + title="", + column="", + subtasks_done=0, + subtasks_total=0, + mode="", + location="", + ) + + def drop_task_from_context( + self, + coder, + task_id: Optional[str] = None, + clear_active: bool = True, + ) -> Dict[str, Any]: + target = _normalize_task_id(task_id or getattr(coder, "active_task_id", "") or "") + if not target: + raise ValueError("No active task to drop. Use /task drop .") + + dirs = self.dirs + rel_candidates = [ + self.relpath(os.path.join(dirs.boardDir, f"{target}.md")), + self.relpath(os.path.join(dirs.logsDir, f"{target}.md")), + ] + abs_candidates = [coder.abs_root_path(rel) for rel in rel_candidates] + + removed_editable = 0 + removed_read_only = 0 + for abs_path in abs_candidates: + if abs_path in coder.abs_fnames: + coder.abs_fnames.remove(abs_path) + removed_editable += 1 + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + removed_read_only += 1 + if hasattr(coder, "abs_read_only_stubs_fnames"): + if abs_path in coder.abs_read_only_stubs_fnames: + coder.abs_read_only_stubs_fnames.remove(abs_path) + + active_id = _normalize_task_id(getattr(coder, "active_task_id", "") or "") + cleared_active = False + if clear_active and active_id == target: + self.clear_active_task(coder) + cleared_active = True + + if hasattr(coder, "use_enhanced_context") and coder.use_enhanced_context: + if hasattr(coder, "_calculate_context_block_tokens"): + coder._calculate_context_block_tokens() + + return { + "id": target, + "removed_editable": removed_editable, + "removed_read_only": removed_read_only, + "cleared_active": cleared_active, + } + + # -- LLM context blocks ------------------------------------------------- + + def render_task_todo_block(self, task_id: str) -> Optional[str]: + found = self.find_task(task_id) + if not found or found["location"] != "board": + return None + + task = found["task"] + subtasks = task.subtasks or [] + if not subtasks: + return None + + done_tasks = [] + remaining_tasks = [] + for s in subtasks: + if s.completed: + done_tasks.append(s.title) + else: + remaining_tasks.append(s.title) + + if not done_tasks and not remaining_tasks: + return None + + result = '\n' + result += "## Active Task Checklist\n\n" + result += f"Checklist for `{task.id}`.\n\n" + + if done_tasks: + result += "Done:\n" + for item in done_tasks: + result += f"[x] {item}\n" + result += "\n" + + if remaining_tasks: + result += "Remaining:\n" + for i, item in enumerate(remaining_tasks): + marker = "->" if i == 0 else "-" + result += f"{marker} {item}\n" + + total = len(done_tasks) + len(remaining_tasks) + done = len(done_tasks) + result += f"\nActive: {task.title} | {task.column or 'in-progress'} | {done}/{total}\n" + result += "" + return result diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 04144f0ca78..aeee00a9d62 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -88,8 +88,8 @@ def __init__(self, *args, **kwargs): self.skip_cli_confirmations = False self.agent_finished = False self.agent_config = self._get_agent_config() - ToolRegistry.build_registry(agent_config=self.agent_config) super().__init__(*args, **kwargs) + ToolRegistry.build_registry(agent_config=self.agent_config) def _get_agent_config(self): """ @@ -1186,26 +1186,36 @@ def print_tree(node, prefix="- ", indent=" ", current_path=""): def get_todo_list(self): """ - Generate a todo list context block from the todo.txt file. - Returns formatted string with the current todo list or None if empty/not present. + Generate a todo list context block from the active board task's subtasks. + + If no active task is set, attempts to resume an incomplete task from + ``board/`` via ``get_or_create_session_task()`` — but only if a board + already exists (to avoid creating one before the agent has done anything). """ try: - todo_file_path = self.local_agent_folder("todo.txt") - abs_path = self.abs_root_path(todo_file_path) - import os + from cecli.brainfile import CecliTaskStore - if not os.path.isfile(abs_path): - return """ -Todo list does not exist. Please update it with the `UpdateTodoList` tool.""" - content = self.io.read_text(abs_path) - if content is None or not content.strip(): - return None - result = '\n' - result += "## Current Todo List\n\n" - result += "Below is the current todo list managed via the `UpdateTodoList` tool:\n\n" - result += f"```\n{content}\n```\n" - result += "" - return result + store = CecliTaskStore(self.root) + active_task_id = getattr(self, "active_task_id", None) + + # Auto-resume: pick up an incomplete board task if one exists + if not active_task_id and store.board_exists(): + try: + opened = store.get_or_create_session_task(self) + active_task_id = opened["id"] + except Exception: + pass + + if active_task_id: + block = store.render_task_todo_block(active_task_id) + if block: + return block + + return ( + '\n' + "Todo list does not exist. Please update it with the" + " `UpdateTodoList` tool." + ) except Exception as e: self.io.tool_error(f"Error generating todo list context: {str(e)}") return None diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ba849667a2a..ce2cb4397f9 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -580,16 +580,7 @@ def __init__( self.auto_test = auto_test self.test_cmd = test_cmd - # Clean up todo list file on startup; sessions will restore it when needed - todo_file_path = self.local_agent_folder("todo.txt") - abs_path = self.abs_root_path(todo_file_path) - if os.path.isfile(abs_path): - try: - os.remove(abs_path) - if self.verbose: - self.io.tool_output(f"Removed existing todo list file: {todo_file_path}") - except Exception as e: - self.io.tool_warning(f"Could not remove todo list file {todo_file_path}: {e}") + # Task board is persisted in `.cecli/tasks`; no startup cleanup. customizations = dict() try: diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 3f438898fde..94a42d901ae 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -55,6 +55,7 @@ from .save import SaveCommand from .save_session import SaveSessionCommand from .settings import SettingsCommand +from .task import TaskCommand from .terminal_setup import TerminalSetupCommand from .test import TestCommand from .think_tokens import ThinkTokensCommand @@ -128,6 +129,7 @@ CommandRegistry.register(SaveSessionCommand) CommandRegistry.register(SettingsCommand) CommandRegistry.register(TerminalSetupCommand) +CommandRegistry.register(TaskCommand) CommandRegistry.register(TestCommand) CommandRegistry.register(ThinkTokensCommand) CommandRegistry.register(TokensCommand) @@ -200,6 +202,7 @@ "SettingsCommand", "SwitchCoderSignal", "TerminalSetupCommand", + "TaskCommand", "TestCommand", "ThinkTokensCommand", "TokensCommand", diff --git a/cecli/commands/task.py b/cecli/commands/task.py new file mode 100644 index 00000000000..d760a2d2aea --- /dev/null +++ b/cecli/commands/task.py @@ -0,0 +1,340 @@ +import shlex +from pathlib import Path +from typing import List + +from cecli.brainfile import CecliTaskStore +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import format_command_result + + +class TaskCommand(BaseCommand): + NORM_NAME = "task" + DESCRIPTION = ( + "Manage repository tasks in .cecli/tasks " + "(list/add/new/show/open/update/delete/complete/drop)" + ) + + _cached_task_ids: List[str] = [] + _cached_mtime: float = 0.0 + + @classmethod + def _get_cached_task_ids(cls, coder) -> List[str]: + """Return task IDs, using a cache invalidated by directory mtime.""" + tasks_dir = Path(coder.root) / ".cecli" / "tasks" + board_dir = tasks_dir / "board" + logs_dir = tasks_dir / "logs" + + # Sum mtimes of both directories for a cheap staleness check + current_mtime = 0.0 + for d in (board_dir, logs_dir): + try: + current_mtime += d.stat().st_mtime + except OSError: + pass + + if current_mtime != cls._cached_mtime or not cls._cached_task_ids: + try: + store = CecliTaskStore(Path(coder.root)) + cls._cached_task_ids = [t["id"] for t in store.list_tasks("all")] + except Exception: + cls._cached_task_ids = [] + cls._cached_mtime = current_mtime + + return cls._cached_task_ids + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + if coder is None: + return format_command_result(io, "task", "No coder instance", error="No coder instance") + + try: + tokens = shlex.split(args) if args.strip() else [] + except ValueError as e: + return format_command_result(io, "task", "Invalid arguments", error=str(e)) + + store = CecliTaskStore(Path(coder.root)) + command = tokens[0].lower() if tokens else "list" + + try: + if command == "list": + scope = tokens[1] if len(tokens) > 1 else "board" + tasks = store.list_tasks(scope) + if not tasks: + io.tool_output(f"No tasks found in scope '{scope}'.") + return format_command_result(io, "task", "No tasks found") + + io.tool_output(f"Tasks ({scope}):") + for task in tasks: + metadata = [task["column"]] + if task["subtasks_total"] > 0: + metadata.append(f"{task['subtasks_done']}/{task['subtasks_total']}") + meta = " | ".join(metadata) + io.tool_output(f"- {task['id']}: {task['title']} [{meta}]") + return format_command_result(io, "task", f"Listed {len(tasks)} task(s)") + + if command == "add": + title = " ".join(tokens[1:]).strip() + if not title: + return format_command_result( + io, "task", "Missing title", error="Usage: /task add " + ) + created = store.create_task(title=title, column="in-progress") + tid, col, ttl = created["id"], created["column"], created["title"] + io.tool_output(f"Created task {tid} in column '{col}': {ttl}") + return format_command_result(io, "task", f"Created {created['id']}") + + if command == "show": + if len(tokens) < 2: + return format_command_result( + io, + "task", + "Missing task id", + error="Usage: /task show <task-id>", + ) + task_ref = " ".join(tokens[1:]).strip() + task = store.find_task(task_ref) + if not task: + return format_command_result( + io, + "task", + "Task not found", + error=f"Task not found: {task_ref}", + ) + t = task["task"] # brainfile Task model + subtasks = t.subtasks or [] + done = sum(1 for s in subtasks if s.completed) + total = len(subtasks) + io.tool_output(f"Task: {task['id']}") + io.tool_output(f"Title: {t.title}") + io.tool_output(f"Location: {task['location']}") + io.tool_output(f"Column: {t.column or 'logs'}") + io.tool_output(f"Progress: {done}/{total}") + io.tool_output(f"Path: {task['relpath']}") + return format_command_result(io, "task", f"Displayed {task['id']}") + + if command == "open": + if len(tokens) < 2: + return format_command_result( + io, + "task", + "Missing task id", + error="Usage: /task open <task-id> [auto|editable|view]", + ) + modes = {"auto", "editable", "view"} + mode = "auto" + task_parts = tokens[1:] + if len(task_parts) > 1 and task_parts[-1].lower() in modes: + mode = task_parts[-1].lower() + task_parts = task_parts[:-1] + task_ref = " ".join(task_parts).strip() + if not task_ref: + return format_command_result( + io, + "task", + "Missing task id", + error="Usage: /task open <task-id> [auto|editable|view]", + ) + opened = store.open_task_in_context(coder, task_ref, mode=mode, explicit=False) + io.tool_output(f"Opened {opened['id']} ({opened['mode']}, {opened['location']}).") + io.tool_output( + f"Active task: {opened['title']} | {opened['column']} |" + f" {opened['subtasks_done']}/{opened['subtasks_total']}" + ) + return format_command_result(io, "task", f"Opened {opened['id']}") + + if command == "update": + if len(tokens) < 3: + return format_command_result( + io, + "task", + "Missing fields", + error=( + "Usage: /task update <task-id> <new title> OR " + "/task update <task-id> --title <title> --column <column>" + ), + ) + task_id = tokens[1] + title = None + column = None + + remainder = tokens[2:] + index = 0 + free_text_title = [] + while index < len(remainder): + token = remainder[index] + if token == "--column": + if index + 1 >= len(remainder): + return format_command_result( + io, + "task", + "Invalid args", + error="--column requires a value", + ) + column = remainder[index + 1] + index += 2 + continue + if token == "--title": + if index + 1 >= len(remainder): + return format_command_result( + io, + "task", + "Invalid args", + error="--title requires a value", + ) + title = remainder[index + 1] + index += 2 + continue + free_text_title.append(token) + index += 1 + + if title is None and free_text_title: + title = " ".join(free_text_title).strip() + + updated = store.update_task(task_id=task_id, title=title, column=column) + if not updated["changed"]: + io.tool_output("No changes applied.") + return format_command_result(io, "task", "No changes applied") + tid, ttl, col = updated["id"], updated["title"], updated["column"] + io.tool_output(f"Updated {tid}: title='{ttl}', column='{col}'") + return format_command_result(io, "task", f"Updated {updated['id']}") + + if command == "delete": + if len(tokens) < 2: + return format_command_result( + io, + "task", + "Missing task id", + error="Usage: /task delete <task-id>", + ) + task_ref = " ".join(tokens[1:]).strip() + deleted = store.delete_task(task_ref) + store.drop_task_from_context(coder, deleted["id"], clear_active=True) + io.tool_output(f"Deleted task {deleted['id']} from {deleted['location']}.") + return format_command_result(io, "task", f"Deleted {deleted['id']}") + + if command == "complete": + if len(tokens) < 2: + return format_command_result( + io, + "task", + "Missing task id", + error="Usage: /task complete <task-id>", + ) + task_ref = " ".join(tokens[1:]).strip() + completed = store.complete_task(task_ref) + store.drop_task_from_context(coder, completed["id"], clear_active=True) + io.tool_output(f"Completed task {completed['id']} (moved to logs).") + return format_command_result(io, "task", f"Completed {completed['id']}") + + if command == "drop": + task_ref = " ".join(tokens[1:]).strip() if len(tokens) > 1 else None + dropped = store.drop_task_from_context( + coder, task_id=(task_ref or None), clear_active=True + ) + if dropped["cleared_active"]: + io.tool_output(f"Closed task {dropped['id']} and cleared active task.") + else: + io.tool_output(f"Closed task {dropped['id']} from context.") + return format_command_result(io, "task", f"Dropped {dropped['id']}") + + if command == "new": + title = " ".join(tokens[1:]).strip() if len(tokens) > 1 else None + if not title: + title = store.auto_title(None) + created = store.create_task(title=title, column="in-progress") + opened = store.open_task_in_context( + coder, created["id"], mode="auto", explicit=False + ) + io.tool_output(f"Created {created['id']}: {created['title']}") + io.tool_output( + f"Active task: {opened['title']} | {opened['column']} | " + f"{opened['subtasks_done']}/{opened['subtasks_total']}" + ) + return format_command_result(io, "task", f"Created {created['id']}") + + return format_command_result( + io, + "task", + "Unknown subcommand", + error=( + f"Unknown subcommand '{command}'. " + "Try: list/add/new/show/open/update/delete/complete/drop" + ), + ) + + except (FileNotFoundError, ValueError) as e: + return format_command_result(io, "task", "Task command failed", error=str(e)) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + if coder is None: + return [] + + try: + tokens = shlex.split(args) if args.strip() else [] + except ValueError: + return [] + + subcommands = [ + "list", + "add", + "new", + "show", + "open", + "update", + "delete", + "complete", + "drop", + ] + scopes = ["board", "logs", "all"] + modes = ["auto", "editable", "view"] + + if not tokens: + return subcommands + + if len(tokens) == 1 and not args.endswith(" "): + prefix = tokens[0].lower() + return [sc for sc in subcommands if sc.startswith(prefix)] + + action = tokens[0].lower() + if action == "list": + if len(tokens) <= 1: + return scopes + if len(tokens) == 2 and not args.endswith(" "): + prefix = tokens[1].lower() + return [s for s in scopes if s.startswith(prefix)] + return [] + + if action in {"show", "open", "update", "delete", "complete", "drop"}: + task_ids = cls._get_cached_task_ids(coder) + if len(tokens) == 1: + return task_ids + if len(tokens) == 2 and not args.endswith(" "): + prefix = tokens[1].lower() + return [task_id for task_id in task_ids if task_id.startswith(prefix)] + if action == "open" and len(tokens) >= 2: + if len(tokens) == 2: + return modes + if len(tokens) == 3 and not args.endswith(" "): + prefix = tokens[2].lower() + return [m for m in modes if m.startswith(prefix)] + if action == "update": + return ["--title", "--column"] + + return [] + + @classmethod + def get_help(cls) -> str: + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /task list [board|logs|all]\n" + help_text += " /task add <title>\n" + help_text += " /task new [title] -- create a new task and set as active\n" + help_text += " /task show <task-id>\n" + help_text += " /task open <task-id> [auto|editable|view]\n" + help_text += " /task update <task-id> <new title>\n" + help_text += " /task update <task-id> --title <title> --column <column>\n" + help_text += " /task delete <task-id>\n" + help_text += " /task complete <task-id>\n" + help_text += " /task drop [task-id]\n" + return help_text diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index 32a8f9477a1..2faf1508824 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -1,63 +1,73 @@ # Agent prompts - inherits from base.yaml +# Overrides specific prompts _inherits: [base] files_content_assistant_reply: | - I have received the file contents. I will use them to provide a precise solution. + I understand. I'll use these files to help with your request. files_no_full_files: | - <context name="file_status"> - I currently lack full file contents. I will use discovery tools to pull necessary context as I progress. - </context> + <context name="file_status">I don't have full contents of any files yet. I'll add them as needed using the tool commands.</context> files_no_full_files_with_repo_map: | <context name="repo_map_status"> - I have a repository map. I will use it to target my navigation and add relevant files to the context. + I have a repository map but no full file contents yet. I will use my navigation tools to add relevant files to the context. </context> +files_no_full_files_with_repo_map_reply: | + I understand. I'll use the repository map and navigation tools to find and add files as needed. + main_system: | <context name="role_and_directives"> ## Core Directives - **Role**: Act as an expert software engineer. - - **Act Proactively**: Autonomously use discovery and management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to fulfill the request. Chain tool calls across multiple turns for continuous exploration. - - **Be Decisive**: Trust your findings. Do not repeat identical searches or ask redundant questions once a path is established. - - **Be Efficient**: Batch tool calls where supported. Respect usage limits while maximizing the utility of each turn. + - **Act Proactively**: Autonomously use file discovery and context management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. + - **Be Decisive**: Trust that your initial findings are valid. Refrain from asking the same question or searching for the same term in multiple similar ways. + - **Be Efficient**: Some tools allow you to perform multiple actions at a time, use them to work quickly and effectively. Respect their usage limits </context> - <context name="workflow_and_tool_usage"> ## Core Workflow - 1. **Plan**: Start by using `UpdateTodoList` to outline the task. Always begin a complex interaction by setting or updating the roadmap. - 2. **Explore**: Use `Grep` for broad searches, but if results exceed 50 matches, refine your pattern immediately. Use discovery tools to add files as read-only context. - 3. **Think**: Use the `Thinking` tool to reason through edits. Avoid "thinking loops" (multiple consecutive `Thinking` calls), but ensure a clear logical path is established before editing. - 4. **Execute**: Use the appropriate editing tool. Mark files as editable with `ContextManager` when needed. Proactively use skills if they are available. - 5. **Verify & Recover**: Review every diff. If an edit fails or introduces errors, prioritize `UndoChange` to restore a known good state before attempting a fix. - 6. **Finished**: Use the `Finished` tool only after verifying the solution. Briefly summarize the changes for the user. - + 1. **Plan**: Determine the necessary changes. Use the `UpdateTodoList` tool to manage your plan. Always begin by updating the todo list. + 2. **Explore**: Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `Grep`) to find relevant files. These tools add files to context as read-only. Use `Grep` first for broad searches to avoid context clutter. Concisely describe your search strategy with the `Thinking` tool. + 3. **Think**: Given the contents of your exploration, concisely reason through the edits with the `Thinking` tool that need to be made to accomplish the goal. For complex edits, briefly outline your plan for the user. Do not chain multiple `Thinking` calls in a row + 4. **Execute**: Use the appropriate editing tool. Remember to mark a file as editable with `ContextManager` before modifying it. Do not attempt large contiguous edits (those greater than 100 lines). Break them into multiple smaller steps. Proactively use skills if they are available + 5. **Verify & Recover**: After every edit, check the resulting diff snippet. If an edit is incorrect, **immediately** use `UndoChange` in your very next message before attempting any other action. + 6. **Finished**: Use the `Finished` tool when all tasks and changes needed to accomplish the goal are finished ## Todo List Management - - Use `UpdateTodoList` every 3-10 tool calls to keep the state synchronized. - - Break complex tasks into granular steps to maintain context across long interactions. - - ### Editing Tools (Precision Protocol) - Files use hashline prefixes: `{{line_num}}|{{hash_fragment}}`. - - **MANDATORY Two-Turn Safety Protocol**: - 1. **Turn 1**: Use `ShowNumberedContext` to verify exact, current line numbers. - 2. **Turn 2**: Execute the edit (Replace, Insert, Delete, Indent) using those verified numbers. - - **Indentation**: Preserve all spaces and tabs. In Python, a single-space error is a syntax error. Use `IndentText` to fix structural alignment. + - **Track Progress**: Use the `UpdateTodoList` tool to add or modify items. + - **Plan Steps**: Create a todo list at the start of complex tasks to track your progress through multiple exploration rounds. + - **Stay Organized**: Update the todo list as you complete steps every 3-10 tool calls to maintain context across multiple tool calls. + ### Editing Tools + Use these for precision and safety. Files are provided with hashline prefixes in the format `{{line_num}}|{{hash_fragment}}` (e.g., `20|Bv`) and separated from the content by a pipe (|). + - **Line-Based Edits**: `ReplaceText`, `InsertText`, `DeleteText`, `IndentText` + - **Refactoring & History**: `ListChanges`, `UndoChange` + - **Skill Management**: `LoadSkill`, `RemoveSkill` + **MANDATORY Safety Protocol for Line-Based Tools:** Line numbers are fragile. You **MUST** use a two-turn process: + 1. **Turn 1**: Use `ShowNumberedContext` to get the exact, current line numbers. + 2. **Turn 2**: In your *next* message, use a line-based editing tool with the verified numbers. + + Do not neglect spaces and indentation, they are EXTREMELY important to preserve. </context> - - Use the `.cecli/workspace` directory for all temporary, test, or scratch files. + Use the .cecli/workspace directory for temporary and test files you make to verify functionality Always reply to the user in {language}. +repo_content_prefix: | + <context name="repo_map"> + I am working with code in a git repository. Here are summaries of some files: + </context> + system_reminder: | <context name="critical_reminders"> ## Reminders - - **Strict Scope**: Stay on task. Do not pursue unrequested refactors. - - **Context Hygiene**: Remove files or skills from context using `ContextManager` or `RemoveSkill` once they are no longer needed to save tokens and prevent confusion. - - **Turn Management**: Tool calls trigger the next turn. Do not include tool calls in your final summary to the user. - - **Sandbox**: Use `.cecli/workspace` for all verification and temporary logic. - - **Precision**: Never guess line numbers. Always use `ShowNumberedContext` first. + - Stay on task. Do not pursue goals the user did not ask for. + - Any tool call automatically continues to the next turn. Provide no tool calls in your final answer. + - Use the .cecli/workspace directory for temporary and test files you make to verify functionality + - Do not neglect spaces and indentation, they are EXTREMELY important to preserve. Fix indentation errors with the `IndentText` tool. + - Remove files from the context when you no longer need them with the `ContextManager` tool. It is fine to re-add them later, if they are needed again + - Remove skills if they are not helpful for your current task with `RemoveSkill` {lazy_prompt} {shell_cmd_reminder} </context> try_again: | - My previous exploration was insufficient. I will now adjust my strategy, use more specific search patterns, and manage my context more aggressively to find the correct solution. \ No newline at end of file + I need to retry my exploration. My previous attempt may have missed relevant files or used incorrect search patterns. + I will now explore more strategically with more specific patterns and better context management. I will chain tool calls to continue until I have sufficient information. diff --git a/cecli/sessions.py b/cecli/sessions.py index a5ed582c146..8982fd4ccc6 100644 --- a/cecli/sessions.py +++ b/cecli/sessions.py @@ -131,17 +131,6 @@ def _build_session_data(self, session_name) -> Dict: for abs_fname in self.coder.abs_read_only_stubs_fnames ] - # Capture todo list content so it can be restored with the session - todo_content = None - try: - todo_path = self.coder.abs_root_path(self.coder.local_agent_folder("todo.txt")) - if os.path.isfile(todo_path): - todo_content = self.io.read_text(todo_path) - if todo_content is None: - todo_content = "" - except Exception as e: - self.io.tool_warning(f"Could not read todo list file: {e}") - # Get CUR and DONE messages from ConversationManager cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) done_messages = ConversationManager.get_messages_dict(MessageTag.DONE) @@ -169,7 +158,7 @@ def _build_session_data(self, session_name) -> Dict: "auto_lint": self.coder.auto_lint, "auto_test": self.coder.auto_test, }, - "todo_list": todo_content, + "active_task_id": getattr(self.coder, "active_task_id", None), } def _find_session_file(self, session_identifier: str) -> Optional[Path]: @@ -247,18 +236,29 @@ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool: if "auto_test" in settings: self.coder.auto_test = settings["auto_test"] - # Restore todo list content if present in the session + # Restore active_task_id if the task file still exists + saved_task_id = session_data.get("active_task_id") + if saved_task_id: + from cecli.brainfile import CecliTaskStore + + store = CecliTaskStore(self.coder.root) + found = store.find_task(saved_task_id) + if found and found["location"] == "board": + store.open_task_in_context( + self.coder, saved_task_id, mode="auto", explicit=False + ) + else: + # Task file no longer exists or was completed — clear it + store.clear_active_task(self.coder) + + # Legacy field handling: + # session_data["todo_list"] was used for deprecated todo.txt snapshots. + # Task state now lives persistently under `.cecli/tasks` and is not restored here. if "todo_list" in session_data: - todo_path = self.coder.abs_root_path(self.coder.local_agent_folder("todo.txt")) - todo_content = session_data.get("todo_list") - try: - if todo_content is None: - if os.path.exists(todo_path): - os.remove(todo_path) - else: - self.io.write_text(todo_path, todo_content) - except Exception as e: - self.io.tool_warning(f"Could not restore todo list: {e}") + self.io.tool_warning( + "Session contains legacy 'todo_list' data; ignored. " + "Tasks are persisted in .cecli/tasks." + ) # Clear CUR and DONE messages from ConversationManager ConversationManager.reset() diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 7777569b6ed..fd73c3fb1c9 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -1,7 +1,6 @@ # flake8: noqa: F401 # Import tool modules into the cecli.tools namespace -# Import all tool modules from . import ( command, command_interactive, @@ -30,7 +29,6 @@ view_files_with_symbol, ) -# List of all available tool modules for dynamic discovery TOOL_MODULES = [ command, command_interactive, diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index efd3ffa2741..438d8998086 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -18,7 +18,10 @@ class Tool(BaseTool): "items": { "type": "object", "properties": { - "task": {"type": "string", "description": "The task description."}, + "task": { + "type": "string", + "description": "The task description.", + }, "done": { "type": "boolean", "description": "Whether the task is completed.", @@ -62,94 +65,60 @@ class Tool(BaseTool): @classmethod def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kwargs): """ - Update the todo list file (todo.txt) with formatted task items. - Can either replace the entire content or append to it. + Update the todo list. + + Always writes to a board task's subtasks. If no active task exists, + one is transparently created via ``get_or_create_session_task()``. """ tool_name = "UpdateTodoList" try: - # Define the todo file path - todo_file_path = coder.local_agent_folder("todo.txt") - abs_path = coder.abs_root_path(todo_file_path) + from cecli.brainfile import CecliTaskStore - # Format tasks into string - done_tasks = [] - remaining_tasks = [] + store = CecliTaskStore(coder.root) - for task_item in tasks: - if task_item.get("done", False): - done_tasks.append(f"✓ {task_item['task']}") - else: - # Check if this is the current task - if task_item.get("current", False): - remaining_tasks.append(f"→ {task_item['task']}") - else: - remaining_tasks.append(f"○ {task_item['task']}") + # Ensure an active board task exists + active_task_id = getattr(coder, "active_task_id", None) + just_created = False + if not active_task_id: + opened = store.get_or_create_session_task(coder) + active_task_id = opened["id"] + just_created = opened.get("subtasks_total", 0) == 0 - # Build formatted content - content_lines = [] - if done_tasks: - content_lines.append("Done:") - content_lines.extend(done_tasks) - content_lines.append("") + task_path = store.get_task_file_path(active_task_id) + if not task_path or not task_path.is_file(): + raise ToolError(f"Active task file not found: {active_task_id}") - if remaining_tasks: - content_lines.append("Remaining:") - content_lines.extend(remaining_tasks) - - # Remove trailing empty line if present - if content_lines and content_lines[-1] == "": - content_lines.pop() - - content = "\n".join(content_lines) - - # Get existing content if appending - existing_content = "" - import os - - if os.path.isfile(abs_path): - existing_content = coder.io.read_text(abs_path) or "" - - # Prepare new content - if append: - if existing_content and not existing_content.endswith("\n"): - existing_content += "\n" - new_content = existing_content + content - else: - new_content = content - - # Check if content exceeds 4096 characters and warn - if len(new_content) > 4096: - coder.io.tool_warning( - "⚠️ Todo list content exceeds 4096 characters. Consider summarizing the plan" - " before proceeding." - ) + existing_content = coder.io.read_text(str(task_path)) or "" - # Check if content actually changed - if existing_content == new_content: - coder.io.tool_warning("No changes made: new content is identical to existing") - return "Warning: No changes made (content identical to existing)" - - # Handle dry run if dry_run: action = "append to" if append else "replace" - dry_run_message = f"Dry run: Would {action} todo list in {todo_file_path}." + dry_run_message = f"Dry run: Would {action} subtasks for task '{active_task_id}'." return format_tool_result( coder, tool_name, "", dry_run=True, dry_run_message=dry_run_message ) - # Apply change + store.update_task_subtasks(active_task_id, tasks, append=append) + + # Auto-title: if the task was just created, derive title from first item + if just_created: + title = store.auto_title(tasks) + store.update_task(active_task_id, title=title) + + new_content = coder.io.read_text(str(task_path)) or "" + + if existing_content == new_content: + coder.io.tool_warning("No changes made: new content is identical to existing") + return "Warning: No changes made (content identical to existing)" + metadata = { "append": append, "existing_length": len(existing_content), "new_length": len(new_content), } + task_rel_path = store.relpath(task_path) - # Write the file directly since it's a special file - coder.io.write_text(abs_path, new_content) - - # Track the change final_change_id = coder.change_tracker.track_change( - file_path=todo_file_path, + file_path=task_rel_path, change_type="updatetodolist", original_content=existing_content, new_content=new_content, @@ -157,11 +126,10 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw change_id=change_id, ) - coder.coder_edited_files.add(todo_file_path) + coder.coder_edited_files.add(task_rel_path) - # Format and return result action = "appended to" if append else "updated" - success_message = f"Successfully {action} todo list" + success_message = f"Successfully {action} subtasks for {active_task_id}" return format_tool_result( coder, tool_name, @@ -184,12 +152,10 @@ def format_output(cls, coder, mcp_server, tool_response): tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - # Parse the parameters to display formatted todo list params = json.loads(tool_response.function.arguments) tasks = params.get("tasks", []) if tasks: - # Format tasks for display done_tasks = [] remaining_tasks = [] @@ -197,13 +163,11 @@ def format_output(cls, coder, mcp_server, tool_response): if task_item.get("done", False): done_tasks.append(f"✓ {task_item['task']}") else: - # Check if this is the current task if task_item.get("current", False): remaining_tasks.append(f"→ {task_item['task']}") else: remaining_tasks.append(f"○ {task_item['task']}") - # Display formatted todo list coder.io.tool_output("") coder.io.tool_output(f"{color_start}Todo List:{color_end}") diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 24f852ddaa4..d66328265e8 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -37,7 +37,7 @@ def list_tools(cls) -> List[str]: return list(cls._tools.keys()) @classmethod - def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: + def build_registry(cls, agent_config: Optional[Dict] = None, **kwargs) -> Dict[str, Type]: """ Build a filtered registry of tools based on agent configuration. @@ -51,25 +51,22 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: if agent_config is None: agent_config = {} + cls.initialize_registry() + # Load tools from tool_paths if specified tool_paths = agent_config.get("tool_paths", []) for tool_path in tool_paths: path = Path(tool_path) if path.is_dir(): - # Find all Python files in the directory for py_file in path.glob("*.py"): try: - # Load the module using plugin_manager module = plugin_manager.load_module(str(py_file)) - # Check if module has a Tool class if hasattr(module, "Tool"): cls.register(module.Tool) except Exception as e: - # Log error but continue with other files print(f"Error loading tool from {py_file}: {e}") else: - # If it's a file, try to load it directly if path.exists() and path.suffix == ".py": try: module = plugin_manager.load_module(str(path)) @@ -91,22 +88,18 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: for tool_name, tool_class in cls._tools.items(): should_include = True - # Apply include list if specified if tools_includelist: should_include = tool_name in tools_includelist - # Essential tools are always included if tool_name in cls._essential_tools: should_include = True - # Apply exclude list (unless essential) if tool_name in tools_excludelist and tool_name not in cls._essential_tools: should_include = False if should_include: registry[tool_name] = tool_class - # Store the built registry in the class attribute cls._registry = registry return registry @@ -131,14 +124,11 @@ def get_registered_tools(cls) -> List[str]: @classmethod def initialize_registry(cls): """Initialize the registry by importing and registering all tools.""" - # Clear existing registry cls._tools.clear() - # Register all tools from TOOL_MODULES for module in TOOL_MODULES: if hasattr(module, "Tool"): - tool_class = module.Tool - cls.register(tool_class) + cls.register(module.Tool) # Initialize the registry when module is imported diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 7423cc41205..a4039f2f225 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -15,6 +15,7 @@ from cecli.io import CommandCompletionException from .widgets import ( + ActiveTaskBar, CompletionBar, FileList, InputArea, @@ -280,6 +281,7 @@ def compose(self) -> ComposeResult: coder_mode=coder_mode, ) yield KeyHints(id="key-hints") + yield ActiveTaskBar(id="active-task-bar") yield MainFooter( model_name=model_name, project_name=project_name, @@ -487,6 +489,17 @@ def handle_output_message(self, msg): footer = self.query_one(MainFooter) footer.update_mode(msg.get("mode", "code")) + elif msg_type == "active_task": + active_task_bar = self.query_one(ActiveTaskBar) + active_task_bar.update_task( + task_id=msg.get("task_id", ""), + title=msg.get("title", ""), + column=msg.get("column", ""), + subtasks_done=msg.get("subtasks_done", 0), + subtasks_total=msg.get("subtasks_total", 0), + mode=msg.get("mode", ""), + location=msg.get("location", ""), + ) def add_output(self, text, task_id=None): """Add output to the output container.""" @@ -980,8 +993,13 @@ def _get_suggestions(self, text: str) -> list[str]: pass else: # Use standard command completions (no file fallback) + # Pass the full args string so multi-token commands + # (like /task show <id>) can complete contextually. + cmd_args = parts[1] if len(parts) > 1 else "" + if text.endswith(" "): + cmd_args = cmd_args + " " if cmd_args else " " try: - cmd_completions = commands.get_completions(cmd_name) + cmd_completions = commands.get_completions(cmd_name, cmd_args) if cmd_completions: if arg_prefix: suggestions = [ @@ -1036,8 +1054,12 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: else: return completion else: - # Replace argument - return parts[0] + " " + completion + # Replace last argument word, preserving earlier tokens + if current_text.endswith(" "): + return current_text + completion + else: + prefix = current_text.rsplit(maxsplit=1)[0] + return prefix + " " + completion elif "@" in current_text: # Replace from @ onwards with the symbol at_index = current_text.rfind("@") diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 76f763123f5..906bcfab92c 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -250,6 +250,30 @@ def _reroute_output(self, text, msg_type, **kwargs): return False + def set_active_task( + self, + task_id: str, + title: str, + column: str = "", + subtasks_done: int = 0, + subtasks_total: int = 0, + mode: str = "", + location: str = "", + ): + """Publish active task metadata to the TUI.""" + self.output_queue.put( + { + "type": "active_task", + "task_id": task_id, + "title": title, + "column": column, + "subtasks_done": subtasks_done, + "subtasks_total": subtasks_total, + "mode": mode, + "location": location, + } + ) + def start_spinner(self, text, update_last_text=True): """Override start_spinner to send spinner state to TUI. diff --git a/cecli/tui/widgets/__init__.py b/cecli/tui/widgets/__init__.py index bc634ec6c82..16d2e2d6270 100644 --- a/cecli/tui/widgets/__init__.py +++ b/cecli/tui/widgets/__init__.py @@ -1,5 +1,6 @@ """Widgets for the cecli TUI.""" +from .active_task_bar import ActiveTaskBar from .completion_bar import CompletionBar from .file_list import FileList from .footer import MainFooter @@ -10,6 +11,7 @@ from .status_bar import StatusBar __all__ = [ + "ActiveTaskBar", "MainFooter", "CompletionBar", "InputArea", diff --git a/cecli/tui/widgets/active_task_bar.py b/cecli/tui/widgets/active_task_bar.py new file mode 100644 index 00000000000..65c1e596e06 --- /dev/null +++ b/cecli/tui/widgets/active_task_bar.py @@ -0,0 +1,50 @@ +"""Active task bar widget shown above the footer.""" + +from textual.widgets import Static + + +class ActiveTaskBar(Static): + """Single-line active task indicator.""" + + DEFAULT_CSS = """ + ActiveTaskBar { + height: 1; + width: 100%; + color: $secondary; + background: $surface; + padding: 0 1; + } + + ActiveTaskBar.hidden { + display: none; + } + """ + + def __init__(self, **kwargs): + super().__init__("", **kwargs) + self.add_class("hidden") + + def update_task( + self, + task_id: str = "", + title: str = "", + column: str = "", + subtasks_done: int = 0, + subtasks_total: int = 0, + mode: str = "", + location: str = "", + ) -> None: + title = (title or "").strip() + if not title: + self.update("") + self.add_class("hidden") + return + + progress = ( + f"{subtasks_done}/{subtasks_total}" if subtasks_total and subtasks_total > 0 else "0/0" + ) + access = "view" if mode == "view" else "edit" + where = "logs" if location == "logs" else "board" + text = f"Task: {title} | {column or '-'} | {progress} | {where}:{access}" + self.update(text) + self.remove_class("hidden") diff --git a/cecli/website/docs/sessions.md b/cecli/website/docs/sessions.md index 9173b4dd51a..3e1064f1bc7 100644 --- a/cecli/website/docs/sessions.md +++ b/cecli/website/docs/sessions.md @@ -42,7 +42,7 @@ When `--auto-save` is enabled, cecli will automatically save your session as 'au - All files in the chat (editable, read-only, and read-only stubs) - Current model and edit format settings - Auto-commit, auto-lint, and auto-test settings -- Todo list content from `.cecli/run/{date}/{agent id}/todo.txt` +- The persistent task board in `.cecli/tasks/` (Brainfile-style `board/` + `logs/`, managed separately from session files) - Session metadata (timestamp, version) ### `/load-session <name>` @@ -57,7 +57,7 @@ Load a previously saved session by name or file path. - Restores chat history and file configurations - Recreates the exact session state - Preserves all settings and model configurations -- Restores the todo list content saved in the session +- Uses the existing `.cecli/tasks/` board (session files do not overwrite task state) ### `/list-sessions` List all available saved sessions in `.cecli/sessions/`. @@ -73,6 +73,15 @@ List all available saved sessions in `.cecli/sessions/`. - Edit format - Creation timestamp +## Task Board Schema Defaults + +The persistent board in `.cecli/tasks/` is initialized with Brainfile v2 schema URLs: + +- Board: `https://brainfile.md/v2/board.json` +- Task: `https://brainfile.md/v2/task.json` + +Tasks are the only item type used in the initial version. + ## How Sessions Work ### Session Storage @@ -101,8 +110,7 @@ Sessions are stored as JSON files in the `.cecli/sessions/` directory within you "auto_commits": true, "auto_lint": false, "auto_test": false - }, - "todo_list": "- plan feature A\n- write tests\n" + } } ``` @@ -173,7 +181,7 @@ If a session fails to load: - Try creating a new session and compare file structures ### Deprecated Options -- `--preserve-todo-list` is deprecated. The todo list is cleared on startup and restored only when you load a session that contains it. +- `--preserve-todo-list` is deprecated. Tasks are now persisted in `.cecli/tasks/` and are not cleared on startup. ## Related Commands - `/reset` - Clear chat history and drop files (useful before loading a session) diff --git a/handoff.md b/handoff.md new file mode 100644 index 00000000000..79d9738d37f --- /dev/null +++ b/handoff.md @@ -0,0 +1,239 @@ +# Handoff: cecli Task Management — Invisible Persistence + +## Session Context +- Date: 2026-02-27 +- Branch: `feat/cecli-tasks-brainfile` +- Previous approach: Two-layer architecture with explicit opt-in board (see git history) + +## Design Philosophy + +**The agent and user should never know brainfile exists.** The existing `UpdateTodoList` tool and todo UX remain identical on the surface. The only difference: todos are now backed by structured task files instead of a flat text file, giving users persistence, history, and session memory for free. + +No new LLM tools. No board creation ceremony. No mode-switch. No "WTF is a kanban board" moment. + +## Architecture + +### What the agent sees (unchanged) +- `UpdateTodoList` — the only tool. Same schema, same behavior, same prompt. +- `get_todo_list()` — returns the same `<context name="todo_list">` block it always did. +- `agent.yml` — no mention of boards, tasks, brainfile, or structured data. + +### What happens underneath (new) +- `UpdateTodoList` writes to `.cecli/tasks/board/task-{N}.md` instead of `.cecli/todo.txt`. +- Each call creates or updates a **single session task** — the "current task" for this session. +- Todo items become subtasks in that task file's YAML frontmatter. +- `.cecli/tasks/brainfile.md` (board config) is auto-created on first write. No explicit init. +- `todo.txt` is no longer the primary storage. + +### What the user gets (for free, without doing anything) +- **Persistence across sessions**: Incomplete todos survive session restarts. The task file persists. +- **History**: Completed tasks move to `.cecli/tasks/logs/`. Git tracks everything. +- **Searchable past work**: `/task list logs` shows what was done across previous sessions. +- **Optional power-user access**: `/task list`, `/task show`, `/task open` for humans who want control. + +## The Session-Task Mapping + +``` +Session starts → + find or create a "current session task" in .cecli/tasks/board/ + (reuse the last incomplete task, or create a new one) + +UpdateTodoList called → + always write to the current session task's subtasks + +Session ends (or user starts new work) → + incomplete task persists in board/ + completed task can be moved to logs/ via /task complete +``` + +### How the "current task" is determined +1. On session start, check `.cecli/tasks/board/` for the most recently updated task. +2. If it has incomplete subtasks → resume it (set as `active_task_id`). +3. If all subtasks are complete or no tasks exist → create a new task on next `UpdateTodoList` call. +4. The user can override with `/task open <id>` to switch to a different task. +5. The user can force a fresh task with `/task new [title]`. + +### Key difference from previous approach +- **Before**: Two separate systems (todo.txt vs board) with a mode-switch and `/task promote` bridge. +- **Now**: One system. `UpdateTodoList` always writes structured files. The agent doesn't know. The user doesn't have to care. Power features are there when you want them. + +## Changes from Previous Implementation + +### Kill +- **Two-layer routing** in `UpdateTodoList` (the `active_task_id` branch to todo.txt vs board). There is now only one write path — always through the store. +- **`_execute_todo_txt`** method — the flat-file write path. All writes go through `CecliTaskStore`. +- **`/task promote`** — unnecessary. Todos are already structured from the first `UpdateTodoList` call. +- **Board tool gating** in `ToolRegistry` — no conditional registration of Task* LLM tools. +- **`BOARD_TOOL_MODULES` split** in `tools/__init__.py` — no separate module list needed. +- **`todo.txt` as primary storage** — replaced by task files. +- **Task* LLM tools** (`TaskCreate`, `TaskList`, `TaskShow`, `TaskUpdate`, `TaskComplete`, `TaskDrop`) — the agent should never call these. They were board-mode tools for a mode that no longer exists. All agent interaction goes through `UpdateTodoList`. Human interaction goes through `/task` slash commands. + +### Keep +- **`CecliTaskStore`** in `cecli/brainfile/store.py` — the adapter is solid. Add `get_or_create_session_task()` method. +- **`/task` command** — all subcommands remain for human power-users: `list`, `show`, `open`, `complete`, `drop`, `delete`, `update`. +- **`UpdateTodoList` tool schema** — identical. `tasks` array, `append`, `done`, `current` flags. +- **`get_todo_list()` context block** — same `<context name="todo_list">` format, now always reads from the current session task's subtasks. +- **`render_task_todo_block()`** — already does what we need. +- **`brainfile` Python library dependency** — unchanged. +- **Completion flow** — `completeTaskFile` moves to `logs/`. +- **`format_output()`** in UpdateTodoList — renders the same ✓/○/→ display. + +### Add +- **Auto-init**: `CecliTaskStore.ensure_initialized()` called lazily on first `UpdateTodoList` write, not on explicit `/task add`. +- **`get_or_create_session_task()`**: New method on `CecliTaskStore`. Finds latest incomplete task or creates a new one. +- **Auto-titling**: New tasks get a title from the first todo item, or a timestamp fallback. +- **`/task new [title]`**: New subcommand — explicitly start a fresh task for users who want to segment work. +- **Session persistence**: Save/restore `active_task_id` in session data. + +## File Changes Required + +### `cecli/tools/update_todo_list.py` +- Remove `_execute_todo_txt` method entirely. +- Remove the `active_task_id` routing branch in `execute()`. +- `execute()` becomes: + 1. Get store: `CecliTaskStore(coder.root)` + 2. Get or create current task: `store.get_or_create_session_task(coder)` + 3. Write subtasks: `store.update_task_subtasks(task_id, tasks, append)` + 4. Track change, return result. +- Keep `format_output()` unchanged. + +### `cecli/coders/agent_coder.py` +- `get_todo_list()`: Remove the `todo.txt` fallback path. Always render from the store via `render_task_todo_block(active_task_id)`. If no active task or no subtasks, return the existing "Todo list does not exist" prompt. +- On init or session resume: call `store.get_or_create_session_task(coder)` to resolve and set `active_task_id`. +- Remove any board-tool registry rebuilding logic (no conditional tool registration). + +### `cecli/brainfile/store.py` +- Add `get_or_create_session_task(coder) -> dict`: + - Calls `ensure_initialized()`. + - Scans `board/` for most recently updated task file. + - If found with incomplete subtasks → sets `active_task_id` on coder, returns it. + - If all complete or none exist → creates new task (column `in-progress`, auto-title), sets `active_task_id`, returns it. +- Add `auto_title(items: list[dict] | None) -> str`: + - First non-done item text, truncated to 80 chars. + - Fallback: `"Session {date}"`. +- Rest of adapter unchanged. + +### `cecli/tools/__init__.py` +- Remove `BOARD_TOOL_MODULES` list. +- Remove `task_create.py`, `task_list.py`, `task_show.py`, `task_update.py`, `task_complete.py`, `task_drop.py`, `open_task.py` from LLM tool registration. +- These files can stay on disk for now (the `/task` command imports from the store directly, not from these tool modules), or be deleted if they're only wired into the LLM tool schema. + +### `cecli/tools/utils/registry.py` +- Remove conditional board-existence check for tool registration. +- Simplify to always register the same tool set. + +### `cecli/prompts/agent.yml` +- **No changes.** Agent prompt stays exactly as it is on main. `UpdateTodoList` is the only tool mentioned. + +### `cecli/commands/task.py` +- Remove `/task promote` subcommand. +- Add `/task new [title]` — creates a fresh task, sets it as active, future `UpdateTodoList` calls write to it. +- Keep everything else: `list`, `add`, `show`, `open`, `update`, `delete`, `complete`, `drop`. + +### `cecli/sessions.py` +- On session save: include `active_task_id` in session JSON. +- On session load: restore `active_task_id`, verify the task file still exists in `board/`. If deleted, clear it. + +## Default Board Config + +Auto-created at `.cecli/tasks/brainfile.md` on first `UpdateTodoList` write: + +```yaml +--- +title: cecli tasks +schema: https://brainfile.md/v2/board.json +strict: false +columns: + - id: in-progress + title: In Progress +--- +``` + +One column. No ceremony. The user who never types `/task` will never see this file. + +## Agent Prompt (unchanged from main) + +``` +## Todo List Management +- **Track Progress**: Use the `UpdateTodoList` tool to add or modify items. +- **Plan Steps**: Create a todo list at the start of complex tasks... +- **Stay Organized**: Update the todo list as you complete steps... +``` + +Zero mention of boards, tasks, brainfile, structured data, or any underlying infrastructure. + +## Example Flows + +### Basic user (never touches /task) +``` +User: "Add authentication to this app" + +Agent: [calls UpdateTodoList with 5 items] + → store.get_or_create_session_task() creates task-1.md + → 5 subtasks written to task-1.md frontmatter + → User sees the same ✓/○/→ display they always saw + +Agent: [completes 3 items, calls UpdateTodoList] + → task-1.md updated, 3 subtasks marked done + +User: closes session, opens new one next day + → agent_coder init finds task-1 with 2 incomplete subtasks + → sets active_task_id = task-1 + → get_todo_list() shows remaining work automatically + +Agent: [finishes remaining items] + → task-1 subtasks all complete + → next UpdateTodoList call creates task-2 (fresh task) +``` + +### Power user (uses /task) +``` +User: /task list + → task-1 (in-progress, 3/5 done) + +User: /task complete task-1 + → Moved to logs/ + +User: /task new "Refactor database layer" + → Creates task-2, sets as active + → Agent's next UpdateTodoList writes to task-2 + +User: /task list logs + → task-1: "Add authentication" (completed) +``` + +### Session restore +``` +User: /session save "auth-work" + → Saves session JSON with active_task_id: "task-1" + +User: /session load "auth-work" + → Restores active_task_id, verifies task-1.md exists + → get_todo_list() renders task-1's remaining subtasks +``` + +## Tests +```bash +uv run pytest tests/basic/test_cecli_task_store.py -q +uv run pytest tests/basic/test_task_management.py -q +uv run pytest tests/basic/test_sessions.py -q +``` + +### Test cases to add/update +- `UpdateTodoList` with no prior board → auto-creates board config + task file +- `UpdateTodoList` with existing incomplete task → updates subtasks in place +- `UpdateTodoList` after all subtasks complete → creates new task on next call +- `get_or_create_session_task` finds latest incomplete → returns it +- `get_or_create_session_task` with no tasks → creates new +- `get_or_create_session_task` with all complete → creates new +- Session save includes `active_task_id` +- Session restore with valid `active_task_id` → resumes +- Session restore with missing task file → clears and creates new +- `/task new` mid-session → switches active task, next UpdateTodoList uses it +- `/task complete` → moves to logs, clears active, next UpdateTodoList creates fresh + +## Open Questions +1. **`todo.txt` write-through**: Should we also write `todo.txt` on each update for backward compat with external scripts? Leaning no — clean break, `todo.txt` is a cecli internal. +2. **Auto-title strategy**: First subtask item (truncated) vs session timestamp vs something else. First item is probably most useful for `/task list` display. +3. **Stale task cleanup**: Should old incomplete tasks auto-archive after N days? Or leave entirely to the user? Leaning leave it — auto-archiving risks losing work. +4. **`/task add` vs `/task new`**: Should `/task add` still exist (creates a task but doesn't switch to it) vs `/task new` (creates and switches)? Probably keep both — `add` for queueing future work, `new` for "start fresh now." diff --git a/pyproject.toml b/pyproject.toml index 12833c71d3e..34db1a629a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ build-backend = "setuptools.build_meta" write_to = "cecli/_version.py" local_scheme = "no-local-version" +[tool.uv.sources] +brainfile = { path = "/home/george/Projects/core/brainfile/brainfile-py" } + [tool.codespell] skip = "*.svg,*.scss,Gemfile.lock,tests/fixtures/*,cecli/website/assets/*" write-changes = true \ No newline at end of file diff --git a/requirements/requirements.in b/requirements/requirements.in index ef6b1783c88..8ffff983caf 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -46,4 +46,5 @@ importlib-metadata>=7.2.1 tomli>=2.3.0; python_version <= "3.10" tree-sitter==0.23.2; python_version < "3.10" -tree-sitter>=0.25.1; python_version >= "3.10" \ No newline at end of file +tree-sitter>=0.25.1; python_version >= "3.10" +brainfile diff --git a/tests/basic/test_cecli_task_store.py b/tests/basic/test_cecli_task_store.py new file mode 100644 index 00000000000..013699d04f1 --- /dev/null +++ b/tests/basic/test_cecli_task_store.py @@ -0,0 +1,262 @@ +import os +import shutil +import tempfile +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase + +from cecli.brainfile.store import CecliTaskStore, _normalize_task_id + + +class TestCecliTaskStore(TestCase): + def setUp(self): + self.original_cwd = os.getcwd() + self.tempdir = tempfile.mkdtemp() + os.chdir(self.tempdir) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.tempdir, ignore_errors=True) + + def test_initialize_board_creates_brainfile(self): + store = CecliTaskStore(Path(self.tempdir)) + store.ensure_initialized() + + board_path = Path(self.tempdir) / ".cecli" / "tasks" / "brainfile.md" + self.assertTrue(board_path.exists()) + content = board_path.read_text(encoding="utf-8") + self.assertIn("title: cecli tasks", content) + self.assertIn("type: board", content) + + def test_board_exists_false_before_init(self): + store = CecliTaskStore(Path(self.tempdir)) + self.assertFalse(store.board_exists()) + + def test_board_exists_true_after_init(self): + store = CecliTaskStore(Path(self.tempdir)) + store.ensure_initialized() + self.assertTrue(store.board_exists()) + + def test_create_and_find_task(self): + store = CecliTaskStore(Path(self.tempdir)) + created = store.create_task(title="Test task") + self.assertEqual(created["id"], "task-1") + self.assertEqual(created["title"], "Test task") + + found = store.find_task("task-1") + self.assertIsNotNone(found) + self.assertEqual(found["id"], "task-1") + self.assertEqual(found["task"].title, "Test task") + + def test_complete_task_moves_to_logs(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="Complete me") + completed = store.complete_task("task-1") + self.assertEqual(completed["location"], "logs") + + # Should not be in board anymore + board_path = Path(self.tempdir) / ".cecli" / "tasks" / "board" / "task-1.md" + self.assertFalse(board_path.exists()) + log_path = Path(self.tempdir) / ".cecli" / "tasks" / "logs" / "task-1.md" + self.assertTrue(log_path.exists()) + + def test_delete_task(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="Delete me") + deleted = store.delete_task("task-1") + self.assertEqual(deleted["id"], "task-1") + self.assertIsNone(store.find_task("task-1")) + + def test_update_task_title_and_column(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="Original") + updated = store.update_task("task-1", title="Updated", column="in-progress") + self.assertTrue(updated["changed"]) + self.assertEqual(updated["title"], "Updated") + self.assertEqual(updated["column"], "in-progress") + + def test_update_task_subtasks(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="With subtasks") + store.update_task_subtasks( + "task-1", + [ + {"task": "Step 1", "done": True}, + {"task": "Step 2", "done": False, "current": True}, + {"task": "Step 3", "done": False}, + ], + ) + + found = store.find_task("task-1") + self.assertIsNotNone(found) + subtasks = found["task"].subtasks + self.assertEqual(len(subtasks), 3) + # Current items come first among remaining + self.assertEqual(subtasks[0].title, "Step 2") + self.assertFalse(subtasks[0].completed) + self.assertEqual(subtasks[1].title, "Step 3") + self.assertEqual(subtasks[2].title, "Step 1") + self.assertTrue(subtasks[2].completed) + + def test_next_task_id(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="First") + store.create_task(title="Second") + store.create_task(title="Third") + + next_id = store.next_task_id("task-1") + self.assertEqual(next_id, "task-2") + + next_id = store.next_task_id("task-2") + self.assertEqual(next_id, "task-3") + + next_id = store.next_task_id("task-3") + self.assertIsNone(next_id) + + def test_list_tasks_scopes(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="Active task") + store.create_task(title="To complete") + store.complete_task("task-2") + + board = store.list_tasks("board") + self.assertEqual(len(board), 1) + self.assertEqual(board[0]["id"], "task-1") + + logs = store.list_tasks("logs") + self.assertEqual(len(logs), 1) + self.assertEqual(logs[0]["id"], "task-2") + + all_tasks = store.list_tasks("all") + self.assertEqual(len(all_tasks), 2) + + def test_normalize_task_id_accepts_natural_references(self): + self.assertEqual(_normalize_task_id("11"), "task-11") + self.assertEqual(_normalize_task_id("task 11"), "task-11") + self.assertEqual(_normalize_task_id("task11"), "task-11") + self.assertEqual(_normalize_task_id(".cecli/tasks/board/task-11.md"), "task-11") + + def test_render_task_todo_block(self): + store = CecliTaskStore(Path(self.tempdir)) + store.create_task(title="Render test") + store.update_task_subtasks( + "task-1", + [ + {"task": "Done item", "done": True}, + {"task": "Current item", "done": False, "current": True}, + {"task": "Pending item", "done": False}, + ], + ) + + block = store.render_task_todo_block("task-1") + self.assertIsNotNone(block) + self.assertIn("Active Task Checklist", block) + self.assertIn("[x] Done item", block) + self.assertIn("-> Current item", block) + self.assertIn("- Pending item", block) + + # -- get_or_create_session_task tests ------------------------------------ + + def _make_fake_coder(self): + """Return a minimal object that satisfies get_or_create_session_task.""" + coder = SimpleNamespace( + root=self.tempdir, + abs_fnames=set(), + abs_read_only_fnames=set(), + abs_read_only_stubs_fnames=set(), + active_task_id=None, + ) + coder.abs_root_path = lambda rel: str(Path(self.tempdir) / rel) + coder.check_added_files = lambda: None + + io = SimpleNamespace() + io.read_text = lambda path: ( + Path(path).read_text(encoding="utf-8") if Path(path).exists() else None + ) + io.set_active_task = lambda **kw: None + io.tool_output = lambda *a, **kw: None + coder.io = io + + return coder + + def test_get_or_create_session_task_creates_when_none(self): + store = CecliTaskStore(Path(self.tempdir)) + coder = self._make_fake_coder() + + result = store.get_or_create_session_task(coder) + self.assertEqual(result["id"], "task-1") + self.assertEqual(coder.active_task_id, "task-1") + + def test_get_or_create_does_not_leak_file_to_context(self): + """Auto-created session task should NOT add file to coder context.""" + store = CecliTaskStore(Path(self.tempdir)) + coder = self._make_fake_coder() + + store.get_or_create_session_task(coder) + self.assertEqual(len(coder.abs_fnames), 0) + self.assertEqual(len(coder.abs_read_only_fnames), 0) + + def test_get_or_create_session_task_finds_incomplete(self): + store = CecliTaskStore(Path(self.tempdir)) + coder = self._make_fake_coder() + + # Pre-create a task with incomplete subtasks + store.create_task("Existing", column="in-progress") + store.update_task_subtasks( + "task-1", + [{"task": "Not done", "done": False}], + ) + + result = store.get_or_create_session_task(coder) + self.assertEqual(result["id"], "task-1") + self.assertEqual(coder.active_task_id, "task-1") + # Should NOT create task-2 + self.assertIsNone(store.find_task("task-2")) + + def test_get_or_create_session_task_creates_when_all_complete(self): + store = CecliTaskStore(Path(self.tempdir)) + coder = self._make_fake_coder() + + # Pre-create a task with all subtasks done + store.create_task("Done task", column="in-progress") + store.update_task_subtasks( + "task-1", + [{"task": "All done", "done": True}], + ) + + result = store.get_or_create_session_task(coder) + # Should create a new task since all existing are complete + self.assertEqual(result["id"], "task-2") + self.assertEqual(coder.active_task_id, "task-2") + + # -- auto_title tests ---------------------------------------------------- + + def test_auto_title_from_items(self): + title = CecliTaskStore.auto_title( + [ + {"task": "First item", "done": False}, + {"task": "Second item", "done": False}, + ] + ) + self.assertEqual(title, "First item") + + def test_auto_title_skips_done_items(self): + title = CecliTaskStore.auto_title( + [ + {"task": "Done", "done": True}, + {"task": "Not done yet", "done": False}, + ] + ) + self.assertEqual(title, "Not done yet") + + def test_auto_title_fallback(self): + from datetime import date + + title = CecliTaskStore.auto_title(None) + self.assertEqual(title, f"Session {date.today().isoformat()}") + + title = CecliTaskStore.auto_title([]) + self.assertEqual(title, f"Session {date.today().isoformat()}") + + title = CecliTaskStore.auto_title([{"task": "All done", "done": True}]) + self.assertEqual(title, f"Session {date.today().isoformat()}") diff --git a/tests/basic/test_sessions.py b/tests/basic/test_sessions.py index 7bc3a266849..f3b261ee60e 100644 --- a/tests/basic/test_sessions.py +++ b/tests/basic/test_sessions.py @@ -47,8 +47,6 @@ async def test_cmd_save_session_basic(self): {"role": "assistant", "content": "Hi there!"}, ] coder.cur_messages = [{"role": "user", "content": "Can you help me?"}] - todo_content = "Task 1\nTask 2" - Path(".cecli.todo.txt").write_text(todo_content, encoding="utf-8") session_name = "test_session" commands.execute("save_session", session_name) session_file = Path(handle_core_files(".cecli")) / "sessions" / f"{session_name}.json" @@ -70,7 +68,7 @@ async def test_cmd_save_session_basic(self): self.assertEqual(settings["auto_commits"], coder.auto_commits) self.assertEqual(settings["auto_lint"], coder.auto_lint) self.assertEqual(settings["auto_test"], coder.auto_test) - self.assertEqual(session_data["todo_list"], todo_content) + self.assertNotIn("todo_list", session_data) async def test_cmd_load_session_basic(self): """Test basic session load functionality""" @@ -105,9 +103,11 @@ async def test_cmd_load_session_basic(self): "read_only": ["subdir/file3.md"], "read_only_stubs": [], }, - "settings": {"auto_commits": True, "auto_lint": False, "auto_test": False}, - "todo_list": """Restored tasks -- item""", + "settings": { + "auto_commits": True, + "auto_lint": False, + "auto_test": False, + }, } session_file = Path(handle_core_files(".cecli")) / "sessions" / "test_session.json" session_file.parent.mkdir(parents=True, exist_ok=True) @@ -124,9 +124,6 @@ async def test_cmd_load_session_basic(self): self.assertEqual(coder.auto_commits, True) self.assertEqual(coder.auto_lint, False) self.assertEqual(coder.auto_test, False) - todo_file = Path(".cecli.todo.txt") - self.assertTrue(todo_file.exists()) - self.assertEqual(todo_file.read_text(encoding="utf-8"), session_data["todo_list"]) async def test_cmd_list_sessions_basic(self): """Test basic session list functionality""" @@ -182,15 +179,86 @@ async def test_cmd_list_sessions_basic(self): self.assertIn("gpt-3.5-turbo", output_text) self.assertIn("gpt-4", output_text) - async def test_preserve_todo_list_deprecated(self): - """Ensure preserve-todo-list flag is deprecated and todo is cleared on startup""" + async def test_session_saves_and_restores_active_task_id(self): + """Session save/load round-trips active_task_id.""" with GitTemporaryDirectory(): - todo_path = Path(".cecli.todo.txt") - todo_path.write_text("keep me", encoding="utf-8") io = InputOutput(pretty=False, fancy_input=False, yes=True) - with mock.patch.object(io, "tool_warning") as mock_tool_warning: - await Coder.create(self.GPT35, None, io) - self.assertFalse(todo_path.exists()) - self.assertTrue( - any("deprecated" in call[0][0] for call in mock_tool_warning.call_args_list) + coder = await Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + # Create a task and set as active + from cecli.brainfile import CecliTaskStore + + store = CecliTaskStore(Path(coder.root)) + store.create_task("Persist me", column="in-progress") + store.open_task_in_context(coder, "task-1", mode="auto", explicit=False) + self.assertEqual(getattr(coder, "active_task_id", None), "task-1") + + # Save session + commands.execute("save_session", "task_session") + session_file = Path(coder.root) / ".cecli" / "sessions" / "task_session.json" + self.assertTrue(session_file.exists()) + + with open(session_file, "r") as f: + data = json.load(f) + self.assertEqual(data["active_task_id"], "task-1") + + # Clear active task, then restore + setattr(coder, "active_task_id", None) + commands.execute("load_session", "task_session") + self.assertEqual(getattr(coder, "active_task_id", None), "task-1") + + async def test_session_restore_clears_missing_active_task(self): + """Session restore clears active_task_id if task file is gone.""" + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + + # Write a session file that references a task that doesn't exist + session_data = { + "version": 1, + "session_name": "stale_task", + "model": self.GPT35.name, + "edit_format": "diff", + "chat_history": {"done_messages": [], "cur_messages": []}, + "files": {"editable": [], "read_only": [], "read_only_stubs": []}, + "settings": { + "auto_commits": True, + "auto_lint": False, + "auto_test": False, + }, + "active_task_id": "task-99", + } + session_dir = Path(coder.root) / ".cecli" / "sessions" + session_dir.mkdir(parents=True, exist_ok=True) + session_file = session_dir / "stale_task.json" + with open(session_file, "w") as f: + json.dump(session_data, f) + + commands.execute("load_session", "stale_task") + self.assertIsNone(getattr(coder, "active_task_id", None)) + + async def test_tasks_board_persists_on_startup(self): + """Ensure `.cecli/tasks` data is not cleared on startup.""" + with GitTemporaryDirectory(): + task_path = Path(".cecli/tasks/board/task-1.md") + task_path.parent.mkdir(parents=True, exist_ok=True) + task_path.write_text( + """--- +id: task-1 +title: Keep me +column: in-progress +subtasks: + - id: task-1-1 + title: item + completed: false +--- +## Description +Persist this file. +""", + encoding="utf-8", ) + io = InputOutput(pretty=False, fancy_input=False, yes=True) + await Coder.create(self.GPT35, None, io) + self.assertTrue(task_path.exists()) diff --git a/tests/basic/test_task_management.py b/tests/basic/test_task_management.py new file mode 100644 index 00000000000..04e97a76549 --- /dev/null +++ b/tests/basic/test_task_management.py @@ -0,0 +1,209 @@ +import os +import shutil +import tempfile +from pathlib import Path +from unittest import TestCase + +from cecli.brainfile import CecliTaskStore +from cecli.coders import Coder +from cecli.commands import Commands +from cecli.io import InputOutput +from cecli.models import Model +from cecli.tools.update_todo_list import Tool as UpdateTodoListTool + + +class TestTaskManagement(TestCase): + def setUp(self): + self.original_cwd = os.getcwd() + self.tempdir = tempfile.mkdtemp() + os.chdir(self.tempdir) + self.GPT35 = Model("gpt-3.5-turbo") + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.tempdir, ignore_errors=True) + + async def test_task_command_basic_crud_and_open(self): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + active_updates = [] + coder.io.set_active_task = lambda **kwargs: active_updates.append(kwargs) + commands = Commands(io, coder) + store = CecliTaskStore(Path(coder.root)) + + await commands.execute("task", 'add "Implement OAuth flow"') + tasks = store.list_tasks("board") + self.assertEqual(len(tasks), 1) + self.assertEqual(tasks[0]["id"], "task-1") + + await commands.execute("task", "open task 1") + board_task_path = Path(coder.root) / ".cecli" / "tasks" / "board" / "task-1.md" + self.assertIn(str(board_task_path.resolve()), coder.abs_fnames) + + await commands.execute("task", 'update task-1 "Implement OAuth callback flow"') + task_data = store.find_task("task-1") + self.assertIsNotNone(task_data) + self.assertEqual(task_data["task"].title, "Implement OAuth callback flow") + + await commands.execute("task", "complete task-1") + self.assertFalse((Path(coder.root) / ".cecli" / "tasks" / "board" / "task-1.md").exists()) + self.assertTrue((Path(coder.root) / ".cecli" / "tasks" / "logs" / "task-1.md").exists()) + self.assertNotIn(str(board_task_path.resolve()), coder.abs_fnames) + self.assertIsNone(getattr(coder, "active_task_id", None)) + self.assertEqual(active_updates[-1]["title"], "") + + async def test_task_drop_closes_task_context_without_deleting(self): + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + commands = Commands(io, coder) + store = CecliTaskStore(Path(coder.root)) + + created = store.create_task("Refactor auth path") + task_id = created["id"] + board_task_path = Path(coder.root) / created["relpath"] + + await commands.execute("task", f"open {task_id}") + self.assertIn(str(board_task_path.resolve()), coder.abs_fnames) + + await commands.execute("task", "drop") + self.assertNotIn(str(board_task_path.resolve()), coder.abs_fnames) + self.assertTrue( + (Path(coder.root) / ".cecli" / "tasks" / "board" / f"{task_id}.md").exists() + ) + + async def test_open_task_via_command(self): + """Test opening a task via /task open and verifying context updates.""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + store = CecliTaskStore(Path(coder.root)) + active_updates = [] + coder.io.set_active_task = lambda **kwargs: active_updates.append(kwargs) + commands = Commands(io, coder) + + created = store.create_task("Write API tests") + task_id = created["id"] + board_path = Path(coder.root) / created["relpath"] + + await commands.execute("task", f"open {task_id}") + self.assertIn(str(board_path.resolve()), coder.abs_fnames) + self.assertGreaterEqual(len(active_updates), 1) + self.assertEqual(active_updates[-1]["task_id"], task_id) + + async def test_update_todo_list_auto_creates_session_task(self): + """When no board task exists, UpdateTodoList auto-creates one (no todo.txt).""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + + result = UpdateTodoListTool.execute( + coder, + tasks=[ + {"task": "Read protocol docs", "done": False, "current": True}, + {"task": "Write summary", "done": False}, + ], + ) + self.assertIn("subtasks for task-1", result) + + # A board task should have been auto-created + store = CecliTaskStore(Path(coder.root)) + brainfile_path = os.path.join(coder.root, ".cecli", "tasks", "brainfile.md") + self.assertTrue(os.path.exists(brainfile_path)) + + found = store.find_task("task-1") + self.assertIsNotNone(found) + subtasks = found["task"].subtasks + self.assertEqual(len(subtasks), 2) + self.assertEqual(subtasks[0].title, "Read protocol docs") + + # active_task_id should be set + self.assertEqual(getattr(coder, "active_task_id", None), "task-1") + + # No todo.txt should exist + todo_path = os.path.join(coder.root, ".cecli", "todo.txt") + self.assertFalse(os.path.isfile(todo_path)) + + async def test_update_todo_list_reuses_incomplete_task(self): + """UpdateTodoList reuses an existing incomplete task instead of creating new.""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + store = CecliTaskStore(Path(coder.root)) + + # Pre-create a task with incomplete subtasks + created = store.create_task("Existing work", column="in-progress") + store.update_task_subtasks( + created["id"], + [{"task": "Step 1", "done": True}, {"task": "Step 2", "done": False}], + ) + + # Now call UpdateTodoList without setting active_task_id + result = UpdateTodoListTool.execute( + coder, + tasks=[ + {"task": "Step 2", "done": True}, + {"task": "Step 3", "done": False}, + ], + ) + self.assertIn("subtasks for task-1", result) + self.assertEqual(getattr(coder, "active_task_id", None), "task-1") + + # Should NOT have created task-2 + self.assertIsNone(store.find_task("task-2")) + + async def test_update_todo_list_creates_new_after_all_complete(self): + """When all existing tasks are complete, UpdateTodoList creates a new one.""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + store = CecliTaskStore(Path(coder.root)) + + # Pre-create a task with all subtasks done + created = store.create_task("Finished work", column="in-progress") + store.update_task_subtasks( + created["id"], + [{"task": "Done item", "done": True}], + ) + + result = UpdateTodoListTool.execute( + coder, + tasks=[{"task": "New work", "done": False}], + ) + self.assertIn("subtasks for task-2", result) + self.assertEqual(getattr(coder, "active_task_id", None), "task-2") + + async def test_update_todo_list_routes_to_board_task_when_active(self): + """When a board task is already active, UpdateTodoList writes to that task's subtasks.""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + store = CecliTaskStore(Path(coder.root)) + commands = Commands(io, coder) + + await commands.execute("task", "new Active checklist task") + result = UpdateTodoListTool.execute( + coder, + tasks=[ + {"task": "Read protocol docs", "done": False, "current": True}, + {"task": "Write summary", "done": False}, + ], + ) + self.assertIn("subtasks for task-1", result) + + found = store.find_task("task-1") + self.assertIsNotNone(found) + subtasks = found["task"].subtasks + self.assertEqual(len(subtasks), 2) + self.assertEqual(subtasks[0].title, "Read protocol docs") + + async def test_task_new_creates_and_activates(self): + """/task new creates a fresh task and sets it as active.""" + io = InputOutput(pretty=False, fancy_input=False, yes=True) + coder = await Coder.create(self.GPT35, None, io) + active_updates = [] + coder.io.set_active_task = lambda **kwargs: active_updates.append(kwargs) + commands = Commands(io, coder) + + await commands.execute("task", "new My custom title") + + store = CecliTaskStore(Path(coder.root)) + tasks = store.list_tasks("board") + self.assertEqual(len(tasks), 1) + self.assertEqual(tasks[0]["title"], "My custom title") + self.assertEqual(getattr(coder, "active_task_id", None), "task-1") + self.assertGreaterEqual(len(active_updates), 1) From 2dd19f05a45c07ba4766e1ffd320a8cc51f60210 Mon Sep 17 00:00:00 2001 From: 1Broseidon <gdikeakos@gmail.com> Date: Sat, 28 Feb 2026 16:52:41 -0600 Subject: [PATCH 2/2] fix: Use brainfile from PyPI instead of local path dependency --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34db1a629a9..f87fffb3887 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,8 +50,6 @@ build-backend = "setuptools.build_meta" write_to = "cecli/_version.py" local_scheme = "no-local-version" -[tool.uv.sources] -brainfile = { path = "/home/george/Projects/core/brainfile/brainfile-py" } [tool.codespell] skip = "*.svg,*.scss,Gemfile.lock,tests/fixtures/*,cecli/website/assets/*"