mcp-missioncache 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """MCP server for MissionCache project management."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,160 @@
1
+ """Per-session active MissionCache task pointer.
2
+
3
+ Tracks which MissionCache checklist task numbers (e.g. ``"54a"``, ``"56"``) the
4
+ caller is currently focused on. The statusline reads this to render the
5
+ ``Task:`` field, replacing the previous read of Claude Code's internal
6
+ TodoList (which duplicated information Claude already prints in chat).
7
+
8
+ Identifier shape: MissionCache's DB tracks projects, not checklist items. The
9
+ items the user picks from (``54a``, ``8``, ``0.1``) are markdown lines in
10
+ ``<project>-tasks.md`` parsed by their numbering, not rows in ``tasks.db``.
11
+ So the active-task pointer keys by ``(project_name, task_numbers)``.
12
+
13
+ State file: ``~/.claude/hooks/state/active-missioncache-task/<session-id>.json``::
14
+
15
+ {
16
+ "project_name": "orbit-public-release",
17
+ "task_numbers": ["54a"],
18
+ "updated": "2026-04-28T12:34:56+03:00"
19
+ }
20
+
21
+ Per-session keying matches the existing ``hooks/state/projects/<sid>.json``
22
+ pattern. Concurrent sessions on the same project don't clobber each other.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+
33
+ # Lives under ``~/.claude/`` (Claude Code's state dir), not ``~/.missioncache/``,
34
+ # because session-scoped state is keyed by Claude Code session ids and
35
+ # parallels the existing hook pointers.
36
+ STATE_DIR = Path.home() / ".claude" / "hooks" / "state" / "active-missioncache-task"
37
+
38
+ # Conservative session-id shape: alphanumeric + ``._-`` only, bounded length.
39
+ # Covers Claude Code's UUIDs and Codex/OpenCode-style ids; rejects path
40
+ # separators, ``..`` components, and null bytes that could escape STATE_DIR
41
+ # when joined into a filename.
42
+ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
43
+
44
+
45
+ def _safe_session_id(session_id: str) -> bool:
46
+ """Return True iff session_id is safe to use as a filename component.
47
+
48
+ Defense in depth - the MCP layer already validates non-empty session ids,
49
+ but accepting any string here would let a misbehaving caller traverse
50
+ out of ``STATE_DIR`` via ``../foo`` or ``/etc/passwd``.
51
+ """
52
+ if not session_id or ".." in session_id:
53
+ return False
54
+ return bool(_SESSION_ID_RE.match(session_id))
55
+
56
+
57
+ def _pointer_path(session_id: str) -> Path:
58
+ return STATE_DIR / f"{session_id}.json"
59
+
60
+
61
+ def read_pointer(session_id: str) -> dict | None:
62
+ """Return the pointer dict for this session, or None if unset/unreadable."""
63
+ if not _safe_session_id(session_id):
64
+ return None
65
+ path = _pointer_path(session_id)
66
+ try:
67
+ return json.loads(path.read_text())
68
+ except (OSError, json.JSONDecodeError):
69
+ return None
70
+
71
+
72
+ def write_pointer(
73
+ session_id: str, project_name: str, task_numbers: list[str]
74
+ ) -> Path:
75
+ """Write or replace the active-task pointer for this session.
76
+
77
+ Atomic via tmp-then-rename. Returns the written path. Raises
78
+ ValueError on session ids that would escape ``STATE_DIR`` - callers
79
+ above the MCP boundary should have already validated, but we
80
+ re-check here as defense in depth.
81
+ """
82
+ if not _safe_session_id(session_id):
83
+ raise ValueError(f"unsafe session_id: {session_id!r}")
84
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
85
+ path = _pointer_path(session_id)
86
+ payload = {
87
+ "project_name": project_name,
88
+ "task_numbers": list(task_numbers),
89
+ "updated": datetime.now().astimezone().isoformat(),
90
+ }
91
+ tmp = path.with_name(path.name + ".tmp")
92
+ tmp.write_text(json.dumps(payload, indent=2))
93
+ os.replace(tmp, path)
94
+ return path
95
+
96
+
97
+ def clear_pointer(session_id: str) -> bool:
98
+ """Delete the pointer for this session. Returns True if a file was removed."""
99
+ if not _safe_session_id(session_id):
100
+ return False
101
+ path = _pointer_path(session_id)
102
+ try:
103
+ path.unlink()
104
+ return True
105
+ except FileNotFoundError:
106
+ return False
107
+ except OSError:
108
+ return False
109
+
110
+
111
+ def remove_task_numbers_everywhere(
112
+ project_name: str, completed_numbers: list[str]
113
+ ) -> list[str]:
114
+ """Remove ``completed_numbers`` from every session's pointer for ``project_name``.
115
+
116
+ Used as the auto-clear hook on ``update_tasks_file``: when items get
117
+ marked ``[x]``, they should disappear from any active-task pointer that
118
+ referenced them. Empty pointers are removed.
119
+
120
+ Returns the list of session ids that were modified (for logging/tests).
121
+ """
122
+ if not completed_numbers:
123
+ return []
124
+ if not STATE_DIR.is_dir():
125
+ return []
126
+
127
+ completed_set = set(completed_numbers)
128
+ affected: list[str] = []
129
+
130
+ for path in STATE_DIR.glob("*.json"):
131
+ try:
132
+ data = json.loads(path.read_text())
133
+ except (OSError, json.JSONDecodeError):
134
+ continue
135
+ if data.get("project_name") != project_name:
136
+ continue
137
+ existing = data.get("task_numbers") or []
138
+ remaining = [n for n in existing if n not in completed_set]
139
+ if remaining == existing:
140
+ continue
141
+ session_id = path.stem
142
+ if not _safe_session_id(session_id):
143
+ # Filename inside STATE_DIR but not a shape we'd write - leave alone.
144
+ continue
145
+ affected.append(session_id)
146
+ # Write empty pointer first when the set drains, then best-effort
147
+ # unlink. If unlink fails (permissions, FS error), the empty
148
+ # pointer is inert (statusline treats empty task_numbers as
149
+ # "hide field"), avoiding a stale-data race where the original
150
+ # file with the now-completed numbers would otherwise persist.
151
+ if remaining:
152
+ write_pointer(session_id, project_name, remaining)
153
+ else:
154
+ write_pointer(session_id, project_name, [])
155
+ try:
156
+ path.unlink()
157
+ except OSError:
158
+ pass
159
+
160
+ return affected
@@ -0,0 +1,5 @@
1
+ """FastMCP application instance, shared across tool modules."""
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ mcp = FastMCP("missioncache")
@@ -0,0 +1,29 @@
1
+ """Configuration for the MissionCache MCP server."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Server configuration from environment variables."""
10
+
11
+ # Path to the task database
12
+ db_path: Path = Path.home() / ".missioncache" / "tasks.db"
13
+
14
+ # Centralized MissionCache root directory
15
+ root: Path = Path.home() / ".missioncache"
16
+
17
+ # Active and completed subdirectory names
18
+ active_dir_name: str = "active"
19
+ completed_dir_name: str = "completed"
20
+
21
+ # Dashboard base URL for out-of-band sync notifications (task creation).
22
+ # Failures are silently ignored - dashboard is optional.
23
+ dashboard_url: str = "http://localhost:8787"
24
+
25
+ class Config:
26
+ env_prefix = "MISSIONCACHE_"
27
+
28
+
29
+ settings = Settings()
mcp_missioncache/db.py ADDED
@@ -0,0 +1,35 @@
1
+ """Database wrapper for missioncache_db."""
2
+
3
+ from missioncache_db import Repository, Task, TaskDB, TaskStatus
4
+
5
+ from .config import settings
6
+
7
+ # Module-level singleton
8
+ _db: TaskDB | None = None
9
+
10
+
11
+ def get_db() -> TaskDB:
12
+ """Get or create the TaskDB singleton."""
13
+ global _db
14
+ if _db is None:
15
+ _db = TaskDB(db_path=settings.db_path)
16
+ _db.initialize()
17
+ return _db
18
+
19
+
20
+ def repo_to_dict(repo: Repository) -> dict:
21
+ """Convert Repository dataclass to dict."""
22
+ from dataclasses import asdict
23
+
24
+ return asdict(repo)
25
+
26
+
27
+ # Re-export for convenience
28
+ __all__ = [
29
+ "get_db",
30
+ "repo_to_dict",
31
+ "Task",
32
+ "Repository",
33
+ "TaskDB",
34
+ "TaskStatus",
35
+ ]
@@ -0,0 +1,86 @@
1
+ """Error codes and handling for the MissionCache MCP server."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+
7
+ class ErrorCode(str, Enum):
8
+ """Standard error codes for structured error responses."""
9
+
10
+ TASK_NOT_FOUND = "TASK_NOT_FOUND"
11
+ REPO_NOT_FOUND = "REPO_NOT_FOUND"
12
+ FILE_NOT_FOUND = "FILE_NOT_FOUND"
13
+ VALIDATION_ERROR = "VALIDATION_ERROR"
14
+ DB_ERROR = "DB_ERROR"
15
+ PERMISSION_ERROR = "PERMISSION_ERROR"
16
+ INVALID_STATE = "INVALID_STATE"
17
+ OPERATION_FAILED = "OPERATION_FAILED"
18
+ ALREADY_EXISTS = "ALREADY_EXISTS"
19
+
20
+
21
+ class MissionCacheError(Exception):
22
+ """Base exception for MissionCache errors with structured response."""
23
+
24
+ def __init__(
25
+ self, code: ErrorCode, message: str, details: dict[str, Any] | None = None
26
+ ):
27
+ self.code = code
28
+ self.message = message
29
+ self.details = details or {}
30
+ super().__init__(message)
31
+
32
+ def to_dict(self) -> dict:
33
+ """Convert to dictionary for MCP response."""
34
+ return {
35
+ "error": True,
36
+ "code": self.code.value,
37
+ "message": self.message,
38
+ "details": self.details,
39
+ }
40
+
41
+
42
+ class TaskNotFoundError(MissionCacheError):
43
+ """Task not found in database."""
44
+
45
+ def __init__(self, task_id: int | str, message: str | None = None):
46
+ super().__init__(
47
+ ErrorCode.TASK_NOT_FOUND,
48
+ message or f"Task not found: {task_id}",
49
+ {"task_id": task_id},
50
+ )
51
+
52
+
53
+ class MissionCacheFileNotFoundError(MissionCacheError):
54
+ """File not found on filesystem."""
55
+
56
+ def __init__(self, path: str, message: str | None = None):
57
+ super().__init__(
58
+ ErrorCode.FILE_NOT_FOUND,
59
+ message or f"File not found: {path}",
60
+ {"path": path},
61
+ )
62
+
63
+
64
+ class ValidationError(MissionCacheError):
65
+ """Input validation failed."""
66
+
67
+ def __init__(self, message: str, field: str | None = None):
68
+ details = {"field": field} if field else {}
69
+ super().__init__(ErrorCode.VALIDATION_ERROR, message, details)
70
+
71
+
72
+ class InvalidStateError(MissionCacheError):
73
+ """Operation invalid for current state."""
74
+
75
+ def __init__(
76
+ self,
77
+ message: str,
78
+ current_state: str | None = None,
79
+ expected_state: str | None = None,
80
+ ):
81
+ details = {}
82
+ if current_state:
83
+ details["current_state"] = current_state
84
+ if expected_state:
85
+ details["expected_state"] = expected_state
86
+ super().__init__(ErrorCode.INVALID_STATE, message, details)
@@ -0,0 +1,377 @@
1
+ """Shared helper functions used across tool modules."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import re
7
+ import sqlite3
8
+ import urllib.request
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+
12
+ import missioncache_db # type: ignore[import-not-found]
13
+
14
+ from . import orbit
15
+ from .config import settings
16
+ from .db import Task, get_db
17
+ from .errors import TaskNotFoundError, ValidationError
18
+ from .models import TaskDetail, TaskProgress, TaskSummary
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # Session-id charset for binding. Matches active_task._SESSION_ID_RE so any
24
+ # session_id accepted there is also bindable here. Bounded length (128) is
25
+ # conservative; Claude Code UUIDs are 36 chars, other tools may use slightly
26
+ # longer ids. Defends downstream filename interpolation in the per-session
27
+ # pointer write against path traversal.
28
+ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
29
+
30
+
31
+ def _resolve_session_id(session_id: str | None) -> str | None:
32
+ """Resolve the effective Claude session id for a binding tool.
33
+
34
+ Precedence: a non-empty explicit ``session_id`` wins. An empty string
35
+ or ``None`` is treated as "no id provided" and falls back to
36
+ ``CLAUDE_CODE_SESSION_ID``, which Claude Code 2.1.154+ injects into
37
+ every stdio MCP subprocess's environment. (Empty and absent are
38
+ deliberately equivalent here: a caller that fumbles client-side
39
+ resolution and passes "" still binds to the right session instead of
40
+ failing.) The env value is stripped, so a stray trailing newline or
41
+ whitespace-only value normalizes to ``None`` rather than leaking into a
42
+ filename or DB row. The env var is per subprocess and a stdio server is 1:1 with
43
+ the Claude Code session that spawned it, so the fallback always names
44
+ the calling session - safe even for concurrent sessions (each has its
45
+ own subprocess + env).
46
+
47
+ Returns ``None`` when neither is available: pre-2.1.154 Claude Code, or
48
+ a non-Claude client (Codex/OpenCode) that doesn't set the var. Callers
49
+ treat ``None`` exactly as a missing session id (skip binding, or raise),
50
+ so behavior for those clients is unchanged. The returned value is NOT
51
+ validated here - downstream ``_bind_session_to_project`` /
52
+ ``active_task.write_pointer`` enforce the session-id charset before it
53
+ reaches a filename or DB row.
54
+ """
55
+ if session_id:
56
+ return session_id
57
+ return (os.environ.get("CLAUDE_CODE_SESSION_ID") or "").strip() or None
58
+
59
+
60
+ def _bind_session_to_project(session_id: str | None, project_name: str) -> bool:
61
+ """Bind a Claude Code session to a project so the statusline picks it up.
62
+
63
+ Writes the ``project_state`` row in ``~/.claude/hooks-state.db`` (source
64
+ of truth the statusline reads) and the per-session
65
+ ``~/.claude/hooks/state/projects/<sid>.json`` pointer (read by
66
+ ``find_task_for_cwd``) atomically with task creation. This eliminates
67
+ the "Claude skipped the binding step" failure mode where the slash
68
+ command's client-side bash binding gets bypassed silently.
69
+
70
+ Mirrors ``hooks/session_start.py:_bind_session_to_project``. The two
71
+ helpers are deliberately not yet extracted to a shared library; if a
72
+ third caller appears, this is the right time to lift them into
73
+ ``missioncache_db``.
74
+
75
+ Direct SQL only - the dashboard may not be running, and degrading
76
+ silently to HTTP would re-introduce the failure mode this binding is
77
+ designed to eliminate. Failures log a stderr breadcrumb but do not
78
+ raise; the binding is best-effort, task creation is the load-bearing
79
+ operation.
80
+
81
+ Returns ``True`` on success, ``False`` on validation or IO failure.
82
+ """
83
+ if not session_id or not project_name:
84
+ return False
85
+ if not _SESSION_ID_RE.match(session_id):
86
+ logger.warning("Skipping session binding: invalid session_id shape")
87
+ return False
88
+
89
+ # Resolve missioncache_db symbols via attribute access (not module-level
90
+ # `from missioncache_db import X`) so test fixtures that monkeypatch
91
+ # ``missioncache_db.HOOKS_STATE_DB_PATH`` to a tmp path are honored.
92
+ db_path = missioncache_db.HOOKS_STATE_DB_PATH
93
+ try:
94
+ db_path.parent.mkdir(parents=True, exist_ok=True)
95
+ conn = sqlite3.connect(str(db_path))
96
+ try:
97
+ missioncache_db.init_hooks_state_db_schema(conn)
98
+ conn.execute(
99
+ "INSERT INTO project_state (session_id, project_name, updated_at) "
100
+ "VALUES (?, ?, datetime('now', 'localtime')) "
101
+ "ON CONFLICT(session_id) DO UPDATE SET "
102
+ "project_name = excluded.project_name, "
103
+ "updated_at = datetime('now', 'localtime')",
104
+ (session_id, project_name),
105
+ )
106
+ conn.commit()
107
+ finally:
108
+ conn.close()
109
+ except sqlite3.Error as e:
110
+ logger.warning(
111
+ f"Failed to bind session to project={project_name!r}: {e}"
112
+ )
113
+ return False
114
+
115
+ pointer_file = (
116
+ Path.home() / ".claude" / "hooks" / "state" / "projects" / f"{session_id}.json"
117
+ )
118
+ try:
119
+ missioncache_db.atomic_write_json(
120
+ pointer_file,
121
+ {
122
+ "projectName": project_name,
123
+ "updated": datetime.now().astimezone().isoformat(),
124
+ "sessionId": session_id,
125
+ },
126
+ )
127
+ except OSError as e:
128
+ # DB row already written - the per-session pointer is a secondary
129
+ # concern (used by find_task_for_cwd, not by the statusline). Log
130
+ # but report partial success since the statusline binding landed.
131
+ logger.warning(f"Failed to write per-session pointer: {e}")
132
+ return False
133
+ return True
134
+
135
+
136
+ async def _notify_dashboard_task_created() -> None:
137
+ """Fire-and-forget POST to the dashboard so it syncs immediately.
138
+
139
+ The dashboard polls SQLite every 60 seconds; this shaves that lag
140
+ off the user-visible "created a project, doesn't show up yet" case.
141
+ Silently swallows every failure - the dashboard is optional, may
142
+ not be running, and we never want to fail a tool call over it.
143
+ """
144
+ url = f"{settings.dashboard_url}/api/hooks/task-created"
145
+
146
+ def _post() -> None:
147
+ try:
148
+ req = urllib.request.Request(
149
+ url,
150
+ data=b"{}",
151
+ headers={"Content-Type": "application/json"},
152
+ method="POST",
153
+ )
154
+ urllib.request.urlopen(req, timeout=0.5)
155
+ except Exception:
156
+ pass
157
+
158
+ await asyncio.to_thread(_post)
159
+
160
+
161
+ def _resolve_to_git_root(path: str) -> str:
162
+ """Walk parents of ``path`` looking for ``.git``; return the git root.
163
+
164
+ Mirrors ``git rev-parse --show-toplevel`` semantics - the first
165
+ ancestor (closest to ``path``) that contains a ``.git`` entry
166
+ (directory or file, the latter for submodules) is the git root.
167
+ Falls back to the resolved input if no ancestor has ``.git``
168
+ before the filesystem root, so non-git project locations stay
169
+ supported.
170
+
171
+ Used at the MCP-tool boundary (``create_orbit_files``,
172
+ ``set_task_repo``) to enforce git-root resolution server-side
173
+ instead of trusting callers to do it. Slash command guidance
174
+ can be skipped silently by the model; tool-level enforcement
175
+ cannot. Callers that legitimately want a sub-package within a
176
+ monorepo to be the project boundary should pass
177
+ ``resolve_git_root=False`` to the tool rather than calling this
178
+ helper directly.
179
+
180
+ Symlinks are followed once via ``Path.resolve()`` before the walk
181
+ so a symlink to a subdir of a git repo lands on the real path.
182
+ ``OSError`` mid-walk (permission denied on ``.git`` probing) is
183
+ treated as "no git root found" and returns the resolved input.
184
+ """
185
+ current = Path(path).expanduser().resolve()
186
+
187
+ walker = current
188
+ while walker != walker.parent:
189
+ try:
190
+ if (walker / ".git").exists():
191
+ return str(walker)
192
+ except OSError:
193
+ return str(current)
194
+ walker = walker.parent
195
+ return str(current)
196
+
197
+
198
+ def _validate_path(
199
+ path: str, field_name: str = "path", must_be_under: Path | None = None
200
+ ) -> Path:
201
+ """Validate and resolve a filesystem path.
202
+
203
+ Checks for empty strings and null bytes, then resolves the path.
204
+ If must_be_under is provided, verifies the resolved path is contained
205
+ within that directory.
206
+
207
+ Raises:
208
+ ValidationError: If path is empty, contains null bytes, or resolves
209
+ outside the required root directory.
210
+ """
211
+ if not path or not path.strip():
212
+ raise ValidationError(f"{field_name} cannot be empty", field=field_name)
213
+ if "\x00" in path:
214
+ raise ValidationError(f"{field_name} contains null bytes", field=field_name)
215
+ resolved = Path(path).resolve()
216
+ if must_be_under is not None:
217
+ root = must_be_under.resolve()
218
+ if resolved != root and not str(resolved).startswith(str(root) + "/"):
219
+ raise ValidationError(
220
+ f"{field_name} must be within {root}", field=field_name
221
+ )
222
+ return resolved
223
+
224
+
225
+ def _resolve_task_dir(
226
+ db, task_id: int | None, task_name: str | None
227
+ ) -> tuple[Path, str]:
228
+ """Resolve task directory and name from task_id or task_name.
229
+
230
+ Returns:
231
+ Tuple of (task_dir, task_name).
232
+
233
+ Raises:
234
+ TaskNotFoundError: If task cannot be found.
235
+ """
236
+ task = None
237
+ if task_id:
238
+ task = db.get_task(task_id)
239
+ elif task_name:
240
+ task = db.get_task_by_name(task_name)
241
+
242
+ if not task:
243
+ identifier = task_id if task_id is not None else (task_name or "unknown")
244
+ raise TaskNotFoundError(identifier)
245
+
246
+ task_dir = settings.root / task.full_path
247
+ return task_dir, task.name
248
+
249
+
250
+ def _task_to_summary(
251
+ task: Task, db=None, time_seconds: int | None = None
252
+ ) -> TaskSummary:
253
+ """Convert a Task to TaskSummary with time info.
254
+
255
+ Args:
256
+ task: Task object to convert.
257
+ db: Database instance (auto-resolved if None).
258
+ time_seconds: Pre-fetched time in seconds. If None, fetches from DB.
259
+ """
260
+ if db is None:
261
+ db = get_db()
262
+
263
+ # Get time tracking info (use pre-fetched if available)
264
+ if time_seconds is None:
265
+ time_seconds = db.get_task_time(task.id)
266
+ time_formatted = db.format_duration(time_seconds)
267
+
268
+ # Get effective last updated (uses file mtime if more recent)
269
+ effective_last = db.get_effective_last_updated(task)
270
+ last_worked_ago = db.format_time_ago(effective_last)
271
+
272
+ # Get repo info if available
273
+ repo_name = None
274
+ repo_path = None
275
+ if task.repo_id:
276
+ repo = db.get_repo(task.repo_id)
277
+ if repo:
278
+ repo_name = repo.short_name
279
+ repo_path = repo.path
280
+
281
+ # Check if MissionCache files exist
282
+ has_orbit_files = False
283
+ if task.full_path:
284
+ task_dir = settings.root / task.full_path
285
+ has_orbit_files = task_dir.exists() and any(
286
+ (task_dir / f).exists()
287
+ for f in [
288
+ f"{task.name}-context.md",
289
+ f"{task.name}-tasks.md",
290
+ f"{task.name}-plan.md",
291
+ "context.md",
292
+ "tasks.md",
293
+ ]
294
+ )
295
+
296
+ return TaskSummary(
297
+ id=task.id,
298
+ name=task.name,
299
+ status=task.status,
300
+ type=task.task_type,
301
+ repo_name=repo_name,
302
+ repo_path=repo_path,
303
+ jira_key=task.jira_key,
304
+ tags=task.tags,
305
+ time_total_seconds=time_seconds,
306
+ time_formatted=time_formatted,
307
+ last_worked_on=effective_last,
308
+ last_worked_ago=last_worked_ago,
309
+ has_orbit_files=has_orbit_files,
310
+ )
311
+
312
+
313
+ def _task_to_detail(
314
+ task: Task, include_subtasks: bool = True, include_updates: bool = True
315
+ ) -> TaskDetail:
316
+ """Convert a Task to TaskDetail with full information."""
317
+ db = get_db()
318
+
319
+ # Get base summary fields
320
+ summary = _task_to_summary(task, db)
321
+
322
+ # Parse progress from MissionCache files
323
+ progress = None
324
+ if task.full_path:
325
+ progress = _parse_task_progress(
326
+ settings.root / task.full_path, task.name
327
+ )
328
+
329
+ # Get subtasks if this is a parent task
330
+ subtasks = []
331
+ if include_subtasks:
332
+ hierarchy = db.get_active_tasks_hierarchical(task.repo_id)
333
+ if task.id in hierarchy.get("children", {}):
334
+ for subtask in hierarchy["children"][task.id]:
335
+ subtasks.append(_task_to_summary(subtask, db))
336
+
337
+ # Get recent updates for non-coding tasks
338
+ recent_updates = []
339
+ if include_updates and task.task_type == "non-coding":
340
+ recent_updates = db.get_task_updates(task.id, limit=5)
341
+
342
+ return TaskDetail(
343
+ **summary.model_dump(by_alias=True),
344
+ full_path=task.full_path,
345
+ parent_id=task.parent_id,
346
+ branch=task.branch,
347
+ pr_url=task.pr_url,
348
+ created_at=task.created_at,
349
+ updated_at=task.updated_at,
350
+ completed_at=task.completed_at,
351
+ progress=progress,
352
+ subtasks=subtasks,
353
+ recent_updates=recent_updates,
354
+ )
355
+
356
+
357
+ def _parse_task_progress(task_dir: Path, task_name: str) -> TaskProgress | None:
358
+ """Parse progress from the tasks.md file."""
359
+ if not task_dir.exists():
360
+ return None
361
+
362
+ # Try both naming conventions
363
+ tasks_files = [
364
+ task_dir / f"{task_name}-tasks.md",
365
+ task_dir / "tasks.md",
366
+ ]
367
+
368
+ for tasks_file in tasks_files:
369
+ if tasks_file.exists():
370
+ try:
371
+ content = tasks_file.read_text()
372
+ return orbit.parse_task_progress(content)
373
+ except Exception as e:
374
+ logger.warning(f"Failed to parse {tasks_file}: {e}")
375
+ continue
376
+
377
+ return None