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.
- mcp_missioncache/__init__.py +3 -0
- mcp_missioncache/active_task.py +160 -0
- mcp_missioncache/app.py +5 -0
- mcp_missioncache/config.py +29 -0
- mcp_missioncache/db.py +35 -0
- mcp_missioncache/errors.py +86 -0
- mcp_missioncache/helpers.py +377 -0
- mcp_missioncache/iteration_log.py +333 -0
- mcp_missioncache/models.py +162 -0
- mcp_missioncache/orbit.py +643 -0
- mcp_missioncache/server.py +32 -0
- mcp_missioncache/tasks_parse.py +60 -0
- mcp_missioncache/templates/__init__.py +1 -0
- mcp_missioncache/templates/context.md +33 -0
- mcp_missioncache/templates/plan.md +39 -0
- mcp_missioncache/templates/tasks.md +33 -0
- mcp_missioncache/tools_active.py +183 -0
- mcp_missioncache/tools_docs.py +393 -0
- mcp_missioncache/tools_iteration.py +167 -0
- mcp_missioncache/tools_planning.py +473 -0
- mcp_missioncache/tools_tasks.py +779 -0
- mcp_missioncache/tools_tracking.py +309 -0
- mcp_missioncache-1.0.2.dist-info/METADATA +51 -0
- mcp_missioncache-1.0.2.dist-info/RECORD +27 -0
- mcp_missioncache-1.0.2.dist-info/WHEEL +4 -0
- mcp_missioncache-1.0.2.dist-info/entry_points.txt +2 -0
- mcp_missioncache-1.0.2.dist-info/licenses/LICENSE +21 -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
|
mcp_missioncache/app.py
ADDED
|
@@ -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
|