mcp-orbit 0.2.0__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_orbit/__init__.py +3 -0
- mcp_orbit/app.py +5 -0
- mcp_orbit/config.py +25 -0
- mcp_orbit/db.py +35 -0
- mcp_orbit/errors.py +85 -0
- mcp_orbit/helpers.py +194 -0
- mcp_orbit/iteration_log.py +333 -0
- mcp_orbit/models.py +131 -0
- mcp_orbit/orbit.py +461 -0
- mcp_orbit/server.py +31 -0
- mcp_orbit/templates/__init__.py +1 -0
- mcp_orbit/templates/context.md +33 -0
- mcp_orbit/templates/plan.md +39 -0
- mcp_orbit/templates/tasks.md +33 -0
- mcp_orbit/tools_docs.py +289 -0
- mcp_orbit/tools_iteration.py +167 -0
- mcp_orbit/tools_planning.py +473 -0
- mcp_orbit/tools_tasks.py +521 -0
- mcp_orbit/tools_tracking.py +287 -0
- mcp_orbit-0.2.0.dist-info/METADATA +51 -0
- mcp_orbit-0.2.0.dist-info/RECORD +24 -0
- mcp_orbit-0.2.0.dist-info/WHEEL +4 -0
- mcp_orbit-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_orbit-0.2.0.dist-info/licenses/LICENSE +21 -0
mcp_orbit/__init__.py
ADDED
mcp_orbit/app.py
ADDED
mcp_orbit/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Configuration for the orbit 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() / ".claude" / "tasks.db"
|
|
13
|
+
|
|
14
|
+
# Centralized orbit root directory
|
|
15
|
+
orbit_root: Path = Path.home() / ".claude" / "orbit"
|
|
16
|
+
|
|
17
|
+
# Active and completed subdirectory names
|
|
18
|
+
active_dir_name: str = "active"
|
|
19
|
+
completed_dir_name: str = "completed"
|
|
20
|
+
|
|
21
|
+
class Config:
|
|
22
|
+
env_prefix = "ORBIT_"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
settings = Settings()
|
mcp_orbit/db.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Database wrapper for orbit_db."""
|
|
2
|
+
|
|
3
|
+
from orbit_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
|
+
]
|
mcp_orbit/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Error codes and handling for the orbit 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
|
+
|
|
19
|
+
|
|
20
|
+
class OrbitError(Exception):
|
|
21
|
+
"""Base exception for orbit errors with structured response."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self, code: ErrorCode, message: str, details: dict[str, Any] | None = None
|
|
25
|
+
):
|
|
26
|
+
self.code = code
|
|
27
|
+
self.message = message
|
|
28
|
+
self.details = details or {}
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
"""Convert to dictionary for MCP response."""
|
|
33
|
+
return {
|
|
34
|
+
"error": True,
|
|
35
|
+
"code": self.code.value,
|
|
36
|
+
"message": self.message,
|
|
37
|
+
"details": self.details,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskNotFoundError(OrbitError):
|
|
42
|
+
"""Task not found in database."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, task_id: int | str, message: str | None = None):
|
|
45
|
+
super().__init__(
|
|
46
|
+
ErrorCode.TASK_NOT_FOUND,
|
|
47
|
+
message or f"Task not found: {task_id}",
|
|
48
|
+
{"task_id": task_id},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OrbitFileNotFoundError(OrbitError):
|
|
53
|
+
"""File not found on filesystem."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, path: str, message: str | None = None):
|
|
56
|
+
super().__init__(
|
|
57
|
+
ErrorCode.FILE_NOT_FOUND,
|
|
58
|
+
message or f"File not found: {path}",
|
|
59
|
+
{"path": path},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ValidationError(OrbitError):
|
|
64
|
+
"""Input validation failed."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, message: str, field: str | None = None):
|
|
67
|
+
details = {"field": field} if field else {}
|
|
68
|
+
super().__init__(ErrorCode.VALIDATION_ERROR, message, details)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InvalidStateError(OrbitError):
|
|
72
|
+
"""Operation invalid for current state."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
message: str,
|
|
77
|
+
current_state: str | None = None,
|
|
78
|
+
expected_state: str | None = None,
|
|
79
|
+
):
|
|
80
|
+
details = {}
|
|
81
|
+
if current_state:
|
|
82
|
+
details["current_state"] = current_state
|
|
83
|
+
if expected_state:
|
|
84
|
+
details["expected_state"] = expected_state
|
|
85
|
+
super().__init__(ErrorCode.INVALID_STATE, message, details)
|
mcp_orbit/helpers.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Shared helper functions used across tool modules."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from . import orbit
|
|
7
|
+
from .config import settings
|
|
8
|
+
from .db import Task, get_db
|
|
9
|
+
from .errors import TaskNotFoundError, ValidationError
|
|
10
|
+
from .models import TaskDetail, TaskProgress, TaskSummary
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_path(
|
|
16
|
+
path: str, field_name: str = "path", must_be_under: Path | None = None
|
|
17
|
+
) -> Path:
|
|
18
|
+
"""Validate and resolve a filesystem path.
|
|
19
|
+
|
|
20
|
+
Checks for empty strings and null bytes, then resolves the path.
|
|
21
|
+
If must_be_under is provided, verifies the resolved path is contained
|
|
22
|
+
within that directory.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValidationError: If path is empty, contains null bytes, or resolves
|
|
26
|
+
outside the required root directory.
|
|
27
|
+
"""
|
|
28
|
+
if not path or not path.strip():
|
|
29
|
+
raise ValidationError(f"{field_name} cannot be empty", field=field_name)
|
|
30
|
+
if "\x00" in path:
|
|
31
|
+
raise ValidationError(f"{field_name} contains null bytes", field=field_name)
|
|
32
|
+
resolved = Path(path).resolve()
|
|
33
|
+
if must_be_under is not None:
|
|
34
|
+
root = must_be_under.resolve()
|
|
35
|
+
if resolved != root and not str(resolved).startswith(str(root) + "/"):
|
|
36
|
+
raise ValidationError(
|
|
37
|
+
f"{field_name} must be within {root}", field=field_name
|
|
38
|
+
)
|
|
39
|
+
return resolved
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_task_dir(
|
|
43
|
+
db, task_id: int | None, task_name: str | None
|
|
44
|
+
) -> tuple[Path, str]:
|
|
45
|
+
"""Resolve task directory and name from task_id or task_name.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (task_dir, task_name).
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
TaskNotFoundError: If task cannot be found.
|
|
52
|
+
"""
|
|
53
|
+
task = None
|
|
54
|
+
if task_id:
|
|
55
|
+
task = db.get_task(task_id)
|
|
56
|
+
elif task_name:
|
|
57
|
+
task = db.get_task_by_name(task_name)
|
|
58
|
+
|
|
59
|
+
if not task:
|
|
60
|
+
identifier = task_id if task_id is not None else (task_name or "unknown")
|
|
61
|
+
raise TaskNotFoundError(identifier)
|
|
62
|
+
|
|
63
|
+
task_dir = settings.orbit_root / task.full_path
|
|
64
|
+
return task_dir, task.name
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _task_to_summary(
|
|
68
|
+
task: Task, db=None, time_seconds: int | None = None
|
|
69
|
+
) -> TaskSummary:
|
|
70
|
+
"""Convert a Task to TaskSummary with time info.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
task: Task object to convert.
|
|
74
|
+
db: Database instance (auto-resolved if None).
|
|
75
|
+
time_seconds: Pre-fetched time in seconds. If None, fetches from DB.
|
|
76
|
+
"""
|
|
77
|
+
if db is None:
|
|
78
|
+
db = get_db()
|
|
79
|
+
|
|
80
|
+
# Get time tracking info (use pre-fetched if available)
|
|
81
|
+
if time_seconds is None:
|
|
82
|
+
time_seconds = db.get_task_time(task.id)
|
|
83
|
+
time_formatted = db.format_duration(time_seconds)
|
|
84
|
+
|
|
85
|
+
# Get effective last updated (uses file mtime if more recent)
|
|
86
|
+
effective_last = db.get_effective_last_updated(task)
|
|
87
|
+
last_worked_ago = db.format_time_ago(effective_last)
|
|
88
|
+
|
|
89
|
+
# Get repo info if available
|
|
90
|
+
repo_name = None
|
|
91
|
+
repo_path = None
|
|
92
|
+
if task.repo_id:
|
|
93
|
+
repo = db.get_repo(task.repo_id)
|
|
94
|
+
if repo:
|
|
95
|
+
repo_name = repo.short_name
|
|
96
|
+
repo_path = repo.path
|
|
97
|
+
|
|
98
|
+
# Check if orbit files exist
|
|
99
|
+
has_orbit_files = False
|
|
100
|
+
if task.full_path:
|
|
101
|
+
task_dir = settings.orbit_root / task.full_path
|
|
102
|
+
has_orbit_files = task_dir.exists() and any(
|
|
103
|
+
(task_dir / f).exists()
|
|
104
|
+
for f in [
|
|
105
|
+
f"{task.name}-context.md",
|
|
106
|
+
f"{task.name}-tasks.md",
|
|
107
|
+
f"{task.name}-plan.md",
|
|
108
|
+
"context.md",
|
|
109
|
+
"tasks.md",
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return TaskSummary(
|
|
114
|
+
id=task.id,
|
|
115
|
+
name=task.name,
|
|
116
|
+
status=task.status,
|
|
117
|
+
type=task.task_type,
|
|
118
|
+
repo_name=repo_name,
|
|
119
|
+
repo_path=repo_path,
|
|
120
|
+
jira_key=task.jira_key,
|
|
121
|
+
tags=task.tags,
|
|
122
|
+
time_total_seconds=time_seconds,
|
|
123
|
+
time_formatted=time_formatted,
|
|
124
|
+
last_worked_on=effective_last,
|
|
125
|
+
last_worked_ago=last_worked_ago,
|
|
126
|
+
has_orbit_files=has_orbit_files,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _task_to_detail(
|
|
131
|
+
task: Task, include_subtasks: bool = True, include_updates: bool = True
|
|
132
|
+
) -> TaskDetail:
|
|
133
|
+
"""Convert a Task to TaskDetail with full information."""
|
|
134
|
+
db = get_db()
|
|
135
|
+
|
|
136
|
+
# Get base summary fields
|
|
137
|
+
summary = _task_to_summary(task, db)
|
|
138
|
+
|
|
139
|
+
# Parse progress from orbit files
|
|
140
|
+
progress = None
|
|
141
|
+
if task.full_path:
|
|
142
|
+
progress = _parse_task_progress(
|
|
143
|
+
settings.orbit_root / task.full_path, task.name
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Get subtasks if this is a parent task
|
|
147
|
+
subtasks = []
|
|
148
|
+
if include_subtasks:
|
|
149
|
+
hierarchy = db.get_active_tasks_hierarchical(task.repo_id)
|
|
150
|
+
if task.id in hierarchy.get("children", {}):
|
|
151
|
+
for subtask in hierarchy["children"][task.id]:
|
|
152
|
+
subtasks.append(_task_to_summary(subtask, db))
|
|
153
|
+
|
|
154
|
+
# Get recent updates for non-coding tasks
|
|
155
|
+
recent_updates = []
|
|
156
|
+
if include_updates and task.task_type == "non-coding":
|
|
157
|
+
recent_updates = db.get_task_updates(task.id, limit=5)
|
|
158
|
+
|
|
159
|
+
return TaskDetail(
|
|
160
|
+
**summary.model_dump(by_alias=True),
|
|
161
|
+
full_path=task.full_path,
|
|
162
|
+
parent_id=task.parent_id,
|
|
163
|
+
branch=task.branch,
|
|
164
|
+
pr_url=task.pr_url,
|
|
165
|
+
created_at=task.created_at,
|
|
166
|
+
updated_at=task.updated_at,
|
|
167
|
+
completed_at=task.completed_at,
|
|
168
|
+
progress=progress,
|
|
169
|
+
subtasks=subtasks,
|
|
170
|
+
recent_updates=recent_updates,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_task_progress(task_dir: Path, task_name: str) -> TaskProgress | None:
|
|
175
|
+
"""Parse progress from the tasks.md file."""
|
|
176
|
+
if not task_dir.exists():
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
# Try both naming conventions
|
|
180
|
+
tasks_files = [
|
|
181
|
+
task_dir / f"{task_name}-tasks.md",
|
|
182
|
+
task_dir / "tasks.md",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for tasks_file in tasks_files:
|
|
186
|
+
if tasks_file.exists():
|
|
187
|
+
try:
|
|
188
|
+
content = tasks_file.read_text()
|
|
189
|
+
return orbit.parse_task_progress(content)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(f"Failed to parse {tasks_file}: {e}")
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
return None
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Iteration log integration for autonomous task execution.
|
|
2
|
+
|
|
3
|
+
Progress tracking is done ONLY via checkboxes in the tasks.md file.
|
|
4
|
+
Prompts do not have status fields - tasks.md is the single source of truth.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_iteration_log_path(task_dir: str | Path, task_name: str) -> Path:
|
|
14
|
+
"""Get path to the iteration log file."""
|
|
15
|
+
return Path(task_dir) / f"{task_name}-iteration-log.md"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def log_iteration(
|
|
19
|
+
task_dir: str | Path,
|
|
20
|
+
task_name: str,
|
|
21
|
+
iteration: int,
|
|
22
|
+
status: str,
|
|
23
|
+
task_title: str | None = None,
|
|
24
|
+
what_done: list[str] | None = None,
|
|
25
|
+
files_modified: list[str] | None = None,
|
|
26
|
+
validation: dict[str, str] | None = None,
|
|
27
|
+
error_details: str | None = None,
|
|
28
|
+
next_steps: list[str] | None = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Log an iteration to the iteration log.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
task_dir: Task directory path
|
|
34
|
+
task_name: Task name
|
|
35
|
+
iteration: Iteration number
|
|
36
|
+
status: SUCCESS, FAILED, or BLOCKED
|
|
37
|
+
task_title: Title of the task being worked on
|
|
38
|
+
what_done: List of what was done/attempted
|
|
39
|
+
files_modified: List of modified files
|
|
40
|
+
validation: Dict of validation results {tests: PASS, typecheck: PASS, etc.}
|
|
41
|
+
error_details: Error details if status is FAILED
|
|
42
|
+
next_steps: Suggested next steps if FAILED
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The log entry that was written
|
|
46
|
+
"""
|
|
47
|
+
log_path = get_iteration_log_path(task_dir, task_name)
|
|
48
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
49
|
+
|
|
50
|
+
# Build the log entry
|
|
51
|
+
entry = f"\n## Iteration {iteration}"
|
|
52
|
+
if task_title:
|
|
53
|
+
entry += f" - {task_title}"
|
|
54
|
+
entry += f"\n**Status:** {status}\n"
|
|
55
|
+
entry += f"**Time:** {timestamp}\n\n"
|
|
56
|
+
|
|
57
|
+
if what_done:
|
|
58
|
+
header = (
|
|
59
|
+
"### What was done" if status == "SUCCESS" else "### What was attempted"
|
|
60
|
+
)
|
|
61
|
+
entry += f"{header}\n"
|
|
62
|
+
for item in what_done:
|
|
63
|
+
entry += f"- {item}\n"
|
|
64
|
+
entry += "\n"
|
|
65
|
+
|
|
66
|
+
if files_modified:
|
|
67
|
+
entry += "### Files modified\n"
|
|
68
|
+
for f in files_modified:
|
|
69
|
+
entry += f"- {f}\n"
|
|
70
|
+
entry += "\n"
|
|
71
|
+
|
|
72
|
+
if validation:
|
|
73
|
+
entry += "### Validation\n"
|
|
74
|
+
for check, result in validation.items():
|
|
75
|
+
entry += f"- {check}: {result}\n"
|
|
76
|
+
entry += "\n"
|
|
77
|
+
|
|
78
|
+
if error_details:
|
|
79
|
+
entry += f"### Error details\n{error_details}\n\n"
|
|
80
|
+
|
|
81
|
+
if next_steps:
|
|
82
|
+
entry += "### Next steps to try\n"
|
|
83
|
+
for step in next_steps:
|
|
84
|
+
entry += f"- {step}\n"
|
|
85
|
+
entry += "\n"
|
|
86
|
+
|
|
87
|
+
# Append to log
|
|
88
|
+
if log_path.exists():
|
|
89
|
+
with open(log_path, "a") as f:
|
|
90
|
+
f.write(entry)
|
|
91
|
+
else:
|
|
92
|
+
# Create new log with header
|
|
93
|
+
log_path.write_text(f"""# Iteration Log - {task_name}
|
|
94
|
+
|
|
95
|
+
**Started:** {timestamp.split()[0]}
|
|
96
|
+
**Max Iterations:** 20
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
{entry}""")
|
|
100
|
+
|
|
101
|
+
return entry
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def log_completion(
|
|
105
|
+
task_dir: str | Path,
|
|
106
|
+
task_name: str,
|
|
107
|
+
total_iterations: int,
|
|
108
|
+
duration_seconds: int,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Log task completion to the iteration log."""
|
|
111
|
+
log_path = get_iteration_log_path(task_dir, task_name)
|
|
112
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
113
|
+
|
|
114
|
+
entry = f"""
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
# COMPLETED
|
|
118
|
+
**Finished:** {timestamp}
|
|
119
|
+
**Total iterations:** {total_iterations}
|
|
120
|
+
**Duration:** {duration_seconds}s
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
if log_path.exists():
|
|
124
|
+
with open(log_path, "a") as f:
|
|
125
|
+
f.write(entry)
|
|
126
|
+
|
|
127
|
+
return entry
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def log_timeout(
|
|
131
|
+
task_dir: str | Path,
|
|
132
|
+
task_name: str,
|
|
133
|
+
max_iterations: int,
|
|
134
|
+
duration_seconds: int,
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Log timeout to the iteration log."""
|
|
137
|
+
log_path = get_iteration_log_path(task_dir, task_name)
|
|
138
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
139
|
+
|
|
140
|
+
entry = f"""
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
# TIMEOUT
|
|
144
|
+
**Stopped:** {timestamp}
|
|
145
|
+
**Reached max iterations:** {max_iterations}
|
|
146
|
+
**Duration:** {duration_seconds}s
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
if log_path.exists():
|
|
150
|
+
with open(log_path, "a") as f:
|
|
151
|
+
f.write(entry)
|
|
152
|
+
|
|
153
|
+
return entry
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_iteration_status(task_dir: str | Path, task_name: str) -> dict[str, Any]:
|
|
157
|
+
"""Get the current iteration loop status from the log file.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dict with status info: {
|
|
161
|
+
exists: bool,
|
|
162
|
+
started: str | None,
|
|
163
|
+
max_iterations: int | None,
|
|
164
|
+
iterations: int,
|
|
165
|
+
last_status: str | None,
|
|
166
|
+
completed: bool,
|
|
167
|
+
timed_out: bool,
|
|
168
|
+
blocked: bool,
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
171
|
+
log_path = get_iteration_log_path(task_dir, task_name)
|
|
172
|
+
|
|
173
|
+
if not log_path.exists():
|
|
174
|
+
return {
|
|
175
|
+
"exists": False,
|
|
176
|
+
"started": None,
|
|
177
|
+
"max_iterations": None,
|
|
178
|
+
"iterations": 0,
|
|
179
|
+
"last_status": None,
|
|
180
|
+
"completed": False,
|
|
181
|
+
"timed_out": False,
|
|
182
|
+
"blocked": False,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
content = log_path.read_text()
|
|
186
|
+
|
|
187
|
+
# Parse started time
|
|
188
|
+
started_match = re.search(r"\*\*Started:\*\* (.+)", content)
|
|
189
|
+
started = started_match.group(1) if started_match else None
|
|
190
|
+
|
|
191
|
+
# Parse max iterations
|
|
192
|
+
max_match = re.search(r"\*\*Max Iterations:\*\* (\d+)", content)
|
|
193
|
+
max_iterations = int(max_match.group(1)) if max_match else None
|
|
194
|
+
|
|
195
|
+
# Count iterations
|
|
196
|
+
iterations = len(re.findall(r"## Iteration \d+", content))
|
|
197
|
+
|
|
198
|
+
# Get last status
|
|
199
|
+
status_matches = list(
|
|
200
|
+
re.finditer(r"\*\*Status:\*\* (SUCCESS|FAILED|BLOCKED)", content)
|
|
201
|
+
)
|
|
202
|
+
last_status = status_matches[-1].group(1) if status_matches else None
|
|
203
|
+
|
|
204
|
+
# Check for completion/timeout
|
|
205
|
+
completed = "# COMPLETED" in content
|
|
206
|
+
timed_out = "# TIMEOUT" in content
|
|
207
|
+
blocked = "BLOCKED" in content and not completed
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"exists": True,
|
|
211
|
+
"started": started,
|
|
212
|
+
"max_iterations": max_iterations,
|
|
213
|
+
"iterations": iterations,
|
|
214
|
+
"last_status": last_status,
|
|
215
|
+
"completed": completed,
|
|
216
|
+
"timed_out": timed_out,
|
|
217
|
+
"blocked": blocked,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _task_id_to_display(task_id: str) -> str:
|
|
222
|
+
"""Convert task_id format to display format.
|
|
223
|
+
|
|
224
|
+
"01" -> "1"
|
|
225
|
+
"01-02" -> "1.2"
|
|
226
|
+
"""
|
|
227
|
+
if "-" in task_id:
|
|
228
|
+
parts = task_id.split("-")
|
|
229
|
+
return f"{int(parts[0])}.{int(parts[1])}"
|
|
230
|
+
return str(int(task_id))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_task_completed(tasks_file: Path, task_id: str) -> bool:
|
|
234
|
+
"""Check if a task is marked as completed in the tasks file.
|
|
235
|
+
|
|
236
|
+
Returns True if the checkbox is marked [x], False if [ ] or not found.
|
|
237
|
+
"""
|
|
238
|
+
if not tasks_file.exists():
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
content = tasks_file.read_text()
|
|
242
|
+
display_id = _task_id_to_display(task_id)
|
|
243
|
+
|
|
244
|
+
# Escape dots for regex
|
|
245
|
+
id_escaped = display_id.replace(".", r"\.")
|
|
246
|
+
|
|
247
|
+
# Check if task is marked completed: "- [x] N." or "- [x] N:"
|
|
248
|
+
if re.search(rf"^\s*- \[x\] {id_escaped}[.:]", content, re.MULTILINE):
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_prompts_status(
|
|
255
|
+
task_dir: str | Path, task_name: str | None = None
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
"""Get status of optimized prompts for a task.
|
|
258
|
+
|
|
259
|
+
Progress is tracked via checkboxes in the tasks.md file, not via status fields
|
|
260
|
+
in prompt files.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Dict with prompt status: {
|
|
264
|
+
exists: bool,
|
|
265
|
+
total: int,
|
|
266
|
+
completed: int, # Tasks marked [x] in tasks.md
|
|
267
|
+
remaining: int, # Tasks still [ ] in tasks.md
|
|
268
|
+
next_prompt: str | None, # Path to next prompt for uncompleted task
|
|
269
|
+
}
|
|
270
|
+
"""
|
|
271
|
+
task_dir = Path(task_dir)
|
|
272
|
+
prompts_dir = task_dir / "prompts"
|
|
273
|
+
|
|
274
|
+
if not prompts_dir.exists():
|
|
275
|
+
return {
|
|
276
|
+
"exists": False,
|
|
277
|
+
"total": 0,
|
|
278
|
+
"completed": 0,
|
|
279
|
+
"remaining": 0,
|
|
280
|
+
"next_prompt": None,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
prompt_files = list(prompts_dir.glob("task-*-prompt.md"))
|
|
284
|
+
|
|
285
|
+
if not prompt_files:
|
|
286
|
+
return {
|
|
287
|
+
"exists": True,
|
|
288
|
+
"total": 0,
|
|
289
|
+
"completed": 0,
|
|
290
|
+
"remaining": 0,
|
|
291
|
+
"next_prompt": None,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# Find tasks file
|
|
295
|
+
if task_name:
|
|
296
|
+
tasks_file = task_dir / f"{task_name}-tasks.md"
|
|
297
|
+
else:
|
|
298
|
+
# Try to infer from directory name
|
|
299
|
+
tasks_file = task_dir / f"{task_dir.name}-tasks.md"
|
|
300
|
+
|
|
301
|
+
completed = 0
|
|
302
|
+
remaining = 0
|
|
303
|
+
next_prompt = None
|
|
304
|
+
|
|
305
|
+
for pf in sorted(prompt_files):
|
|
306
|
+
content = pf.read_text()
|
|
307
|
+
|
|
308
|
+
# Extract task_id from YAML frontmatter
|
|
309
|
+
task_id_match = re.search(
|
|
310
|
+
r"^task_id:\s*[\"']?([^\"'\n]+)[\"']?", content, re.MULTILINE
|
|
311
|
+
)
|
|
312
|
+
if task_id_match:
|
|
313
|
+
task_id = task_id_match.group(1).strip()
|
|
314
|
+
|
|
315
|
+
if _is_task_completed(tasks_file, task_id):
|
|
316
|
+
completed += 1
|
|
317
|
+
else:
|
|
318
|
+
remaining += 1
|
|
319
|
+
if next_prompt is None:
|
|
320
|
+
next_prompt = str(pf)
|
|
321
|
+
else:
|
|
322
|
+
# No task_id found, count as remaining
|
|
323
|
+
remaining += 1
|
|
324
|
+
if next_prompt is None:
|
|
325
|
+
next_prompt = str(pf)
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
"exists": True,
|
|
329
|
+
"total": len(prompt_files),
|
|
330
|
+
"completed": completed,
|
|
331
|
+
"remaining": remaining,
|
|
332
|
+
"next_prompt": next_prompt,
|
|
333
|
+
}
|