missioncache-db 1.0.1__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.
- missioncache_db/__init__.py +3974 -0
- missioncache_db/__main__.py +6 -0
- missioncache_db-1.0.1.dist-info/METADATA +68 -0
- missioncache_db-1.0.1.dist-info/RECORD +7 -0
- missioncache_db-1.0.1.dist-info/WHEEL +4 -0
- missioncache_db-1.0.1.dist-info/entry_points.txt +2 -0
- missioncache_db-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,3974 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Task Database Manager for the MissionCache system on Claude Code.
|
|
4
|
+
|
|
5
|
+
Provides SQLite-based cross-repo task tracking with WakaTime-style time tracking.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python missioncache_db.py init # Initialize database
|
|
9
|
+
python missioncache_db.py add-repo <path> [name] # Add repository to track
|
|
10
|
+
python missioncache_db.py add-repos-glob <pattern> # Add repos from glob pattern
|
|
11
|
+
python missioncache_db.py scan [repo_id] # Scan repos for tasks
|
|
12
|
+
python missioncache_db.py list-active # List all active tasks
|
|
13
|
+
python missioncache_db.py list-repos # List tracked repositories
|
|
14
|
+
python missioncache_db.py get-task <task_id> # Get task details (JSON)
|
|
15
|
+
python missioncache_db.py heartbeat [task_id] # Record activity heartbeat
|
|
16
|
+
python missioncache_db.py heartbeat-auto # Auto-detect task from cwd
|
|
17
|
+
python missioncache_db.py process-heartbeats # Aggregate heartbeats into sessions
|
|
18
|
+
python missioncache_db.py task-time <task_id> [period] # Get time spent
|
|
19
|
+
python missioncache_db.py prune [days] # Prune old completed tasks
|
|
20
|
+
python missioncache_db.py complete-task <task_id> # Mark task as completed
|
|
21
|
+
python missioncache_db.py reopen-task <task_id> # Reopen a completed task
|
|
22
|
+
python missioncache_db.py rename-task <old-name> <new-name> # Rename a project
|
|
23
|
+
python missioncache_db.py list-completed [days] # List recently completed tasks
|
|
24
|
+
python missioncache_db.py get-task-by-name <name> # Find task by name
|
|
25
|
+
|
|
26
|
+
Keyword Management:
|
|
27
|
+
python missioncache_db.py add-keyword <keyword> # Add custom tag keyword
|
|
28
|
+
python missioncache_db.py remove-keyword <keyword> # Remove custom tag keyword
|
|
29
|
+
python missioncache_db.py list-keywords # List all tag keywords
|
|
30
|
+
python missioncache_db.py backfill-tags # Backfill tags for existing tasks
|
|
31
|
+
|
|
32
|
+
Non-Coding Task Management:
|
|
33
|
+
python missioncache_db.py create-task [--type TYPE] [--jira TICKET] <name> # Create task
|
|
34
|
+
python missioncache_db.py add-update <task_id> <note> # Add timestamped update
|
|
35
|
+
python missioncache_db.py get-updates <task_id> [limit] # Get task updates
|
|
36
|
+
python missioncache_db.py today-updates [task_id] # Get today's updates
|
|
37
|
+
|
|
38
|
+
Migration:
|
|
39
|
+
python missioncache_db.py migrate-orbit-docs [--dry-run] # Move docs to ~/.missioncache/
|
|
40
|
+
|
|
41
|
+
Cleanup:
|
|
42
|
+
python missioncache_db.py cleanup [--dry-run] # Archive orphans, resolve dupes, normalize paths
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import sqlite3
|
|
50
|
+
import sys
|
|
51
|
+
import time
|
|
52
|
+
from contextlib import contextmanager
|
|
53
|
+
from dataclasses import asdict, dataclass
|
|
54
|
+
from datetime import datetime, timedelta
|
|
55
|
+
from enum import Enum
|
|
56
|
+
from glob import glob as glob_files
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Configuration
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
DB_PATH = Path.home() / ".missioncache" / "tasks.db"
|
|
68
|
+
MISSIONCACHE_ROOT = Path.home() / ".missioncache"
|
|
69
|
+
|
|
70
|
+
# Shared session-state database used by the dashboard, statusline, and hooks.
|
|
71
|
+
# Multiple writers exist (dashboard's HTTP API, hooks at SessionStart, missioncache-db's
|
|
72
|
+
# rename sweep). The dashboard authors the schema, but every writer should call
|
|
73
|
+
# init_hooks_state_db_schema() to be tolerant of fresh installs where the
|
|
74
|
+
# dashboard never ran.
|
|
75
|
+
HOOKS_STATE_DB_PATH = Path.home() / ".claude" / "hooks-state.db"
|
|
76
|
+
|
|
77
|
+
# Legacy data locations the migration guard warns about, in two tiers:
|
|
78
|
+
# - the ancient pre-Phase-11 ~/.claude/ layout
|
|
79
|
+
# - the ~/.orbit/ layout (pre-MissionCache rename, superseded by ~/.missioncache/ in Task 71)
|
|
80
|
+
_LEGACY_CLAUDE_DB = Path.home() / ".claude" / "tasks.db"
|
|
81
|
+
_LEGACY_CLAUDE_ORBIT_ROOT = Path.home() / ".claude" / "orbit"
|
|
82
|
+
_LEGACY_ORBIT_DB = Path.home() / ".orbit" / "tasks.db"
|
|
83
|
+
_LEGACY_ORBIT_ROOT = Path.home() / ".orbit"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def atomic_write_json(path: Path, payload: object) -> None:
|
|
87
|
+
"""Write JSON to ``path`` via tmp+os.replace.
|
|
88
|
+
|
|
89
|
+
Used by hooks (cwd-session pointer, per-session project pointer) and by
|
|
90
|
+
statusline cache writes - any place where multiple processes can race on
|
|
91
|
+
the same file. ``write_text`` lets two writers observe a half-written
|
|
92
|
+
file; ``os.replace`` makes the live path atomically valid or untouched.
|
|
93
|
+
|
|
94
|
+
Tmp filename is suffixed with the writer's pid so concurrent writers do
|
|
95
|
+
not collide on the same tmp path. Pid is not stable across reboots or
|
|
96
|
+
PID-reuse, so this also sweeps any sibling ``<name>.tmp.*`` older than
|
|
97
|
+
one hour - that catches leftovers from prior crashes between
|
|
98
|
+
``write_text`` and ``os.replace`` without needing an external janitor.
|
|
99
|
+
|
|
100
|
+
Failures are silent (return on OSError) so a full disk or read-only
|
|
101
|
+
mount cannot break the caller's hot path. Programming errors that pass
|
|
102
|
+
a non-JSON-serializable payload are still raised loudly via TypeError -
|
|
103
|
+
that surface deliberately is not swallowed; silent type errors corrupt
|
|
104
|
+
cache files in ways the next reader cannot detect.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
# Best-effort sweep of leftover tmp files from prior crashes.
|
|
109
|
+
cutoff = time.time() - 3600
|
|
110
|
+
for stale in path.parent.glob(f"{path.name}.tmp.*"):
|
|
111
|
+
try:
|
|
112
|
+
if stale.stat().st_mtime < cutoff:
|
|
113
|
+
stale.unlink()
|
|
114
|
+
except OSError:
|
|
115
|
+
pass
|
|
116
|
+
tmp_path = path.parent / f"{path.name}.tmp.{os.getpid()}"
|
|
117
|
+
tmp_path.write_text(json.dumps(payload))
|
|
118
|
+
os.replace(tmp_path, path)
|
|
119
|
+
except OSError:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def init_hooks_state_db_schema(conn: sqlite3.Connection) -> None:
|
|
124
|
+
"""Idempotently create every table needed in hooks-state.db.
|
|
125
|
+
|
|
126
|
+
Safe to call from any writer. Hooks rely on this when the dashboard has
|
|
127
|
+
never started (fresh install): without it, the first INSERT into
|
|
128
|
+
project_state raises ``OperationalError: no such table`` and the bind
|
|
129
|
+
silently no-ops. Schema must stay in sync with the dashboard's
|
|
130
|
+
``_init_hooks_state_db`` (server.py); the dashboard delegates to this
|
|
131
|
+
function so they cannot drift.
|
|
132
|
+
"""
|
|
133
|
+
conn.executescript(
|
|
134
|
+
"""
|
|
135
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
136
|
+
session_id TEXT PRIMARY KEY,
|
|
137
|
+
context_percent INTEGER DEFAULT 0,
|
|
138
|
+
context_tokens TEXT DEFAULT '',
|
|
139
|
+
edit_count INTEGER DEFAULT 0,
|
|
140
|
+
qa_review_suggested INTEGER DEFAULT 0,
|
|
141
|
+
action TEXT,
|
|
142
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
143
|
+
);
|
|
144
|
+
CREATE TABLE IF NOT EXISTS project_state (
|
|
145
|
+
session_id TEXT PRIMARY KEY,
|
|
146
|
+
project_name TEXT NOT NULL,
|
|
147
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
148
|
+
);
|
|
149
|
+
CREATE TABLE IF NOT EXISTS term_sessions (
|
|
150
|
+
term_session_id TEXT PRIMARY KEY,
|
|
151
|
+
session_id TEXT NOT NULL,
|
|
152
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
153
|
+
);
|
|
154
|
+
CREATE TABLE IF NOT EXISTS guard_warned (
|
|
155
|
+
key TEXT PRIMARY KEY,
|
|
156
|
+
rule TEXT NOT NULL,
|
|
157
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
158
|
+
);
|
|
159
|
+
CREATE TABLE IF NOT EXISTS validation_state (
|
|
160
|
+
session_id TEXT PRIMARY KEY,
|
|
161
|
+
validated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
162
|
+
);
|
|
163
|
+
"""
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class MissionCacheMigrationRequired(RuntimeError):
|
|
168
|
+
"""Raised when MissionCache data is at a legacy path (~/.claude/orbit/ or
|
|
169
|
+
~/.orbit/) but the new ~/.missioncache/ DB doesn't exist. Caught by CLI entry
|
|
170
|
+
points to print a user-friendly migration message; subclasses RuntimeError so
|
|
171
|
+
existing `except Exception` handlers in hooks catch it cleanly (unlike
|
|
172
|
+
SystemExit which is BaseException and would escape `except Exception`)."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class RenameError(ValueError):
|
|
176
|
+
"""Base class for rename_task failures other than basic ValidationError.
|
|
177
|
+
|
|
178
|
+
Subclasses are catchable by name in callers (CLI, MCP tool, dashboard
|
|
179
|
+
endpoint) to surface specific user-facing messages.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class NameCollisionError(RenameError):
|
|
184
|
+
"""Another task in the same repo already has the target name."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class FilesystemCollisionError(RenameError):
|
|
188
|
+
"""The target MissionCache directory already exists on disk."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AutoRunActiveError(RenameError):
|
|
192
|
+
"""A missioncache-auto run is currently in progress on this task."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_TASK_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def validate_task_name(name: str) -> None:
|
|
199
|
+
"""Validate a project / task name for filesystem and DB safety.
|
|
200
|
+
|
|
201
|
+
Mirrors ``mcp_missioncache.orbit.validate_task_name`` exactly so the same
|
|
202
|
+
inputs are accepted/rejected on every surface (CLI, MCP, dashboard).
|
|
203
|
+
The check is split into three branches so each failure mode gets a
|
|
204
|
+
specific user-facing message.
|
|
205
|
+
"""
|
|
206
|
+
if not name:
|
|
207
|
+
raise ValueError("Project name cannot be empty.")
|
|
208
|
+
if name.startswith("-"):
|
|
209
|
+
raise ValueError(
|
|
210
|
+
"Project name must start with a letter or digit, not a hyphen."
|
|
211
|
+
)
|
|
212
|
+
if not _TASK_NAME_RE.match(name):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"Project name must use lowercase letters, digits, and hyphens "
|
|
215
|
+
"only (e.g., 'my-project')."
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def check_legacy_paths() -> None:
|
|
220
|
+
"""Raise MissionCacheMigrationRequired if MissionCache data exists at a legacy
|
|
221
|
+
path but not at the canonical ~/.missioncache/ path. Reads module-level
|
|
222
|
+
DB_PATH / _LEGACY_CLAUDE_DB / _LEGACY_CLAUDE_ORBIT_ROOT / _LEGACY_ORBIT_DB /
|
|
223
|
+
_LEGACY_ORBIT_ROOT at call time so tests can monkeypatch them.
|
|
224
|
+
|
|
225
|
+
Two legacy tiers are detected: the ancient ~/.claude/ layout and the ~/.orbit/
|
|
226
|
+
layout (superseded by ~/.missioncache/ in the MissionCache rename, Task 71).
|
|
227
|
+
|
|
228
|
+
Public API: missioncache-auto calls this directly (missioncache-db>=1.0.5) to warn
|
|
229
|
+
about unmigrated data without constructing a TaskDB."""
|
|
230
|
+
if DB_PATH.exists():
|
|
231
|
+
return
|
|
232
|
+
orbit_legacy = _LEGACY_ORBIT_DB.exists() or _LEGACY_ORBIT_ROOT.exists()
|
|
233
|
+
claude_legacy = _LEGACY_CLAUDE_DB.exists() or _LEGACY_CLAUDE_ORBIT_ROOT.exists()
|
|
234
|
+
if not (orbit_legacy or claude_legacy):
|
|
235
|
+
return
|
|
236
|
+
lines = [
|
|
237
|
+
"MissionCache data found at a legacy path but not at ~/.missioncache/.",
|
|
238
|
+
"Migrate before starting missioncache:",
|
|
239
|
+
" mkdir -p ~/.missioncache",
|
|
240
|
+
]
|
|
241
|
+
if orbit_legacy:
|
|
242
|
+
lines += [
|
|
243
|
+
" # data at ~/.orbit/ (pre-rename layout):",
|
|
244
|
+
" mv ~/.orbit/active ~/.missioncache/active 2>/dev/null",
|
|
245
|
+
" mv ~/.orbit/completed ~/.missioncache/completed 2>/dev/null",
|
|
246
|
+
" mv ~/.orbit/tasks.db* ~/.missioncache/ 2>/dev/null",
|
|
247
|
+
" mv ~/.orbit/tasks.duckdb* ~/.missioncache/ 2>/dev/null",
|
|
248
|
+
" rmdir ~/.orbit 2>/dev/null",
|
|
249
|
+
]
|
|
250
|
+
if claude_legacy:
|
|
251
|
+
lines += [
|
|
252
|
+
" # data at ~/.claude/orbit/ (ancient layout):",
|
|
253
|
+
" mv ~/.claude/orbit/active ~/.missioncache/active 2>/dev/null",
|
|
254
|
+
" mv ~/.claude/orbit/completed ~/.missioncache/completed 2>/dev/null",
|
|
255
|
+
" mv ~/.claude/tasks.db* ~/.missioncache/ 2>/dev/null",
|
|
256
|
+
" mv ~/.claude/tasks.duckdb* ~/.missioncache/ 2>/dev/null",
|
|
257
|
+
" rmdir ~/.claude/orbit 2>/dev/null",
|
|
258
|
+
]
|
|
259
|
+
raise MissionCacheMigrationRequired("\n".join(lines))
|
|
260
|
+
|
|
261
|
+
# Non-git folder to track with shadow repo (only this folder gets shadow commits)
|
|
262
|
+
SHADOW_TRACKED_FOLDER = Path(os.environ.get("MISSIONCACHE_SHADOW_FOLDER", str(Path.home() / "work")))
|
|
263
|
+
|
|
264
|
+
DEFAULT_CONFIG = {
|
|
265
|
+
"idle_timeout_seconds": 300, # 5 minutes
|
|
266
|
+
"assumed_work_seconds": 120, # 2 minutes
|
|
267
|
+
"prune_after_days": 30,
|
|
268
|
+
"auto_prune_on_startup": True,
|
|
269
|
+
"scan_on_startup": True,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# Default keywords for smart tagging
|
|
273
|
+
DEFAULT_TAG_KEYWORDS = {
|
|
274
|
+
# Infrastructure
|
|
275
|
+
"kafka",
|
|
276
|
+
"clickhouse",
|
|
277
|
+
"k8s",
|
|
278
|
+
"kubernetes",
|
|
279
|
+
"helm",
|
|
280
|
+
"docker",
|
|
281
|
+
"argo",
|
|
282
|
+
"s3",
|
|
283
|
+
"redis",
|
|
284
|
+
"postgres",
|
|
285
|
+
"prometheus",
|
|
286
|
+
"grafana",
|
|
287
|
+
"argocd",
|
|
288
|
+
"mongo",
|
|
289
|
+
"mysql",
|
|
290
|
+
"nginx",
|
|
291
|
+
"envoy",
|
|
292
|
+
"istio",
|
|
293
|
+
"vault",
|
|
294
|
+
# Security & Config
|
|
295
|
+
"auth",
|
|
296
|
+
"secrets",
|
|
297
|
+
"tls",
|
|
298
|
+
"ssl",
|
|
299
|
+
"creds",
|
|
300
|
+
"credentials",
|
|
301
|
+
"token",
|
|
302
|
+
"oauth",
|
|
303
|
+
"jwt",
|
|
304
|
+
"rbac",
|
|
305
|
+
"iam",
|
|
306
|
+
# DevOps & CI/CD
|
|
307
|
+
"ci",
|
|
308
|
+
"cd",
|
|
309
|
+
"cicd",
|
|
310
|
+
"deploy",
|
|
311
|
+
"build",
|
|
312
|
+
"test",
|
|
313
|
+
"pipeline",
|
|
314
|
+
"release",
|
|
315
|
+
"jenkins",
|
|
316
|
+
"github",
|
|
317
|
+
"gitlab",
|
|
318
|
+
"actions",
|
|
319
|
+
"workflow",
|
|
320
|
+
# Actions
|
|
321
|
+
"fix",
|
|
322
|
+
"refactor",
|
|
323
|
+
"migrate",
|
|
324
|
+
"upgrade",
|
|
325
|
+
"optimize",
|
|
326
|
+
"cleanup",
|
|
327
|
+
"debug",
|
|
328
|
+
"hotfix",
|
|
329
|
+
"patch",
|
|
330
|
+
"update",
|
|
331
|
+
"improve",
|
|
332
|
+
# Scrum Master tasks
|
|
333
|
+
"sprint",
|
|
334
|
+
"standup",
|
|
335
|
+
"retro",
|
|
336
|
+
"retrospective",
|
|
337
|
+
"planning",
|
|
338
|
+
"grooming",
|
|
339
|
+
"backlog",
|
|
340
|
+
"refinement",
|
|
341
|
+
"velocity",
|
|
342
|
+
"burndown",
|
|
343
|
+
"scrum",
|
|
344
|
+
"agile",
|
|
345
|
+
"blocker",
|
|
346
|
+
"impediment",
|
|
347
|
+
"ceremony",
|
|
348
|
+
"demo",
|
|
349
|
+
"review",
|
|
350
|
+
"stakeholder",
|
|
351
|
+
"epic",
|
|
352
|
+
"story",
|
|
353
|
+
"jira",
|
|
354
|
+
"kanban",
|
|
355
|
+
# AI Lead tasks
|
|
356
|
+
"ai",
|
|
357
|
+
"ml",
|
|
358
|
+
"llm",
|
|
359
|
+
"prompt",
|
|
360
|
+
"model",
|
|
361
|
+
"training",
|
|
362
|
+
"inference",
|
|
363
|
+
"claude",
|
|
364
|
+
"gpt",
|
|
365
|
+
"embedding",
|
|
366
|
+
"rag",
|
|
367
|
+
"agent",
|
|
368
|
+
"mcp",
|
|
369
|
+
"anthropic",
|
|
370
|
+
"openai",
|
|
371
|
+
"finetune",
|
|
372
|
+
"evaluation",
|
|
373
|
+
"benchmark",
|
|
374
|
+
"transformer",
|
|
375
|
+
# General
|
|
376
|
+
"api",
|
|
377
|
+
"web",
|
|
378
|
+
"frontend",
|
|
379
|
+
"backend",
|
|
380
|
+
"service",
|
|
381
|
+
"microservice",
|
|
382
|
+
"gateway",
|
|
383
|
+
"proxy",
|
|
384
|
+
"cache",
|
|
385
|
+
"queue",
|
|
386
|
+
"log",
|
|
387
|
+
"monitor",
|
|
388
|
+
"alert",
|
|
389
|
+
"doc",
|
|
390
|
+
"docs",
|
|
391
|
+
"documentation",
|
|
392
|
+
"readme",
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def extract_tags(task_name: str) -> List[str]:
|
|
397
|
+
"""Extract tags from task name using keyword matching.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
task_name: The name of the task (e.g., "kafka-plaintext-secrets")
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
List of matched tags sorted alphabetically
|
|
404
|
+
"""
|
|
405
|
+
tags = set()
|
|
406
|
+
name_lower = task_name.lower()
|
|
407
|
+
|
|
408
|
+
# Split on hyphens, underscores, and spaces
|
|
409
|
+
parts = re.split(r"[-_\s]+", name_lower)
|
|
410
|
+
|
|
411
|
+
# Get merged keywords (default + custom)
|
|
412
|
+
keywords = get_tag_keywords()
|
|
413
|
+
|
|
414
|
+
for part in parts:
|
|
415
|
+
# Direct match
|
|
416
|
+
if part in keywords:
|
|
417
|
+
tags.add(part)
|
|
418
|
+
# Check for partial matches in compound words
|
|
419
|
+
for keyword in keywords:
|
|
420
|
+
if len(keyword) > 2 and keyword in part:
|
|
421
|
+
tags.add(keyword)
|
|
422
|
+
|
|
423
|
+
return sorted(list(tags))
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_tag_keywords() -> set:
|
|
427
|
+
"""Get merged tag keywords (default + custom from config).
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
Set of all keywords (default + user-configured)
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
db = TaskDB()
|
|
434
|
+
custom_json = db.get_config("custom_tag_keywords", "[]")
|
|
435
|
+
custom = set(json.loads(custom_json))
|
|
436
|
+
except Exception:
|
|
437
|
+
custom = set()
|
|
438
|
+
|
|
439
|
+
return DEFAULT_TAG_KEYWORDS | custom
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# =============================================================================
|
|
443
|
+
# Schema
|
|
444
|
+
# =============================================================================
|
|
445
|
+
|
|
446
|
+
SCHEMA_SQL = """
|
|
447
|
+
-- Repositories for cross-repo tracking
|
|
448
|
+
CREATE TABLE IF NOT EXISTS repositories (
|
|
449
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
450
|
+
path TEXT UNIQUE NOT NULL,
|
|
451
|
+
short_name TEXT NOT NULL,
|
|
452
|
+
glob_pattern TEXT,
|
|
453
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
454
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
455
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
456
|
+
last_scanned_at TEXT
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
-- Core task tracking
|
|
460
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
461
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
462
|
+
repo_id INTEGER REFERENCES repositories(id) ON DELETE CASCADE,
|
|
463
|
+
name TEXT NOT NULL,
|
|
464
|
+
full_path TEXT NOT NULL,
|
|
465
|
+
parent_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
|
|
466
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
467
|
+
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
|
|
468
|
+
type TEXT NOT NULL DEFAULT 'coding'
|
|
469
|
+
CHECK (type IN ('coding', 'non-coding')),
|
|
470
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
471
|
+
priority INTEGER,
|
|
472
|
+
jira_key TEXT,
|
|
473
|
+
branch TEXT,
|
|
474
|
+
pr_url TEXT,
|
|
475
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
476
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
477
|
+
completed_at TEXT,
|
|
478
|
+
archived_at TEXT,
|
|
479
|
+
last_worked_on TEXT,
|
|
480
|
+
UNIQUE(repo_id, full_path)
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
-- Task updates for non-coding task progress notes
|
|
484
|
+
CREATE TABLE IF NOT EXISTS task_updates (
|
|
485
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
486
|
+
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
487
|
+
note TEXT NOT NULL,
|
|
488
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
-- WakaTime-style heartbeats
|
|
492
|
+
CREATE TABLE IF NOT EXISTS heartbeats (
|
|
493
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
494
|
+
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
495
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
496
|
+
session_id TEXT,
|
|
497
|
+
context TEXT,
|
|
498
|
+
processed INTEGER NOT NULL DEFAULT 0
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
-- Aggregated work sessions
|
|
502
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
503
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
504
|
+
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
505
|
+
session_id TEXT,
|
|
506
|
+
start_time TEXT NOT NULL,
|
|
507
|
+
end_time TEXT,
|
|
508
|
+
duration_seconds INTEGER NOT NULL DEFAULT 0,
|
|
509
|
+
heartbeat_count INTEGER NOT NULL DEFAULT 0
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
-- Configuration
|
|
513
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
514
|
+
key TEXT PRIMARY KEY,
|
|
515
|
+
value TEXT NOT NULL,
|
|
516
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
-- Indexes for performance
|
|
520
|
+
CREATE INDEX IF NOT EXISTS idx_repos_active ON repositories(active);
|
|
521
|
+
CREATE INDEX IF NOT EXISTS idx_repos_path ON repositories(path);
|
|
522
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
523
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_repo_status ON tasks(repo_id, status);
|
|
524
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_last_worked ON tasks(last_worked_on DESC);
|
|
525
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
|
526
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_type ON tasks(type);
|
|
527
|
+
CREATE INDEX IF NOT EXISTS idx_updates_task ON task_updates(task_id);
|
|
528
|
+
CREATE INDEX IF NOT EXISTS idx_updates_created ON task_updates(created_at);
|
|
529
|
+
CREATE INDEX IF NOT EXISTS idx_heartbeats_task_time ON heartbeats(task_id, timestamp);
|
|
530
|
+
CREATE INDEX IF NOT EXISTS idx_heartbeats_unprocessed ON heartbeats(processed, timestamp);
|
|
531
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_task_time ON sessions(task_id, start_time);
|
|
532
|
+
|
|
533
|
+
-- Triggers for automatic timestamp updates
|
|
534
|
+
CREATE TRIGGER IF NOT EXISTS trg_repos_updated
|
|
535
|
+
AFTER UPDATE ON repositories
|
|
536
|
+
BEGIN
|
|
537
|
+
UPDATE repositories SET updated_at = datetime('now', 'localtime') WHERE id = NEW.id;
|
|
538
|
+
END;
|
|
539
|
+
|
|
540
|
+
CREATE TRIGGER IF NOT EXISTS trg_tasks_updated
|
|
541
|
+
AFTER UPDATE ON tasks
|
|
542
|
+
BEGIN
|
|
543
|
+
UPDATE tasks SET updated_at = datetime('now', 'localtime') WHERE id = NEW.id;
|
|
544
|
+
END;
|
|
545
|
+
|
|
546
|
+
CREATE TRIGGER IF NOT EXISTS trg_tasks_completed
|
|
547
|
+
AFTER UPDATE OF status ON tasks
|
|
548
|
+
WHEN NEW.status = 'completed' AND OLD.status != 'completed'
|
|
549
|
+
BEGIN
|
|
550
|
+
UPDATE tasks SET completed_at = datetime('now', 'localtime') WHERE id = NEW.id;
|
|
551
|
+
END;
|
|
552
|
+
|
|
553
|
+
CREATE TRIGGER IF NOT EXISTS trg_tasks_archived
|
|
554
|
+
AFTER UPDATE OF status ON tasks
|
|
555
|
+
WHEN NEW.status = 'archived' AND OLD.status != 'archived'
|
|
556
|
+
BEGIN
|
|
557
|
+
UPDATE tasks SET archived_at = datetime('now', 'localtime') WHERE id = NEW.id;
|
|
558
|
+
END;
|
|
559
|
+
|
|
560
|
+
-- Auto execution runs (missioncache-auto)
|
|
561
|
+
CREATE TABLE IF NOT EXISTS auto_executions (
|
|
562
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
563
|
+
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
564
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
565
|
+
completed_at TEXT,
|
|
566
|
+
status TEXT NOT NULL DEFAULT 'running'
|
|
567
|
+
CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
|
568
|
+
mode TEXT NOT NULL DEFAULT 'parallel'
|
|
569
|
+
CHECK (mode IN ('sequential', 'parallel')),
|
|
570
|
+
worker_count INTEGER,
|
|
571
|
+
total_subtasks INTEGER NOT NULL DEFAULT 0,
|
|
572
|
+
completed_subtasks INTEGER NOT NULL DEFAULT 0,
|
|
573
|
+
failed_subtasks INTEGER NOT NULL DEFAULT 0,
|
|
574
|
+
error_message TEXT
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
-- Auto execution log lines (for streaming)
|
|
578
|
+
CREATE TABLE IF NOT EXISTS auto_execution_logs (
|
|
579
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
580
|
+
execution_id INTEGER NOT NULL REFERENCES auto_executions(id) ON DELETE CASCADE,
|
|
581
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
|
582
|
+
worker_id INTEGER,
|
|
583
|
+
subtask_id TEXT,
|
|
584
|
+
level TEXT NOT NULL DEFAULT 'info'
|
|
585
|
+
CHECK (level IN ('debug', 'info', 'warn', 'error', 'success')),
|
|
586
|
+
message TEXT NOT NULL
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
-- Indexes for auto execution tables
|
|
590
|
+
CREATE INDEX IF NOT EXISTS idx_auto_executions_task ON auto_executions(task_id);
|
|
591
|
+
CREATE INDEX IF NOT EXISTS idx_auto_executions_status ON auto_executions(status);
|
|
592
|
+
CREATE INDEX IF NOT EXISTS idx_auto_execution_logs_exec ON auto_execution_logs(execution_id);
|
|
593
|
+
CREATE INDEX IF NOT EXISTS idx_auto_execution_logs_time ON auto_execution_logs(execution_id, timestamp);
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# =============================================================================
|
|
598
|
+
# Data Classes
|
|
599
|
+
# =============================================================================
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class TaskStatus(Enum):
|
|
603
|
+
ACTIVE = "active"
|
|
604
|
+
PAUSED = "paused"
|
|
605
|
+
COMPLETED = "completed"
|
|
606
|
+
ARCHIVED = "archived"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@dataclass
|
|
610
|
+
class Repository:
|
|
611
|
+
id: int
|
|
612
|
+
path: str
|
|
613
|
+
short_name: str
|
|
614
|
+
glob_pattern: Optional[str]
|
|
615
|
+
active: bool
|
|
616
|
+
created_at: str
|
|
617
|
+
updated_at: str
|
|
618
|
+
last_scanned_at: Optional[str]
|
|
619
|
+
|
|
620
|
+
@classmethod
|
|
621
|
+
def from_row(cls, row: sqlite3.Row) -> "Repository":
|
|
622
|
+
return cls(
|
|
623
|
+
id=row["id"],
|
|
624
|
+
path=row["path"],
|
|
625
|
+
short_name=row["short_name"],
|
|
626
|
+
glob_pattern=row["glob_pattern"],
|
|
627
|
+
active=bool(row["active"]),
|
|
628
|
+
created_at=row["created_at"],
|
|
629
|
+
updated_at=row["updated_at"],
|
|
630
|
+
last_scanned_at=row["last_scanned_at"],
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@dataclass
|
|
635
|
+
class Task:
|
|
636
|
+
id: int
|
|
637
|
+
repo_id: Optional[int] # Nullable for non-coding tasks
|
|
638
|
+
name: str
|
|
639
|
+
full_path: str
|
|
640
|
+
parent_id: Optional[int]
|
|
641
|
+
status: str
|
|
642
|
+
task_type: str # 'coding' or 'non-coding'
|
|
643
|
+
tags: List[str] # Auto-generated tags from task name
|
|
644
|
+
priority: Optional[int]
|
|
645
|
+
jira_key: Optional[str]
|
|
646
|
+
branch: Optional[str]
|
|
647
|
+
pr_url: Optional[str]
|
|
648
|
+
created_at: str
|
|
649
|
+
updated_at: str
|
|
650
|
+
completed_at: Optional[str]
|
|
651
|
+
archived_at: Optional[str]
|
|
652
|
+
last_worked_on: Optional[str]
|
|
653
|
+
|
|
654
|
+
@classmethod
|
|
655
|
+
def from_row(cls, row: sqlite3.Row) -> "Task":
|
|
656
|
+
# Parse tags from JSON string
|
|
657
|
+
tags_raw = row["tags"] if "tags" in row.keys() else "[]"
|
|
658
|
+
try:
|
|
659
|
+
tags = json.loads(tags_raw) if tags_raw else []
|
|
660
|
+
except (json.JSONDecodeError, TypeError):
|
|
661
|
+
tags = []
|
|
662
|
+
|
|
663
|
+
return cls(
|
|
664
|
+
id=row["id"],
|
|
665
|
+
repo_id=row["repo_id"],
|
|
666
|
+
name=row["name"],
|
|
667
|
+
full_path=row["full_path"],
|
|
668
|
+
parent_id=row["parent_id"],
|
|
669
|
+
status=row["status"],
|
|
670
|
+
task_type=row["type"] if "type" in row.keys() else "coding",
|
|
671
|
+
tags=tags,
|
|
672
|
+
priority=row["priority"],
|
|
673
|
+
jira_key=row["jira_key"],
|
|
674
|
+
branch=row["branch"],
|
|
675
|
+
pr_url=row["pr_url"],
|
|
676
|
+
created_at=row["created_at"],
|
|
677
|
+
updated_at=row["updated_at"],
|
|
678
|
+
completed_at=row["completed_at"],
|
|
679
|
+
archived_at=row["archived_at"],
|
|
680
|
+
last_worked_on=row["last_worked_on"],
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass
|
|
685
|
+
class Session:
|
|
686
|
+
id: int
|
|
687
|
+
task_id: int
|
|
688
|
+
session_id: Optional[str]
|
|
689
|
+
start_time: str
|
|
690
|
+
end_time: Optional[str]
|
|
691
|
+
duration_seconds: int
|
|
692
|
+
heartbeat_count: int
|
|
693
|
+
|
|
694
|
+
@classmethod
|
|
695
|
+
def from_row(cls, row: sqlite3.Row) -> "Session":
|
|
696
|
+
return cls(
|
|
697
|
+
id=row["id"],
|
|
698
|
+
task_id=row["task_id"],
|
|
699
|
+
session_id=row["session_id"],
|
|
700
|
+
start_time=row["start_time"],
|
|
701
|
+
end_time=row["end_time"],
|
|
702
|
+
duration_seconds=row["duration_seconds"],
|
|
703
|
+
heartbeat_count=row["heartbeat_count"],
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@dataclass
|
|
708
|
+
class AutoExecution:
|
|
709
|
+
"""A missioncache-auto execution run."""
|
|
710
|
+
|
|
711
|
+
id: int
|
|
712
|
+
task_id: int
|
|
713
|
+
started_at: str
|
|
714
|
+
completed_at: Optional[str]
|
|
715
|
+
status: str # 'running', 'completed', 'failed', 'cancelled'
|
|
716
|
+
mode: str # 'sequential', 'parallel'
|
|
717
|
+
worker_count: Optional[int]
|
|
718
|
+
total_subtasks: int
|
|
719
|
+
completed_subtasks: int
|
|
720
|
+
failed_subtasks: int
|
|
721
|
+
error_message: Optional[str]
|
|
722
|
+
|
|
723
|
+
@classmethod
|
|
724
|
+
def from_row(cls, row: sqlite3.Row) -> "AutoExecution":
|
|
725
|
+
return cls(
|
|
726
|
+
id=row["id"],
|
|
727
|
+
task_id=row["task_id"],
|
|
728
|
+
started_at=row["started_at"],
|
|
729
|
+
completed_at=row["completed_at"],
|
|
730
|
+
status=row["status"],
|
|
731
|
+
mode=row["mode"],
|
|
732
|
+
worker_count=row["worker_count"],
|
|
733
|
+
total_subtasks=row["total_subtasks"],
|
|
734
|
+
completed_subtasks=row["completed_subtasks"],
|
|
735
|
+
failed_subtasks=row["failed_subtasks"],
|
|
736
|
+
error_message=row["error_message"],
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@dataclass
|
|
741
|
+
class AutoExecutionLog:
|
|
742
|
+
"""A log entry from a missioncache-auto execution."""
|
|
743
|
+
|
|
744
|
+
id: int
|
|
745
|
+
execution_id: int
|
|
746
|
+
timestamp: str
|
|
747
|
+
worker_id: Optional[int]
|
|
748
|
+
subtask_id: Optional[str]
|
|
749
|
+
level: str # 'debug', 'info', 'warn', 'error', 'success'
|
|
750
|
+
message: str
|
|
751
|
+
|
|
752
|
+
@classmethod
|
|
753
|
+
def from_row(cls, row: sqlite3.Row) -> "AutoExecutionLog":
|
|
754
|
+
return cls(
|
|
755
|
+
id=row["id"],
|
|
756
|
+
execution_id=row["execution_id"],
|
|
757
|
+
timestamp=row["timestamp"],
|
|
758
|
+
worker_id=row["worker_id"],
|
|
759
|
+
subtask_id=row["subtask_id"],
|
|
760
|
+
level=row["level"],
|
|
761
|
+
message=row["message"],
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
# =============================================================================
|
|
766
|
+
# Database Manager
|
|
767
|
+
# =============================================================================
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class TaskDB:
|
|
771
|
+
"""SQLite-based task management database."""
|
|
772
|
+
|
|
773
|
+
def __init__(self, db_path: Optional[Path] = None):
|
|
774
|
+
self.db_path = db_path or DB_PATH
|
|
775
|
+
self._connection: Optional[sqlite3.Connection] = None
|
|
776
|
+
# Guard against using MissionCache while data still lives at legacy paths.
|
|
777
|
+
# Raises MissionCacheMigrationRequired (RuntimeError subclass) so callers
|
|
778
|
+
# using `except Exception` catch it normally; CLI entry points pretty-print.
|
|
779
|
+
# Note: the guard inspects module-level DB_PATH (the canonical path),
|
|
780
|
+
# not self.db_path. Callers passing a custom db_path still get the
|
|
781
|
+
# check against the user's primary install location - intentional, so
|
|
782
|
+
# alternate-path TaskDB usage doesn't bypass the migration prompt.
|
|
783
|
+
check_legacy_paths()
|
|
784
|
+
|
|
785
|
+
@contextmanager
|
|
786
|
+
def connection(self) -> Iterator[sqlite3.Connection]:
|
|
787
|
+
"""Context manager for database connection."""
|
|
788
|
+
if self._connection is None:
|
|
789
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
790
|
+
self._connection = sqlite3.connect(
|
|
791
|
+
str(self.db_path), detect_types=sqlite3.PARSE_DECLTYPES
|
|
792
|
+
)
|
|
793
|
+
self._connection.row_factory = sqlite3.Row
|
|
794
|
+
self._connection.execute("PRAGMA foreign_keys = ON")
|
|
795
|
+
self._connection.execute("PRAGMA journal_mode = WAL")
|
|
796
|
+
self._connection.execute("PRAGMA busy_timeout = 5000")
|
|
797
|
+
# Auto-init schema on first open. SCHEMA_SQL is fully idempotent
|
|
798
|
+
# (28 CREATE ... IF NOT EXISTS clauses), so this is safe for both
|
|
799
|
+
# fresh and existing DBs. Without this, the bare `missioncache-db` CLI
|
|
800
|
+
# and any other first-time caller would crash on "no such table"
|
|
801
|
+
# errors because __init__ only ever created an empty DB file.
|
|
802
|
+
self._connection.executescript(SCHEMA_SQL)
|
|
803
|
+
for key, value in DEFAULT_CONFIG.items():
|
|
804
|
+
self._connection.execute(
|
|
805
|
+
"INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)",
|
|
806
|
+
(key, json.dumps(value)),
|
|
807
|
+
)
|
|
808
|
+
self._connection.commit()
|
|
809
|
+
try:
|
|
810
|
+
yield self._connection
|
|
811
|
+
finally:
|
|
812
|
+
pass # Keep connection open for reuse
|
|
813
|
+
|
|
814
|
+
def close(self):
|
|
815
|
+
"""Close the database connection."""
|
|
816
|
+
if self._connection:
|
|
817
|
+
self._connection.close()
|
|
818
|
+
self._connection = None
|
|
819
|
+
|
|
820
|
+
def initialize(self) -> None:
|
|
821
|
+
"""Initialize the database schema and default config."""
|
|
822
|
+
with self.connection() as conn:
|
|
823
|
+
conn.executescript(SCHEMA_SQL)
|
|
824
|
+
|
|
825
|
+
# Insert default config
|
|
826
|
+
for key, value in DEFAULT_CONFIG.items():
|
|
827
|
+
conn.execute(
|
|
828
|
+
"""INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)""",
|
|
829
|
+
(key, json.dumps(value)),
|
|
830
|
+
)
|
|
831
|
+
conn.commit()
|
|
832
|
+
|
|
833
|
+
# =========================================================================
|
|
834
|
+
# Configuration
|
|
835
|
+
# =========================================================================
|
|
836
|
+
|
|
837
|
+
def get_config(self, key: str, default: Any = None) -> Any:
|
|
838
|
+
"""Get a configuration value."""
|
|
839
|
+
with self.connection() as conn:
|
|
840
|
+
row = conn.execute(
|
|
841
|
+
"SELECT value FROM config WHERE key = ?", (key,)
|
|
842
|
+
).fetchone()
|
|
843
|
+
if row:
|
|
844
|
+
return json.loads(row["value"])
|
|
845
|
+
return default
|
|
846
|
+
|
|
847
|
+
def set_config(self, key: str, value: Any) -> None:
|
|
848
|
+
"""Set a configuration value."""
|
|
849
|
+
with self.connection() as conn:
|
|
850
|
+
conn.execute(
|
|
851
|
+
"""INSERT INTO config (key, value) VALUES (?, ?)
|
|
852
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value,
|
|
853
|
+
updated_at = datetime('now', 'localtime')""",
|
|
854
|
+
(key, json.dumps(value)),
|
|
855
|
+
)
|
|
856
|
+
conn.commit()
|
|
857
|
+
|
|
858
|
+
@property
|
|
859
|
+
def idle_timeout_seconds(self) -> int:
|
|
860
|
+
return self.get_config("idle_timeout_seconds", 300)
|
|
861
|
+
|
|
862
|
+
@property
|
|
863
|
+
def assumed_work_seconds(self) -> int:
|
|
864
|
+
return self.get_config("assumed_work_seconds", 120)
|
|
865
|
+
|
|
866
|
+
@property
|
|
867
|
+
def prune_after_days(self) -> int:
|
|
868
|
+
return self.get_config("prune_after_days", 30)
|
|
869
|
+
|
|
870
|
+
# =========================================================================
|
|
871
|
+
# Keyword Management
|
|
872
|
+
# =========================================================================
|
|
873
|
+
|
|
874
|
+
def add_keyword(self, keyword: str) -> bool:
|
|
875
|
+
"""Add a custom tag keyword.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
keyword: The keyword to add (lowercase)
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
True if added, False if already exists
|
|
882
|
+
"""
|
|
883
|
+
keyword = keyword.lower().strip()
|
|
884
|
+
if not keyword:
|
|
885
|
+
return False
|
|
886
|
+
|
|
887
|
+
custom = self.get_config("custom_tag_keywords", [])
|
|
888
|
+
if keyword in custom or keyword in DEFAULT_TAG_KEYWORDS:
|
|
889
|
+
return False
|
|
890
|
+
|
|
891
|
+
custom.append(keyword)
|
|
892
|
+
self.set_config("custom_tag_keywords", custom)
|
|
893
|
+
return True
|
|
894
|
+
|
|
895
|
+
def remove_keyword(self, keyword: str) -> bool:
|
|
896
|
+
"""Remove a custom tag keyword.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
keyword: The keyword to remove
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
True if removed, False if not found
|
|
903
|
+
"""
|
|
904
|
+
keyword = keyword.lower().strip()
|
|
905
|
+
custom = self.get_config("custom_tag_keywords", [])
|
|
906
|
+
|
|
907
|
+
if keyword not in custom:
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
custom.remove(keyword)
|
|
911
|
+
self.set_config("custom_tag_keywords", custom)
|
|
912
|
+
return True
|
|
913
|
+
|
|
914
|
+
def list_keywords(self) -> Dict[str, List[str]]:
|
|
915
|
+
"""List all tag keywords (default + custom).
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
Dict with 'default' and 'custom' keyword lists
|
|
919
|
+
"""
|
|
920
|
+
custom = self.get_config("custom_tag_keywords", [])
|
|
921
|
+
return {
|
|
922
|
+
"default": sorted(list(DEFAULT_TAG_KEYWORDS)),
|
|
923
|
+
"custom": sorted(custom),
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
# =========================================================================
|
|
927
|
+
# Repository Management
|
|
928
|
+
# =========================================================================
|
|
929
|
+
|
|
930
|
+
def add_repo(
|
|
931
|
+
self,
|
|
932
|
+
path: Union[str, Path],
|
|
933
|
+
short_name: Optional[str] = None,
|
|
934
|
+
glob_pattern: Optional[str] = None,
|
|
935
|
+
) -> int:
|
|
936
|
+
"""Add a repository to track."""
|
|
937
|
+
path_obj = Path(path).expanduser().resolve()
|
|
938
|
+
path_str = str(path_obj)
|
|
939
|
+
|
|
940
|
+
if short_name is None:
|
|
941
|
+
short_name = path_obj.name
|
|
942
|
+
|
|
943
|
+
with self.connection() as conn:
|
|
944
|
+
try:
|
|
945
|
+
cursor = conn.execute(
|
|
946
|
+
"""INSERT INTO repositories (path, short_name, glob_pattern)
|
|
947
|
+
VALUES (?, ?, ?)""",
|
|
948
|
+
(path_str, short_name, glob_pattern),
|
|
949
|
+
)
|
|
950
|
+
conn.commit()
|
|
951
|
+
return cursor.lastrowid
|
|
952
|
+
except sqlite3.IntegrityError:
|
|
953
|
+
# Already exists
|
|
954
|
+
row = conn.execute(
|
|
955
|
+
"SELECT id FROM repositories WHERE path = ?", (path_str,)
|
|
956
|
+
).fetchone()
|
|
957
|
+
return row["id"]
|
|
958
|
+
|
|
959
|
+
def add_repos_from_glob(self, pattern: str) -> List[int]:
|
|
960
|
+
"""Add multiple repos from a glob pattern."""
|
|
961
|
+
expanded = str(Path(pattern).expanduser())
|
|
962
|
+
paths = glob_files(expanded)
|
|
963
|
+
repo_ids = []
|
|
964
|
+
for p in paths:
|
|
965
|
+
path_obj = Path(p)
|
|
966
|
+
if path_obj.is_dir() and not path_obj.name.startswith("."):
|
|
967
|
+
repo_id = self.add_repo(p, glob_pattern=pattern)
|
|
968
|
+
repo_ids.append(repo_id)
|
|
969
|
+
return repo_ids
|
|
970
|
+
|
|
971
|
+
def get_repos(self, active_only: bool = True) -> List[Repository]:
|
|
972
|
+
"""Get all tracked repositories."""
|
|
973
|
+
with self.connection() as conn:
|
|
974
|
+
query = "SELECT * FROM repositories"
|
|
975
|
+
if active_only:
|
|
976
|
+
query += " WHERE active = 1"
|
|
977
|
+
query += " ORDER BY short_name"
|
|
978
|
+
rows = conn.execute(query).fetchall()
|
|
979
|
+
return [Repository.from_row(r) for r in rows]
|
|
980
|
+
|
|
981
|
+
def get_repo(self, repo_id: int) -> Optional[Repository]:
|
|
982
|
+
"""Get a specific repository."""
|
|
983
|
+
with self.connection() as conn:
|
|
984
|
+
row = conn.execute(
|
|
985
|
+
"SELECT * FROM repositories WHERE id = ?", (repo_id,)
|
|
986
|
+
).fetchone()
|
|
987
|
+
return Repository.from_row(row) if row else None
|
|
988
|
+
|
|
989
|
+
def get_repo_by_path(self, path: Union[str, Path]) -> Optional[Repository]:
|
|
990
|
+
"""Get a repository by its path."""
|
|
991
|
+
path_str = str(Path(path).expanduser().resolve())
|
|
992
|
+
with self.connection() as conn:
|
|
993
|
+
row = conn.execute(
|
|
994
|
+
"SELECT * FROM repositories WHERE path = ?", (path_str,)
|
|
995
|
+
).fetchone()
|
|
996
|
+
return Repository.from_row(row) if row else None
|
|
997
|
+
|
|
998
|
+
# =========================================================================
|
|
999
|
+
# Task Discovery & Sync
|
|
1000
|
+
# =========================================================================
|
|
1001
|
+
|
|
1002
|
+
def scan_repo(self, repo_id: int) -> List[Task]:
|
|
1003
|
+
"""Scan centralized MissionCache root for tasks and sync with database."""
|
|
1004
|
+
repo = self.get_repo(repo_id)
|
|
1005
|
+
if not repo:
|
|
1006
|
+
return []
|
|
1007
|
+
|
|
1008
|
+
discovered_tasks = []
|
|
1009
|
+
|
|
1010
|
+
# Scan active tasks from centralized MissionCache root
|
|
1011
|
+
active_dir = MISSIONCACHE_ROOT / "active"
|
|
1012
|
+
if active_dir.exists():
|
|
1013
|
+
for task_dir in active_dir.iterdir():
|
|
1014
|
+
if task_dir.is_dir() and not task_dir.name.startswith("."):
|
|
1015
|
+
task = self._sync_task_from_dir(repo_id, task_dir, "active")
|
|
1016
|
+
if task:
|
|
1017
|
+
discovered_tasks.append(task)
|
|
1018
|
+
# Check for subtasks
|
|
1019
|
+
for subtask_dir in task_dir.iterdir():
|
|
1020
|
+
if subtask_dir.is_dir() and not subtask_dir.name.startswith(
|
|
1021
|
+
"."
|
|
1022
|
+
):
|
|
1023
|
+
# Check if it's a subtask (has context.md or tasks.md)
|
|
1024
|
+
if (
|
|
1025
|
+
(subtask_dir / "context.md").exists()
|
|
1026
|
+
or (
|
|
1027
|
+
subtask_dir / f"{subtask_dir.name}-context.md"
|
|
1028
|
+
).exists()
|
|
1029
|
+
or (subtask_dir / "tasks.md").exists()
|
|
1030
|
+
):
|
|
1031
|
+
subtask = self._sync_task_from_dir(
|
|
1032
|
+
repo_id,
|
|
1033
|
+
subtask_dir,
|
|
1034
|
+
"active",
|
|
1035
|
+
parent_id=task.id,
|
|
1036
|
+
)
|
|
1037
|
+
if subtask:
|
|
1038
|
+
discovered_tasks.append(subtask)
|
|
1039
|
+
|
|
1040
|
+
# Update last scanned timestamp
|
|
1041
|
+
with self.connection() as conn:
|
|
1042
|
+
conn.execute(
|
|
1043
|
+
"UPDATE repositories SET last_scanned_at = datetime('now', 'localtime') WHERE id = ?",
|
|
1044
|
+
(repo_id,),
|
|
1045
|
+
)
|
|
1046
|
+
conn.commit()
|
|
1047
|
+
|
|
1048
|
+
return discovered_tasks
|
|
1049
|
+
|
|
1050
|
+
def _sync_task_from_dir(
|
|
1051
|
+
self, repo_id: int, task_dir: Path, status: str, parent_id: Optional[int] = None
|
|
1052
|
+
) -> Optional[Task]:
|
|
1053
|
+
"""Sync a single task directory with database."""
|
|
1054
|
+
repo = self.get_repo(repo_id)
|
|
1055
|
+
if not repo:
|
|
1056
|
+
return None
|
|
1057
|
+
|
|
1058
|
+
relative_path = str(task_dir.relative_to(MISSIONCACHE_ROOT))
|
|
1059
|
+
task_name = task_dir.name
|
|
1060
|
+
|
|
1061
|
+
# Parse metadata from markdown files
|
|
1062
|
+
metadata = self._parse_task_metadata(task_dir)
|
|
1063
|
+
|
|
1064
|
+
with self.connection() as conn:
|
|
1065
|
+
# Check if task exists for this repo
|
|
1066
|
+
existing = conn.execute(
|
|
1067
|
+
"SELECT * FROM tasks WHERE repo_id = ? AND full_path = ?",
|
|
1068
|
+
(repo_id, relative_path),
|
|
1069
|
+
).fetchone()
|
|
1070
|
+
|
|
1071
|
+
if existing:
|
|
1072
|
+
# Update existing task
|
|
1073
|
+
conn.execute(
|
|
1074
|
+
"""UPDATE tasks SET
|
|
1075
|
+
jira_key = COALESCE(?, jira_key),
|
|
1076
|
+
branch = COALESCE(?, branch),
|
|
1077
|
+
pr_url = COALESCE(?, pr_url),
|
|
1078
|
+
parent_id = COALESCE(?, parent_id)
|
|
1079
|
+
WHERE id = ?""",
|
|
1080
|
+
(
|
|
1081
|
+
metadata.get("jira_key"),
|
|
1082
|
+
metadata.get("branch"),
|
|
1083
|
+
metadata.get("pr_url"),
|
|
1084
|
+
parent_id,
|
|
1085
|
+
existing["id"],
|
|
1086
|
+
),
|
|
1087
|
+
)
|
|
1088
|
+
conn.commit()
|
|
1089
|
+
return self.get_task(existing["id"])
|
|
1090
|
+
|
|
1091
|
+
# Check if task already exists in ANY repo (prevent cross-repo duplication)
|
|
1092
|
+
any_existing = conn.execute(
|
|
1093
|
+
"SELECT * FROM tasks WHERE full_path = ? AND status IN ('active', 'paused')",
|
|
1094
|
+
(relative_path,),
|
|
1095
|
+
).fetchone()
|
|
1096
|
+
if any_existing:
|
|
1097
|
+
# Task belongs to another repo - skip to avoid duplication
|
|
1098
|
+
return self.get_task(any_existing["id"])
|
|
1099
|
+
|
|
1100
|
+
# Create new task only if it doesn't exist anywhere
|
|
1101
|
+
cursor = conn.execute(
|
|
1102
|
+
"""INSERT INTO tasks (repo_id, name, full_path, parent_id, status,
|
|
1103
|
+
jira_key, branch, pr_url)
|
|
1104
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
1105
|
+
(
|
|
1106
|
+
repo_id,
|
|
1107
|
+
task_name,
|
|
1108
|
+
relative_path,
|
|
1109
|
+
parent_id,
|
|
1110
|
+
status,
|
|
1111
|
+
metadata.get("jira_key"),
|
|
1112
|
+
metadata.get("branch"),
|
|
1113
|
+
metadata.get("pr_url"),
|
|
1114
|
+
),
|
|
1115
|
+
)
|
|
1116
|
+
conn.commit()
|
|
1117
|
+
return self.get_task(cursor.lastrowid)
|
|
1118
|
+
|
|
1119
|
+
def _parse_task_metadata(self, task_dir: Path) -> Dict[str, str]:
|
|
1120
|
+
"""Extract metadata from task markdown files."""
|
|
1121
|
+
metadata = {}
|
|
1122
|
+
|
|
1123
|
+
# Try various files
|
|
1124
|
+
for filename in ["context.md", f"{task_dir.name}-context.md", "README.md"]:
|
|
1125
|
+
filepath = task_dir / filename
|
|
1126
|
+
if filepath.exists():
|
|
1127
|
+
try:
|
|
1128
|
+
content = filepath.read_text()
|
|
1129
|
+
|
|
1130
|
+
# Extract JIRA key (pattern: GC-XXXXX or similar)
|
|
1131
|
+
jira_match = re.search(r"\[([A-Z]+-\d+)\]", content)
|
|
1132
|
+
if jira_match:
|
|
1133
|
+
metadata["jira_key"] = jira_match.group(1)
|
|
1134
|
+
|
|
1135
|
+
# Extract branch
|
|
1136
|
+
branch_match = re.search(
|
|
1137
|
+
r'Branch[:\s]+[`"]?([^\s`"]+)[`"]?', content, re.IGNORECASE
|
|
1138
|
+
)
|
|
1139
|
+
if branch_match:
|
|
1140
|
+
metadata["branch"] = branch_match.group(1)
|
|
1141
|
+
|
|
1142
|
+
# Extract PR URL
|
|
1143
|
+
pr_match = re.search(
|
|
1144
|
+
r"(https://github\.com/[^/]+/[^/]+/pull/\d+)", content
|
|
1145
|
+
)
|
|
1146
|
+
if pr_match:
|
|
1147
|
+
metadata["pr_url"] = pr_match.group(1)
|
|
1148
|
+
|
|
1149
|
+
break
|
|
1150
|
+
except Exception:
|
|
1151
|
+
pass
|
|
1152
|
+
|
|
1153
|
+
return metadata
|
|
1154
|
+
|
|
1155
|
+
def scan_all_repos(self) -> List[Task]:
|
|
1156
|
+
"""Scan all active repositories for tasks."""
|
|
1157
|
+
all_tasks = []
|
|
1158
|
+
for repo in self.get_repos(active_only=True):
|
|
1159
|
+
tasks = self.scan_repo(repo.id)
|
|
1160
|
+
all_tasks.extend(tasks)
|
|
1161
|
+
return all_tasks
|
|
1162
|
+
|
|
1163
|
+
# =========================================================================
|
|
1164
|
+
# Task CRUD
|
|
1165
|
+
# =========================================================================
|
|
1166
|
+
|
|
1167
|
+
def get_task(self, task_id: int) -> Optional[Task]:
|
|
1168
|
+
"""Get a task by ID."""
|
|
1169
|
+
with self.connection() as conn:
|
|
1170
|
+
row = conn.execute(
|
|
1171
|
+
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
|
1172
|
+
).fetchone()
|
|
1173
|
+
return Task.from_row(row) if row else None
|
|
1174
|
+
|
|
1175
|
+
def get_task_by_path(self, repo_id: int, full_path: str) -> Optional[Task]:
|
|
1176
|
+
"""Get a task by its path within a repo."""
|
|
1177
|
+
with self.connection() as conn:
|
|
1178
|
+
row = conn.execute(
|
|
1179
|
+
"SELECT * FROM tasks WHERE repo_id = ? AND full_path = ?",
|
|
1180
|
+
(repo_id, full_path),
|
|
1181
|
+
).fetchone()
|
|
1182
|
+
return Task.from_row(row) if row else None
|
|
1183
|
+
|
|
1184
|
+
def find_task_by_full_path(self, full_path: str) -> Optional[Task]:
|
|
1185
|
+
"""Find a task by full_path across all repos."""
|
|
1186
|
+
with self.connection() as conn:
|
|
1187
|
+
row = conn.execute(
|
|
1188
|
+
"SELECT * FROM tasks WHERE full_path = ? AND status = 'active'",
|
|
1189
|
+
(full_path,),
|
|
1190
|
+
).fetchone()
|
|
1191
|
+
return Task.from_row(row) if row else None
|
|
1192
|
+
|
|
1193
|
+
# =========================================================================
|
|
1194
|
+
# Non-Coding Task Management
|
|
1195
|
+
# =========================================================================
|
|
1196
|
+
|
|
1197
|
+
def create_task(
|
|
1198
|
+
self,
|
|
1199
|
+
name: str,
|
|
1200
|
+
task_type: str = "coding",
|
|
1201
|
+
repo_id: Optional[int] = None,
|
|
1202
|
+
jira_key: Optional[str] = None,
|
|
1203
|
+
) -> Task:
|
|
1204
|
+
"""Create a new task (coding or non-coding).
|
|
1205
|
+
|
|
1206
|
+
Args:
|
|
1207
|
+
name: Task name (e.g., "Sprint planning meeting")
|
|
1208
|
+
task_type: 'coding' or 'non-coding'
|
|
1209
|
+
repo_id: Repository ID (required for coding, None for non-coding)
|
|
1210
|
+
jira_key: Optional JIRA ticket ID
|
|
1211
|
+
|
|
1212
|
+
Returns:
|
|
1213
|
+
The created Task object
|
|
1214
|
+
"""
|
|
1215
|
+
if task_type not in ("coding", "non-coding"):
|
|
1216
|
+
raise ValueError(f"Invalid task type: {task_type}")
|
|
1217
|
+
|
|
1218
|
+
# Non-coding tasks must not have a repo_id
|
|
1219
|
+
if task_type == "non-coding" and repo_id is not None:
|
|
1220
|
+
raise ValueError("Non-coding tasks cannot be associated with a repository")
|
|
1221
|
+
|
|
1222
|
+
# Coding tasks should have a repo_id (though we allow None for flexibility)
|
|
1223
|
+
tags = extract_tags(name)
|
|
1224
|
+
full_path = f"global/{name}" if task_type == "non-coding" else f"manual/{name}"
|
|
1225
|
+
|
|
1226
|
+
with self.connection() as conn:
|
|
1227
|
+
cursor = conn.execute(
|
|
1228
|
+
"""INSERT INTO tasks (repo_id, name, full_path, type, tags, jira_key, status)
|
|
1229
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active')""",
|
|
1230
|
+
(repo_id, name, full_path, task_type, json.dumps(tags), jira_key),
|
|
1231
|
+
)
|
|
1232
|
+
conn.commit()
|
|
1233
|
+
return self.get_task(cursor.lastrowid)
|
|
1234
|
+
|
|
1235
|
+
def add_task_update(self, task_id: int, note: str) -> int:
|
|
1236
|
+
"""Add a timestamped update to a task.
|
|
1237
|
+
|
|
1238
|
+
Args:
|
|
1239
|
+
task_id: The task ID
|
|
1240
|
+
note: The update note
|
|
1241
|
+
|
|
1242
|
+
Returns:
|
|
1243
|
+
The update ID
|
|
1244
|
+
"""
|
|
1245
|
+
with self.connection() as conn:
|
|
1246
|
+
cursor = conn.execute(
|
|
1247
|
+
"INSERT INTO task_updates (task_id, note) VALUES (?, ?)",
|
|
1248
|
+
(task_id, note),
|
|
1249
|
+
)
|
|
1250
|
+
# Also update the task's last_worked_on timestamp
|
|
1251
|
+
conn.execute(
|
|
1252
|
+
"UPDATE tasks SET last_worked_on = datetime('now', 'localtime') WHERE id = ?",
|
|
1253
|
+
(task_id,),
|
|
1254
|
+
)
|
|
1255
|
+
conn.commit()
|
|
1256
|
+
return cursor.lastrowid
|
|
1257
|
+
|
|
1258
|
+
def get_task_updates(self, task_id: int, limit: int = 50) -> List[Dict]:
|
|
1259
|
+
"""Get updates for a task.
|
|
1260
|
+
|
|
1261
|
+
Args:
|
|
1262
|
+
task_id: The task ID
|
|
1263
|
+
limit: Maximum number of updates to return
|
|
1264
|
+
|
|
1265
|
+
Returns:
|
|
1266
|
+
List of update dicts with id, note, created_at
|
|
1267
|
+
"""
|
|
1268
|
+
with self.connection() as conn:
|
|
1269
|
+
rows = conn.execute(
|
|
1270
|
+
"""SELECT id, note, created_at
|
|
1271
|
+
FROM task_updates
|
|
1272
|
+
WHERE task_id = ?
|
|
1273
|
+
ORDER BY created_at DESC
|
|
1274
|
+
LIMIT ?""",
|
|
1275
|
+
(task_id, limit),
|
|
1276
|
+
).fetchall()
|
|
1277
|
+
return [dict(row) for row in rows]
|
|
1278
|
+
|
|
1279
|
+
def get_today_updates(self, task_id: Optional[int] = None) -> List[Dict]:
|
|
1280
|
+
"""Get all updates from today, optionally filtered by task.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
task_id: Optional task ID to filter by
|
|
1284
|
+
|
|
1285
|
+
Returns:
|
|
1286
|
+
List of update dicts with task info
|
|
1287
|
+
"""
|
|
1288
|
+
with self.connection() as conn:
|
|
1289
|
+
if task_id:
|
|
1290
|
+
rows = conn.execute(
|
|
1291
|
+
"""SELECT u.id, u.task_id, u.note, u.created_at, t.name as task_name
|
|
1292
|
+
FROM task_updates u
|
|
1293
|
+
JOIN tasks t ON u.task_id = t.id
|
|
1294
|
+
WHERE u.task_id = ? AND date(u.created_at) = date('now', 'localtime')
|
|
1295
|
+
ORDER BY u.created_at DESC""",
|
|
1296
|
+
(task_id,),
|
|
1297
|
+
).fetchall()
|
|
1298
|
+
else:
|
|
1299
|
+
rows = conn.execute(
|
|
1300
|
+
"""SELECT u.id, u.task_id, u.note, u.created_at, t.name as task_name
|
|
1301
|
+
FROM task_updates u
|
|
1302
|
+
JOIN tasks t ON u.task_id = t.id
|
|
1303
|
+
WHERE date(u.created_at) = date('now', 'localtime')
|
|
1304
|
+
ORDER BY u.created_at DESC"""
|
|
1305
|
+
).fetchall()
|
|
1306
|
+
return [dict(row) for row in rows]
|
|
1307
|
+
|
|
1308
|
+
def get_active_tasks(self, repo_id: Optional[int] = None) -> List[Task]:
|
|
1309
|
+
"""Get all active tasks, optionally filtered by repo."""
|
|
1310
|
+
with self.connection() as conn:
|
|
1311
|
+
if repo_id:
|
|
1312
|
+
rows = conn.execute(
|
|
1313
|
+
"""SELECT * FROM tasks
|
|
1314
|
+
WHERE status IN ('active', 'paused') AND repo_id = ?
|
|
1315
|
+
ORDER BY last_worked_on DESC NULLS LAST""",
|
|
1316
|
+
(repo_id,),
|
|
1317
|
+
).fetchall()
|
|
1318
|
+
else:
|
|
1319
|
+
rows = conn.execute(
|
|
1320
|
+
"""SELECT * FROM tasks
|
|
1321
|
+
WHERE status IN ('active', 'paused')
|
|
1322
|
+
ORDER BY last_worked_on DESC NULLS LAST"""
|
|
1323
|
+
).fetchall()
|
|
1324
|
+
return [Task.from_row(r) for r in rows]
|
|
1325
|
+
|
|
1326
|
+
def get_active_tasks_hierarchical(
|
|
1327
|
+
self, repo_id: Optional[int] = None
|
|
1328
|
+
) -> Dict[str, Any]:
|
|
1329
|
+
"""Get active tasks organized as hierarchy.
|
|
1330
|
+
|
|
1331
|
+
Returns:
|
|
1332
|
+
{
|
|
1333
|
+
"top_level": [Task, ...], # Tasks with no parent
|
|
1334
|
+
"children": {parent_id: [Task, ...]}, # Child tasks grouped by parent
|
|
1335
|
+
}
|
|
1336
|
+
"""
|
|
1337
|
+
all_tasks = self.get_active_tasks(repo_id)
|
|
1338
|
+
top_level = []
|
|
1339
|
+
children: Dict[int, List[Task]] = {}
|
|
1340
|
+
|
|
1341
|
+
for task in all_tasks:
|
|
1342
|
+
if task.parent_id is None:
|
|
1343
|
+
top_level.append(task)
|
|
1344
|
+
else:
|
|
1345
|
+
children.setdefault(task.parent_id, []).append(task)
|
|
1346
|
+
|
|
1347
|
+
return {"top_level": top_level, "children": children}
|
|
1348
|
+
|
|
1349
|
+
def get_recent_completed(self, days: int = 7) -> List[Task]:
|
|
1350
|
+
"""Get recently completed tasks."""
|
|
1351
|
+
with self.connection() as conn:
|
|
1352
|
+
rows = conn.execute(
|
|
1353
|
+
"""SELECT * FROM tasks
|
|
1354
|
+
WHERE status = 'completed'
|
|
1355
|
+
AND completed_at >= datetime('now', 'localtime', ?)
|
|
1356
|
+
ORDER BY completed_at DESC""",
|
|
1357
|
+
(f"-{days} days",),
|
|
1358
|
+
).fetchall()
|
|
1359
|
+
return [Task.from_row(r) for r in rows]
|
|
1360
|
+
|
|
1361
|
+
def get_all_completed(self, limit: int = 50) -> List[Task]:
|
|
1362
|
+
"""Get all completed tasks (not archived)."""
|
|
1363
|
+
with self.connection() as conn:
|
|
1364
|
+
rows = conn.execute(
|
|
1365
|
+
"""SELECT * FROM tasks
|
|
1366
|
+
WHERE status = 'completed'
|
|
1367
|
+
ORDER BY completed_at DESC
|
|
1368
|
+
LIMIT ?""",
|
|
1369
|
+
(limit,),
|
|
1370
|
+
).fetchall()
|
|
1371
|
+
return [Task.from_row(r) for r in rows]
|
|
1372
|
+
|
|
1373
|
+
def get_task_by_name(
|
|
1374
|
+
self, name: str, status: Optional[str] = None
|
|
1375
|
+
) -> Optional[Task]:
|
|
1376
|
+
"""Get a task by its name, optionally filtered by status."""
|
|
1377
|
+
with self.connection() as conn:
|
|
1378
|
+
if status:
|
|
1379
|
+
row = conn.execute(
|
|
1380
|
+
"SELECT * FROM tasks WHERE name = ? AND status = ?", (name, status)
|
|
1381
|
+
).fetchone()
|
|
1382
|
+
else:
|
|
1383
|
+
row = conn.execute(
|
|
1384
|
+
"SELECT * FROM tasks WHERE name = ? "
|
|
1385
|
+
"ORDER BY CASE WHEN status='active' THEN 0 ELSE 1 END, id DESC",
|
|
1386
|
+
(name,),
|
|
1387
|
+
).fetchone()
|
|
1388
|
+
return Task.from_row(row) if row else None
|
|
1389
|
+
|
|
1390
|
+
def reopen_task(self, task_id: int) -> Optional[Task]:
|
|
1391
|
+
"""Reopen a completed task by setting it back to active.
|
|
1392
|
+
|
|
1393
|
+
Args:
|
|
1394
|
+
task_id: The task ID to reopen
|
|
1395
|
+
|
|
1396
|
+
Returns:
|
|
1397
|
+
The updated Task object or None if not found
|
|
1398
|
+
"""
|
|
1399
|
+
with self.connection() as conn:
|
|
1400
|
+
# Verify task exists and is completed
|
|
1401
|
+
task = self.get_task(task_id)
|
|
1402
|
+
if not task:
|
|
1403
|
+
return None
|
|
1404
|
+
if task.status != "completed":
|
|
1405
|
+
return task # Already active, return as-is
|
|
1406
|
+
|
|
1407
|
+
# Update status to active and clear completed_at
|
|
1408
|
+
conn.execute(
|
|
1409
|
+
"""UPDATE tasks SET
|
|
1410
|
+
status = 'active',
|
|
1411
|
+
completed_at = NULL,
|
|
1412
|
+
last_worked_on = datetime('now', 'localtime')
|
|
1413
|
+
WHERE id = ?""",
|
|
1414
|
+
(task_id,),
|
|
1415
|
+
)
|
|
1416
|
+
conn.commit()
|
|
1417
|
+
return self.get_task(task_id)
|
|
1418
|
+
|
|
1419
|
+
def update_task_status(self, task_id: int, status: str) -> Optional[Task]:
|
|
1420
|
+
"""Update task status."""
|
|
1421
|
+
with self.connection() as conn:
|
|
1422
|
+
conn.execute("UPDATE tasks SET status = ? WHERE id = ?", (status, task_id))
|
|
1423
|
+
conn.commit()
|
|
1424
|
+
return self.get_task(task_id)
|
|
1425
|
+
|
|
1426
|
+
def rename_task(self, task_id: int, new_name: str) -> Dict[str, Any]:
|
|
1427
|
+
"""Rename a project: update DB row, move directory, rename files, rewrite H1s.
|
|
1428
|
+
|
|
1429
|
+
Inputs are normalized (trim + lowercase) before validation. The
|
|
1430
|
+
response always reports the canonical stored name in ``name``;
|
|
1431
|
+
callers should display that, not the user-typed input. The
|
|
1432
|
+
``normalized`` flag tells callers whether normalization changed
|
|
1433
|
+
the input so they can prefix their confirmation message.
|
|
1434
|
+
|
|
1435
|
+
Atomicity: every filesystem mutation (outer dir rename, inner
|
|
1436
|
+
file renames, H1 rewrites) is recorded as it happens. If the DB
|
|
1437
|
+
UPDATE fails after FS work succeeded, every recorded mutation is
|
|
1438
|
+
reversed in LIFO order before the original exception propagates.
|
|
1439
|
+
Reversal failures are logged via the module logger and surfaced
|
|
1440
|
+
in the response ``warnings`` list. The pre-flight auto-run guard
|
|
1441
|
+
is re-checked inside the write transaction to tighten the TOCTOU
|
|
1442
|
+
window between the user-facing check and the UPDATE.
|
|
1443
|
+
|
|
1444
|
+
Subtasks (``parent_id is not None``) are out of scope - rename
|
|
1445
|
+
the parent instead.
|
|
1446
|
+
|
|
1447
|
+
Returns:
|
|
1448
|
+
Dict with keys: success, changed, name, old_name, normalized,
|
|
1449
|
+
full_path, files_renamed, h1_rewritten, h1_skipped,
|
|
1450
|
+
sessions_updated, warnings.
|
|
1451
|
+
|
|
1452
|
+
Raises:
|
|
1453
|
+
ValueError: invalid name (after normalization), missing task,
|
|
1454
|
+
subtask, or unexpected full_path shape.
|
|
1455
|
+
NameCollisionError: another task in the same repo has that name.
|
|
1456
|
+
FilesystemCollisionError: target MissionCache directory already exists.
|
|
1457
|
+
AutoRunActiveError: a missioncache-auto run is currently running.
|
|
1458
|
+
"""
|
|
1459
|
+
raw_input = new_name
|
|
1460
|
+
new_name = new_name.strip().lower()
|
|
1461
|
+
normalized = new_name != raw_input
|
|
1462
|
+
validate_task_name(new_name)
|
|
1463
|
+
|
|
1464
|
+
task = self.get_task(task_id)
|
|
1465
|
+
if not task:
|
|
1466
|
+
raise ValueError(f"No project found with id {task_id}.")
|
|
1467
|
+
if task.parent_id is not None:
|
|
1468
|
+
raise ValueError(
|
|
1469
|
+
"Subtask rename is not supported. Rename the parent project instead."
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
old_name = task.name
|
|
1473
|
+
# No-op short-circuit. Same-name rename returns success cleanly.
|
|
1474
|
+
if new_name == old_name:
|
|
1475
|
+
return {
|
|
1476
|
+
"success": True,
|
|
1477
|
+
"changed": False,
|
|
1478
|
+
"name": new_name,
|
|
1479
|
+
"old_name": old_name,
|
|
1480
|
+
"normalized": normalized,
|
|
1481
|
+
"full_path": task.full_path,
|
|
1482
|
+
"files_renamed": [],
|
|
1483
|
+
"h1_rewritten": [],
|
|
1484
|
+
"h1_skipped": [],
|
|
1485
|
+
"sessions_updated": 0,
|
|
1486
|
+
"warnings": [],
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
# Compute new full_path - same prefix, new name. Splits on the LAST
|
|
1490
|
+
# "/" so any "<prefix>/<name>" shape works (active/, manual/,
|
|
1491
|
+
# global/, dev/active/<repo>/, etc.). Subtasks were already refused.
|
|
1492
|
+
prefix_parts = task.full_path.rsplit("/", 1)
|
|
1493
|
+
if len(prefix_parts) != 2:
|
|
1494
|
+
raise ValueError(
|
|
1495
|
+
f"Unexpected full_path shape: {task.full_path!r}. "
|
|
1496
|
+
f"Expected '<prefix>/<name>'."
|
|
1497
|
+
)
|
|
1498
|
+
prefix = prefix_parts[0]
|
|
1499
|
+
new_full_path = f"{prefix}/{new_name}"
|
|
1500
|
+
|
|
1501
|
+
# Pre-flight checks (DB collision + auto-run guard). Friendly
|
|
1502
|
+
# fast-fail before we touch the filesystem. Re-checked inside
|
|
1503
|
+
# the write transaction below to tighten the TOCTOU window.
|
|
1504
|
+
with self.connection() as conn:
|
|
1505
|
+
existing = conn.execute(
|
|
1506
|
+
"SELECT id FROM tasks "
|
|
1507
|
+
"WHERE COALESCE(repo_id, -1) = COALESCE(?, -1) "
|
|
1508
|
+
" AND full_path = ? AND id != ?",
|
|
1509
|
+
(task.repo_id, new_full_path, task_id),
|
|
1510
|
+
).fetchone()
|
|
1511
|
+
if existing:
|
|
1512
|
+
raise NameCollisionError(
|
|
1513
|
+
f"A project named '{new_name}' already exists. "
|
|
1514
|
+
f"Pick a different name."
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
running = conn.execute(
|
|
1518
|
+
"SELECT id FROM auto_executions "
|
|
1519
|
+
"WHERE task_id = ? AND status = 'running' LIMIT 1",
|
|
1520
|
+
(task_id,),
|
|
1521
|
+
).fetchone()
|
|
1522
|
+
if running:
|
|
1523
|
+
raise AutoRunActiveError(
|
|
1524
|
+
"Cannot rename while missioncache-auto is running on this project. "
|
|
1525
|
+
"Stop the run and try again."
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
# Filesystem collision pre-check.
|
|
1529
|
+
old_dir = MISSIONCACHE_ROOT / task.full_path
|
|
1530
|
+
new_dir = MISSIONCACHE_ROOT / new_full_path
|
|
1531
|
+
if new_dir.exists():
|
|
1532
|
+
raise FilesystemCollisionError(
|
|
1533
|
+
f"Directory '{new_dir}' already exists. Pick a different name."
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
files_renamed: List[str] = []
|
|
1537
|
+
h1_rewritten: List[str] = []
|
|
1538
|
+
h1_skipped: List[str] = []
|
|
1539
|
+
# Rollback ledgers - every successful FS mutation is appended
|
|
1540
|
+
# here so the DB-failure rollback can reverse it in LIFO order.
|
|
1541
|
+
renamed_pairs: List[Tuple[Path, Path]] = [] # (current_path, original_path)
|
|
1542
|
+
h1_originals: List[Tuple[Path, str]] = [] # (path, original_content)
|
|
1543
|
+
fs_renamed = False
|
|
1544
|
+
|
|
1545
|
+
# Coding tasks have an on-disk directory; non-coding tasks
|
|
1546
|
+
# (full_path = "global/<name>") do not. Skip FS work in the
|
|
1547
|
+
# latter case - the DB update is enough.
|
|
1548
|
+
if old_dir.exists():
|
|
1549
|
+
# Pre-flight inner-file collision check BEFORE moving the outer
|
|
1550
|
+
# dir. POSIX rename(2) silently overwrites an existing target
|
|
1551
|
+
# file - if the source dir somehow already contains a file with
|
|
1552
|
+
# the new prefix (e.g. user manually renamed one of the four
|
|
1553
|
+
# without using this primitive), the inner loop would clobber
|
|
1554
|
+
# it. Raising here keeps the FS untouched so no rollback is
|
|
1555
|
+
# needed.
|
|
1556
|
+
for suffix in ("plan", "context", "tasks", "iteration-log"):
|
|
1557
|
+
proposed_target = old_dir / f"{new_name}-{suffix}.md"
|
|
1558
|
+
if proposed_target.exists():
|
|
1559
|
+
raise FilesystemCollisionError(
|
|
1560
|
+
f"File '{proposed_target.name}' already exists in "
|
|
1561
|
+
f"'{old_dir}'. Resolve manually before renaming."
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
old_dir.rename(new_dir)
|
|
1565
|
+
fs_renamed = True
|
|
1566
|
+
|
|
1567
|
+
# File renames inside. Prompts subdir uses unprefixed names
|
|
1568
|
+
# (task-NN-prompt.md) so it stays untouched.
|
|
1569
|
+
for suffix in ("plan", "context", "tasks", "iteration-log"):
|
|
1570
|
+
old_file = new_dir / f"{old_name}-{suffix}.md"
|
|
1571
|
+
new_file = new_dir / f"{new_name}-{suffix}.md"
|
|
1572
|
+
if old_file.exists():
|
|
1573
|
+
old_file.rename(new_file)
|
|
1574
|
+
renamed_pairs.append((new_file, old_file))
|
|
1575
|
+
files_renamed.append(new_file.name)
|
|
1576
|
+
|
|
1577
|
+
# H1 rewrite - only when the H1 still matches the exact
|
|
1578
|
+
# template default. If the user has edited the H1 (different
|
|
1579
|
+
# text, different shape), leave it alone and report skipped.
|
|
1580
|
+
old_titlecase = old_name.replace("-", " ").title()
|
|
1581
|
+
new_titlecase = new_name.replace("-", " ").title()
|
|
1582
|
+
for suffix, label in (
|
|
1583
|
+
("plan", "Plan"),
|
|
1584
|
+
("context", "Context"),
|
|
1585
|
+
("tasks", "Tasks"),
|
|
1586
|
+
):
|
|
1587
|
+
f = new_dir / f"{new_name}-{suffix}.md"
|
|
1588
|
+
if not f.exists():
|
|
1589
|
+
continue
|
|
1590
|
+
try:
|
|
1591
|
+
content = f.read_text()
|
|
1592
|
+
except OSError:
|
|
1593
|
+
h1_skipped.append(f.name)
|
|
1594
|
+
continue
|
|
1595
|
+
head, _, rest = content.partition("\n")
|
|
1596
|
+
expected_h1 = f"# {old_titlecase} - {label}"
|
|
1597
|
+
if head.rstrip() == expected_h1:
|
|
1598
|
+
new_h1 = f"# {new_titlecase} - {label}"
|
|
1599
|
+
try:
|
|
1600
|
+
f.write_text(new_h1 + "\n" + rest if rest else new_h1)
|
|
1601
|
+
h1_originals.append((f, content))
|
|
1602
|
+
h1_rewritten.append(f.name)
|
|
1603
|
+
except OSError:
|
|
1604
|
+
h1_skipped.append(f.name)
|
|
1605
|
+
else:
|
|
1606
|
+
h1_skipped.append(f.name)
|
|
1607
|
+
|
|
1608
|
+
# DB update - re-check the auto-run guard inside the same
|
|
1609
|
+
# connection used for the UPDATE so a concurrent missioncache-auto INSERT
|
|
1610
|
+
# between the pre-flight check and the UPDATE is caught. Narrow
|
|
1611
|
+
# except sqlite3.Error so unrelated bugs (KeyError, attribute
|
|
1612
|
+
# mistakes, MissionCacheMigrationRequired) don't silently trigger a
|
|
1613
|
+
# misdirected FS rollback.
|
|
1614
|
+
try:
|
|
1615
|
+
with self.connection() as conn:
|
|
1616
|
+
running = conn.execute(
|
|
1617
|
+
"SELECT id FROM auto_executions "
|
|
1618
|
+
"WHERE task_id = ? AND status = 'running' LIMIT 1",
|
|
1619
|
+
(task_id,),
|
|
1620
|
+
).fetchone()
|
|
1621
|
+
if running:
|
|
1622
|
+
raise AutoRunActiveError(
|
|
1623
|
+
"Cannot rename while missioncache-auto is running on this project. "
|
|
1624
|
+
"Stop the run and try again."
|
|
1625
|
+
)
|
|
1626
|
+
conn.execute(
|
|
1627
|
+
"UPDATE tasks SET name = ?, full_path = ? WHERE id = ?",
|
|
1628
|
+
(new_name, new_full_path, task_id),
|
|
1629
|
+
)
|
|
1630
|
+
# Subtask rows embed the parent's full_path as a prefix
|
|
1631
|
+
# (e.g. "active/parent/child"). The outer dir rename
|
|
1632
|
+
# already moved their on-disk dirs as subdirectories, but
|
|
1633
|
+
# their DB rows would otherwise keep the old prefix, so
|
|
1634
|
+
# the next scan_repos would re-discover them at the new
|
|
1635
|
+
# path and create duplicate rows. Rewrite the prefix in
|
|
1636
|
+
# the same transaction.
|
|
1637
|
+
old_prefix = task.full_path + "/"
|
|
1638
|
+
new_prefix = new_full_path + "/"
|
|
1639
|
+
children = conn.execute(
|
|
1640
|
+
"SELECT id, full_path FROM tasks WHERE parent_id = ?",
|
|
1641
|
+
(task_id,),
|
|
1642
|
+
).fetchall()
|
|
1643
|
+
for child in children:
|
|
1644
|
+
if child["full_path"].startswith(old_prefix):
|
|
1645
|
+
new_child_path = (
|
|
1646
|
+
new_prefix + child["full_path"][len(old_prefix):]
|
|
1647
|
+
)
|
|
1648
|
+
conn.execute(
|
|
1649
|
+
"UPDATE tasks SET full_path = ? WHERE id = ?",
|
|
1650
|
+
(new_child_path, child["id"]),
|
|
1651
|
+
)
|
|
1652
|
+
conn.commit()
|
|
1653
|
+
except (sqlite3.Error, AutoRunActiveError):
|
|
1654
|
+
# Reverse every recorded FS mutation in LIFO order: H1
|
|
1655
|
+
# contents -> inner file renames -> outer directory rename.
|
|
1656
|
+
# Each step is independently logged so partial-rollback
|
|
1657
|
+
# state is observable.
|
|
1658
|
+
for f, original in reversed(h1_originals):
|
|
1659
|
+
try:
|
|
1660
|
+
f.write_text(original)
|
|
1661
|
+
except OSError:
|
|
1662
|
+
logger.exception(
|
|
1663
|
+
"rename rollback: H1 restore failed for %s", f
|
|
1664
|
+
)
|
|
1665
|
+
for current, original in reversed(renamed_pairs):
|
|
1666
|
+
try:
|
|
1667
|
+
current.rename(original)
|
|
1668
|
+
except OSError:
|
|
1669
|
+
logger.exception(
|
|
1670
|
+
"rename rollback: inner file restore failed for %s -> %s",
|
|
1671
|
+
current,
|
|
1672
|
+
original,
|
|
1673
|
+
)
|
|
1674
|
+
if fs_renamed:
|
|
1675
|
+
try:
|
|
1676
|
+
new_dir.rename(old_dir)
|
|
1677
|
+
except OSError:
|
|
1678
|
+
logger.exception(
|
|
1679
|
+
"rename rollback: directory restore failed for %s -> %s",
|
|
1680
|
+
new_dir,
|
|
1681
|
+
old_dir,
|
|
1682
|
+
)
|
|
1683
|
+
raise
|
|
1684
|
+
|
|
1685
|
+
sweep = self._sweep_session_pointers(old_name, new_name)
|
|
1686
|
+
|
|
1687
|
+
return {
|
|
1688
|
+
"success": True,
|
|
1689
|
+
"changed": True,
|
|
1690
|
+
"name": new_name,
|
|
1691
|
+
"old_name": old_name,
|
|
1692
|
+
"normalized": normalized,
|
|
1693
|
+
"full_path": new_full_path,
|
|
1694
|
+
"files_renamed": files_renamed,
|
|
1695
|
+
"h1_rewritten": h1_rewritten,
|
|
1696
|
+
"h1_skipped": h1_skipped,
|
|
1697
|
+
"sessions_updated": sweep["updated"],
|
|
1698
|
+
"warnings": sweep["warnings"],
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
def _sweep_session_pointers(
|
|
1702
|
+
self, old_name: str, new_name: str
|
|
1703
|
+
) -> Dict[str, Any]:
|
|
1704
|
+
"""Best-effort rewrite of per-session state files that reference
|
|
1705
|
+
the renamed project by name.
|
|
1706
|
+
|
|
1707
|
+
Updates (does not delete) so session ownership is preserved. Each
|
|
1708
|
+
sweep target is independently wrapped so a partial failure on one
|
|
1709
|
+
pointer doesn't block the others - this is post-DB-commit cleanup,
|
|
1710
|
+
not a transactional step. Failures are logged via the module
|
|
1711
|
+
logger AND surfaced in the returned ``warnings`` list so callers
|
|
1712
|
+
can show the user that some pointers may be stale.
|
|
1713
|
+
|
|
1714
|
+
Returns a dict with keys ``updated`` (int count of pointers
|
|
1715
|
+
successfully rewritten) and ``warnings`` (list of human-readable
|
|
1716
|
+
strings describing each failure).
|
|
1717
|
+
"""
|
|
1718
|
+
state_dir = Path.home() / ".claude" / "hooks" / "state"
|
|
1719
|
+
updated = 0
|
|
1720
|
+
warnings: List[str] = []
|
|
1721
|
+
|
|
1722
|
+
# ``pending-task.json`` used to be swept here; removed when the file
|
|
1723
|
+
# itself stopped being written. The legacy file (if it still exists
|
|
1724
|
+
# from a pre-0.2.13 install) is no longer maintained or read by any
|
|
1725
|
+
# current code path. See docs/hooks.md.
|
|
1726
|
+
|
|
1727
|
+
projects_dir = state_dir / "projects"
|
|
1728
|
+
if projects_dir.is_dir():
|
|
1729
|
+
for f in projects_dir.glob("*.json"):
|
|
1730
|
+
try:
|
|
1731
|
+
data = json.loads(f.read_text())
|
|
1732
|
+
if data.get("projectName") == old_name:
|
|
1733
|
+
data["projectName"] = new_name
|
|
1734
|
+
f.write_text(json.dumps(data))
|
|
1735
|
+
updated += 1
|
|
1736
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
1737
|
+
logger.warning(
|
|
1738
|
+
"session sweep: %s update failed: %s", f.name, e
|
|
1739
|
+
)
|
|
1740
|
+
warnings.append(
|
|
1741
|
+
f"projects/{f.name} update failed ({type(e).__name__}); "
|
|
1742
|
+
"that session may show stale name"
|
|
1743
|
+
)
|
|
1744
|
+
|
|
1745
|
+
if HOOKS_STATE_DB_PATH.exists():
|
|
1746
|
+
try:
|
|
1747
|
+
conn = sqlite3.connect(str(HOOKS_STATE_DB_PATH))
|
|
1748
|
+
cursor = conn.execute(
|
|
1749
|
+
"UPDATE project_state SET project_name = ?, "
|
|
1750
|
+
"updated_at = datetime('now', 'localtime') "
|
|
1751
|
+
"WHERE project_name = ?",
|
|
1752
|
+
(new_name, old_name),
|
|
1753
|
+
)
|
|
1754
|
+
if cursor.rowcount and cursor.rowcount > 0:
|
|
1755
|
+
updated += cursor.rowcount
|
|
1756
|
+
conn.commit()
|
|
1757
|
+
conn.close()
|
|
1758
|
+
except sqlite3.Error as e:
|
|
1759
|
+
logger.warning(
|
|
1760
|
+
"session sweep: hooks-state.db update failed: %s", e
|
|
1761
|
+
)
|
|
1762
|
+
warnings.append(
|
|
1763
|
+
f"hooks-state.db update failed ({type(e).__name__}); "
|
|
1764
|
+
"active sessions may show stale name"
|
|
1765
|
+
)
|
|
1766
|
+
|
|
1767
|
+
return {"updated": updated, "warnings": warnings}
|
|
1768
|
+
|
|
1769
|
+
def update_task_repo(self, task_id: int, repo_id: int) -> None:
|
|
1770
|
+
"""Reassign a task to a different repository."""
|
|
1771
|
+
with self.connection() as conn:
|
|
1772
|
+
conn.execute(
|
|
1773
|
+
"UPDATE tasks SET repo_id = ? WHERE id = ?", (repo_id, task_id)
|
|
1774
|
+
)
|
|
1775
|
+
conn.commit()
|
|
1776
|
+
|
|
1777
|
+
def find_task_for_cwd(
|
|
1778
|
+
self, cwd: Union[str, Path], session_id: Optional[str] = None
|
|
1779
|
+
) -> Optional[Task]:
|
|
1780
|
+
"""Find the active task that matches the current working directory.
|
|
1781
|
+
|
|
1782
|
+
Only returns a task when explicitly working on one:
|
|
1783
|
+
1. Check pending-project.json for explicitly registered project (from /missioncache:orbit-project-continue)
|
|
1784
|
+
2. Check per-session project file (written by statusline after consuming pending-project.json)
|
|
1785
|
+
3. Check if cwd is in dev/active/<task>/<subtask> directory
|
|
1786
|
+
|
|
1787
|
+
Does NOT fall back to "most recent task in repo" - this prevents spurious
|
|
1788
|
+
updates to tasks when working in a repo on unrelated things.
|
|
1789
|
+
"""
|
|
1790
|
+
cwd_path = Path(cwd).resolve()
|
|
1791
|
+
state_dir = Path.home() / ".claude" / "hooks" / "state"
|
|
1792
|
+
|
|
1793
|
+
# Priority 1: Check pending-project.json for explicitly registered project
|
|
1794
|
+
pending_project_file = state_dir / "pending-project.json"
|
|
1795
|
+
if pending_project_file.exists():
|
|
1796
|
+
try:
|
|
1797
|
+
with open(pending_project_file) as f:
|
|
1798
|
+
pending = json.load(f)
|
|
1799
|
+
pending_cwd = Path(pending.get("cwd", "")).resolve()
|
|
1800
|
+
pending_name = pending.get("projectName", "")
|
|
1801
|
+
|
|
1802
|
+
# Check if pending task's cwd matches or is parent of current cwd
|
|
1803
|
+
if pending_name and (
|
|
1804
|
+
cwd_path == pending_cwd
|
|
1805
|
+
or str(cwd_path).startswith(str(pending_cwd) + os.sep)
|
|
1806
|
+
):
|
|
1807
|
+
# Find the task by name
|
|
1808
|
+
# pending_name could be "task-name" or "parent/subtask"
|
|
1809
|
+
task = self._find_task_by_registered_name(pending_name, cwd_path)
|
|
1810
|
+
if task:
|
|
1811
|
+
return task
|
|
1812
|
+
except (json.JSONDecodeError, IOError):
|
|
1813
|
+
pass # Fall through to other methods
|
|
1814
|
+
|
|
1815
|
+
# Priority 2: Check per-session project file (written by statusline)
|
|
1816
|
+
# This persists the project assignment after pending-project.json is consumed
|
|
1817
|
+
if session_id:
|
|
1818
|
+
session_project_file = state_dir / "projects" / f"{session_id}.json"
|
|
1819
|
+
if session_project_file.exists():
|
|
1820
|
+
try:
|
|
1821
|
+
with open(session_project_file) as f:
|
|
1822
|
+
session_data = json.load(f)
|
|
1823
|
+
task_name = session_data.get("projectName", "")
|
|
1824
|
+
if task_name:
|
|
1825
|
+
task = self._find_task_by_registered_name(task_name, cwd_path)
|
|
1826
|
+
if task:
|
|
1827
|
+
return task
|
|
1828
|
+
except (json.JSONDecodeError, IOError):
|
|
1829
|
+
pass # Fall through to other methods
|
|
1830
|
+
|
|
1831
|
+
# Priority 3: Check if cwd is under centralized MissionCache root
|
|
1832
|
+
orbit_active = MISSIONCACHE_ROOT / "active"
|
|
1833
|
+
try:
|
|
1834
|
+
relative = cwd_path.relative_to(orbit_active)
|
|
1835
|
+
parts = relative.parts
|
|
1836
|
+
if parts:
|
|
1837
|
+
task_name = parts[0]
|
|
1838
|
+
# Check for subtask
|
|
1839
|
+
if len(parts) >= 2:
|
|
1840
|
+
full_path = f"active/{parts[0]}/{parts[1]}"
|
|
1841
|
+
task = self.find_task_by_full_path(full_path)
|
|
1842
|
+
if task:
|
|
1843
|
+
return task
|
|
1844
|
+
# Try parent task
|
|
1845
|
+
full_path = f"active/{task_name}"
|
|
1846
|
+
task = self.find_task_by_full_path(full_path)
|
|
1847
|
+
if task:
|
|
1848
|
+
return task
|
|
1849
|
+
except ValueError:
|
|
1850
|
+
pass # cwd is not under MissionCache root
|
|
1851
|
+
|
|
1852
|
+
# Legacy: check repo-local dev/active/ paths
|
|
1853
|
+
for repo in self.get_repos(active_only=True):
|
|
1854
|
+
repo_path = Path(repo.path)
|
|
1855
|
+
try:
|
|
1856
|
+
relative = cwd_path.relative_to(repo_path)
|
|
1857
|
+
parts = relative.parts
|
|
1858
|
+
|
|
1859
|
+
if len(parts) >= 3 and parts[0] == "dev" and parts[1] == "active":
|
|
1860
|
+
task_name = parts[2]
|
|
1861
|
+
|
|
1862
|
+
if len(parts) >= 4:
|
|
1863
|
+
full_path = f"dev/active/{parts[2]}/{parts[3]}"
|
|
1864
|
+
task = self.get_task_by_path(repo.id, full_path)
|
|
1865
|
+
if task:
|
|
1866
|
+
return task
|
|
1867
|
+
|
|
1868
|
+
full_path = f"dev/active/{task_name}"
|
|
1869
|
+
task = self.get_task_by_path(repo.id, full_path)
|
|
1870
|
+
if task:
|
|
1871
|
+
return task
|
|
1872
|
+
|
|
1873
|
+
except ValueError:
|
|
1874
|
+
continue
|
|
1875
|
+
|
|
1876
|
+
return None
|
|
1877
|
+
|
|
1878
|
+
def _find_task_by_registered_name(
|
|
1879
|
+
self, task_name: str, cwd_path: Path
|
|
1880
|
+
) -> Optional[Task]:
|
|
1881
|
+
"""Find a task by its registered name (from pending-project.json).
|
|
1882
|
+
|
|
1883
|
+
Handles both standalone tasks ("task-name") and subtasks ("parent/subtask").
|
|
1884
|
+
Uses the most specific matching repo (longest path that contains cwd).
|
|
1885
|
+
"""
|
|
1886
|
+
# Find the most specific repo (longest path that matches cwd)
|
|
1887
|
+
matching_repos = []
|
|
1888
|
+
for repo in self.get_repos(active_only=True):
|
|
1889
|
+
repo_path = Path(repo.path)
|
|
1890
|
+
try:
|
|
1891
|
+
cwd_path.relative_to(repo_path)
|
|
1892
|
+
matching_repos.append(repo)
|
|
1893
|
+
except ValueError:
|
|
1894
|
+
continue
|
|
1895
|
+
|
|
1896
|
+
if not matching_repos:
|
|
1897
|
+
return None
|
|
1898
|
+
|
|
1899
|
+
# Sort by path length descending (most specific first)
|
|
1900
|
+
matching_repos.sort(key=lambda r: len(r.path), reverse=True)
|
|
1901
|
+
|
|
1902
|
+
for repo in matching_repos:
|
|
1903
|
+
# Handle parent/subtask format
|
|
1904
|
+
if "/" in task_name:
|
|
1905
|
+
parent_name, subtask_name = task_name.split("/", 1)
|
|
1906
|
+
# Find subtask by name under this parent
|
|
1907
|
+
with self.connection() as conn:
|
|
1908
|
+
row = conn.execute(
|
|
1909
|
+
"""SELECT t.* FROM tasks t
|
|
1910
|
+
JOIN tasks p ON t.parent_id = p.id
|
|
1911
|
+
WHERE t.name = ? AND p.name = ? AND t.repo_id = ?
|
|
1912
|
+
AND t.status IN ('active', 'paused')""",
|
|
1913
|
+
(subtask_name, parent_name, repo.id),
|
|
1914
|
+
).fetchone()
|
|
1915
|
+
if row:
|
|
1916
|
+
return Task.from_row(row)
|
|
1917
|
+
|
|
1918
|
+
# Try as standalone task name
|
|
1919
|
+
with self.connection() as conn:
|
|
1920
|
+
row = conn.execute(
|
|
1921
|
+
"""SELECT * FROM tasks
|
|
1922
|
+
WHERE name = ? AND repo_id = ?
|
|
1923
|
+
AND status IN ('active', 'paused')""",
|
|
1924
|
+
(task_name, repo.id),
|
|
1925
|
+
).fetchone()
|
|
1926
|
+
if row:
|
|
1927
|
+
return Task.from_row(row)
|
|
1928
|
+
|
|
1929
|
+
return None
|
|
1930
|
+
|
|
1931
|
+
# =========================================================================
|
|
1932
|
+
# Activity Tracking (Heartbeat System)
|
|
1933
|
+
# =========================================================================
|
|
1934
|
+
|
|
1935
|
+
def record_heartbeat(
|
|
1936
|
+
self,
|
|
1937
|
+
task_id: int,
|
|
1938
|
+
session_id: Optional[str] = None,
|
|
1939
|
+
context: Optional[Dict[str, Any]] = None,
|
|
1940
|
+
) -> int:
|
|
1941
|
+
"""Record a heartbeat for activity tracking."""
|
|
1942
|
+
with self.connection() as conn:
|
|
1943
|
+
cursor = conn.execute(
|
|
1944
|
+
"INSERT INTO heartbeats (task_id, session_id, context) VALUES (?, ?, ?)",
|
|
1945
|
+
(task_id, session_id, json.dumps(context) if context else None),
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
# Update task's last_worked_on
|
|
1949
|
+
conn.execute(
|
|
1950
|
+
"UPDATE tasks SET last_worked_on = datetime('now', 'localtime') WHERE id = ?",
|
|
1951
|
+
(task_id,),
|
|
1952
|
+
)
|
|
1953
|
+
conn.commit()
|
|
1954
|
+
return cursor.lastrowid
|
|
1955
|
+
|
|
1956
|
+
def record_heartbeat_auto(
|
|
1957
|
+
self,
|
|
1958
|
+
cwd: Union[str, Path],
|
|
1959
|
+
session_id: Optional[str] = None,
|
|
1960
|
+
context: Optional[Dict[str, Any]] = None,
|
|
1961
|
+
) -> Optional[int]:
|
|
1962
|
+
"""Record a heartbeat, auto-detecting the task from cwd and session."""
|
|
1963
|
+
task = self.find_task_for_cwd(cwd, session_id)
|
|
1964
|
+
if task:
|
|
1965
|
+
hb_id = self.record_heartbeat(task.id, session_id, context)
|
|
1966
|
+
|
|
1967
|
+
# Trigger shadow commit for non-git folders
|
|
1968
|
+
self._maybe_shadow_commit(cwd, task.id, session_id)
|
|
1969
|
+
|
|
1970
|
+
return hb_id
|
|
1971
|
+
return None
|
|
1972
|
+
|
|
1973
|
+
def _maybe_shadow_commit(
|
|
1974
|
+
self, cwd: Union[str, Path], task_id: int, session_id: Optional[str] = None
|
|
1975
|
+
) -> None:
|
|
1976
|
+
"""Trigger shadow commit if cwd is under the tracked non-git folder."""
|
|
1977
|
+
try:
|
|
1978
|
+
cwd_path = Path(cwd).resolve()
|
|
1979
|
+
tracked = SHADOW_TRACKED_FOLDER.resolve()
|
|
1980
|
+
|
|
1981
|
+
# Only trigger for paths under the tracked folder
|
|
1982
|
+
if not (cwd_path == tracked or tracked in cwd_path.parents):
|
|
1983
|
+
return
|
|
1984
|
+
|
|
1985
|
+
# Don't commit if it's actually a git repo
|
|
1986
|
+
if self._is_git_repo(cwd_path):
|
|
1987
|
+
return
|
|
1988
|
+
|
|
1989
|
+
# Import here to avoid circular imports
|
|
1990
|
+
from shadow_repo import ShadowRepoManager
|
|
1991
|
+
|
|
1992
|
+
mgr = ShadowRepoManager()
|
|
1993
|
+
result = mgr.sync_and_commit(
|
|
1994
|
+
str(tracked), # Always commit from the root tracked folder
|
|
1995
|
+
task_id=task_id,
|
|
1996
|
+
session_id=session_id,
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
if result:
|
|
2000
|
+
# Log silently - don't spam output
|
|
2001
|
+
pass
|
|
2002
|
+
|
|
2003
|
+
except Exception:
|
|
2004
|
+
# Shadow commits are best-effort, don't break heartbeat on failure
|
|
2005
|
+
pass
|
|
2006
|
+
|
|
2007
|
+
def _is_git_repo(self, path: Union[str, Path]) -> bool:
|
|
2008
|
+
"""Check if a path is inside a git repository."""
|
|
2009
|
+
path = Path(path)
|
|
2010
|
+
while path != path.parent:
|
|
2011
|
+
if (path / ".git").exists():
|
|
2012
|
+
return True
|
|
2013
|
+
path = path.parent
|
|
2014
|
+
return False
|
|
2015
|
+
|
|
2016
|
+
def process_heartbeats(self) -> int:
|
|
2017
|
+
"""Process unprocessed heartbeats into sessions."""
|
|
2018
|
+
idle_timeout = self.idle_timeout_seconds
|
|
2019
|
+
assumed_work = self.assumed_work_seconds
|
|
2020
|
+
processed_count = 0
|
|
2021
|
+
|
|
2022
|
+
with self.connection() as conn:
|
|
2023
|
+
# Get unprocessed heartbeats (skip orphaned task_ids)
|
|
2024
|
+
heartbeats = conn.execute(
|
|
2025
|
+
"""SELECT h.* FROM heartbeats h
|
|
2026
|
+
JOIN tasks t ON h.task_id = t.id
|
|
2027
|
+
WHERE h.processed = 0
|
|
2028
|
+
ORDER BY h.task_id, h.timestamp"""
|
|
2029
|
+
).fetchall()
|
|
2030
|
+
|
|
2031
|
+
# Mark orphaned heartbeats as processed
|
|
2032
|
+
conn.execute(
|
|
2033
|
+
"""UPDATE heartbeats SET processed = 1
|
|
2034
|
+
WHERE processed = 0
|
|
2035
|
+
AND task_id NOT IN (SELECT id FROM tasks)"""
|
|
2036
|
+
)
|
|
2037
|
+
|
|
2038
|
+
if not heartbeats:
|
|
2039
|
+
return 0
|
|
2040
|
+
|
|
2041
|
+
current_task_id = None
|
|
2042
|
+
current_session_id = None
|
|
2043
|
+
last_heartbeat_time = None
|
|
2044
|
+
session_start_time = None
|
|
2045
|
+
|
|
2046
|
+
for hb in heartbeats:
|
|
2047
|
+
hb_time = datetime.fromisoformat(hb["timestamp"])
|
|
2048
|
+
|
|
2049
|
+
# Task changed - close any open session
|
|
2050
|
+
if hb["task_id"] != current_task_id:
|
|
2051
|
+
if current_session_id and last_heartbeat_time:
|
|
2052
|
+
self._close_session(
|
|
2053
|
+
conn, current_session_id, last_heartbeat_time, assumed_work
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
current_task_id = hb["task_id"]
|
|
2057
|
+
current_session_id = None
|
|
2058
|
+
last_heartbeat_time = None
|
|
2059
|
+
session_start_time = None
|
|
2060
|
+
|
|
2061
|
+
# Check if we need a new session
|
|
2062
|
+
if current_session_id is None:
|
|
2063
|
+
# Start new session
|
|
2064
|
+
current_session_id = self._start_session(
|
|
2065
|
+
conn, current_task_id, hb_time, hb["session_id"]
|
|
2066
|
+
)
|
|
2067
|
+
session_start_time = hb_time
|
|
2068
|
+
last_heartbeat_time = hb_time
|
|
2069
|
+
else:
|
|
2070
|
+
# Check gap since last heartbeat
|
|
2071
|
+
gap = (hb_time - last_heartbeat_time).total_seconds()
|
|
2072
|
+
|
|
2073
|
+
if gap > idle_timeout:
|
|
2074
|
+
# Gap too large - close old session, start new one
|
|
2075
|
+
self._close_session(
|
|
2076
|
+
conn, current_session_id, last_heartbeat_time, assumed_work
|
|
2077
|
+
)
|
|
2078
|
+
current_session_id = self._start_session(
|
|
2079
|
+
conn, current_task_id, hb_time, hb["session_id"]
|
|
2080
|
+
)
|
|
2081
|
+
session_start_time = hb_time
|
|
2082
|
+
else:
|
|
2083
|
+
# Continue session - add time
|
|
2084
|
+
self._add_to_session(conn, current_session_id, gap)
|
|
2085
|
+
|
|
2086
|
+
last_heartbeat_time = hb_time
|
|
2087
|
+
|
|
2088
|
+
# Mark heartbeat as processed
|
|
2089
|
+
conn.execute(
|
|
2090
|
+
"UPDATE heartbeats SET processed = 1 WHERE id = ?", (hb["id"],)
|
|
2091
|
+
)
|
|
2092
|
+
processed_count += 1
|
|
2093
|
+
|
|
2094
|
+
# Close any remaining open session
|
|
2095
|
+
if current_session_id and last_heartbeat_time:
|
|
2096
|
+
self._close_session(
|
|
2097
|
+
conn, current_session_id, last_heartbeat_time, assumed_work
|
|
2098
|
+
)
|
|
2099
|
+
|
|
2100
|
+
conn.commit()
|
|
2101
|
+
|
|
2102
|
+
return processed_count
|
|
2103
|
+
|
|
2104
|
+
def _start_session(
|
|
2105
|
+
self,
|
|
2106
|
+
conn: sqlite3.Connection,
|
|
2107
|
+
task_id: int,
|
|
2108
|
+
start_time: datetime,
|
|
2109
|
+
claude_session_id: Optional[str],
|
|
2110
|
+
) -> int:
|
|
2111
|
+
"""Start a new session."""
|
|
2112
|
+
cursor = conn.execute(
|
|
2113
|
+
"""INSERT INTO sessions (task_id, session_id, start_time, heartbeat_count)
|
|
2114
|
+
VALUES (?, ?, ?, 1)""",
|
|
2115
|
+
(task_id, claude_session_id, start_time.isoformat()),
|
|
2116
|
+
)
|
|
2117
|
+
return cursor.lastrowid
|
|
2118
|
+
|
|
2119
|
+
def _add_to_session(
|
|
2120
|
+
self, conn: sqlite3.Connection, session_id: int, duration: float
|
|
2121
|
+
) -> None:
|
|
2122
|
+
"""Add duration to an existing session."""
|
|
2123
|
+
conn.execute(
|
|
2124
|
+
"""UPDATE sessions SET
|
|
2125
|
+
duration_seconds = duration_seconds + ?,
|
|
2126
|
+
heartbeat_count = heartbeat_count + 1
|
|
2127
|
+
WHERE id = ?""",
|
|
2128
|
+
(int(duration), session_id),
|
|
2129
|
+
)
|
|
2130
|
+
|
|
2131
|
+
def _close_session(
|
|
2132
|
+
self,
|
|
2133
|
+
conn: sqlite3.Connection,
|
|
2134
|
+
session_id: int,
|
|
2135
|
+
last_time: datetime,
|
|
2136
|
+
assumed_work: int,
|
|
2137
|
+
) -> None:
|
|
2138
|
+
"""Close a session with end time."""
|
|
2139
|
+
end_time = last_time + timedelta(seconds=assumed_work)
|
|
2140
|
+
conn.execute(
|
|
2141
|
+
"""UPDATE sessions SET
|
|
2142
|
+
end_time = ?,
|
|
2143
|
+
duration_seconds = duration_seconds + ?
|
|
2144
|
+
WHERE id = ?""",
|
|
2145
|
+
(end_time.isoformat(), assumed_work, session_id),
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
# =========================================================================
|
|
2149
|
+
# Time Queries
|
|
2150
|
+
# =========================================================================
|
|
2151
|
+
|
|
2152
|
+
def get_task_time(self, task_id: int, period: str = "all") -> int:
|
|
2153
|
+
"""Get total time spent on a task in seconds."""
|
|
2154
|
+
with self.connection() as conn:
|
|
2155
|
+
if period == "all":
|
|
2156
|
+
row = conn.execute(
|
|
2157
|
+
"SELECT COALESCE(SUM(duration_seconds), 0) as total FROM sessions WHERE task_id = ?",
|
|
2158
|
+
(task_id,),
|
|
2159
|
+
).fetchone()
|
|
2160
|
+
elif period == "week":
|
|
2161
|
+
row = conn.execute(
|
|
2162
|
+
"""SELECT COALESCE(SUM(duration_seconds), 0) as total FROM sessions
|
|
2163
|
+
WHERE task_id = ? AND start_time >= datetime('now', 'localtime', '-7 days')""",
|
|
2164
|
+
(task_id,),
|
|
2165
|
+
).fetchone()
|
|
2166
|
+
elif period == "today":
|
|
2167
|
+
# start_time is stored as a naive local-time ISO string (Python datetime.now()),
|
|
2168
|
+
# so date(start_time) already extracts the local calendar date. Applying 'localtime'
|
|
2169
|
+
# here would re-interpret the input as UTC and add the TZ offset, crossing midnight
|
|
2170
|
+
# for late-evening sessions in east-of-UTC zones (known timezone-window bug).
|
|
2171
|
+
# The 'localtime' modifier is still needed on 'now' because SQLite's now is UTC.
|
|
2172
|
+
row = conn.execute(
|
|
2173
|
+
"""SELECT COALESCE(SUM(duration_seconds), 0) as total FROM sessions
|
|
2174
|
+
WHERE task_id = ? AND date(start_time) = date('now', 'localtime')""",
|
|
2175
|
+
(task_id,),
|
|
2176
|
+
).fetchone()
|
|
2177
|
+
else:
|
|
2178
|
+
row = conn.execute(
|
|
2179
|
+
"SELECT COALESCE(SUM(duration_seconds), 0) as total FROM sessions WHERE task_id = ?",
|
|
2180
|
+
(task_id,),
|
|
2181
|
+
).fetchone()
|
|
2182
|
+
|
|
2183
|
+
return row["total"] if row else 0
|
|
2184
|
+
|
|
2185
|
+
def get_subtask_time_total(self, parent_task_id: int) -> int:
|
|
2186
|
+
"""Get total time spent on all subtasks of a parent task."""
|
|
2187
|
+
with self.connection() as conn:
|
|
2188
|
+
row = conn.execute(
|
|
2189
|
+
"""SELECT COALESCE(SUM(s.duration_seconds), 0) as total
|
|
2190
|
+
FROM sessions s
|
|
2191
|
+
JOIN tasks t ON s.task_id = t.id
|
|
2192
|
+
WHERE t.parent_id = ?""",
|
|
2193
|
+
(parent_task_id,),
|
|
2194
|
+
).fetchone()
|
|
2195
|
+
return row["total"] if row else 0
|
|
2196
|
+
|
|
2197
|
+
def get_task_session_count(self, task_id: int) -> int:
|
|
2198
|
+
"""Get number of sessions for a task."""
|
|
2199
|
+
with self.connection() as conn:
|
|
2200
|
+
row = conn.execute(
|
|
2201
|
+
"SELECT COUNT(*) as count FROM sessions WHERE task_id = ?", (task_id,)
|
|
2202
|
+
).fetchone()
|
|
2203
|
+
return row["count"] if row else 0
|
|
2204
|
+
|
|
2205
|
+
def get_batch_task_times(
|
|
2206
|
+
self, task_ids: List[int], period: str = "all"
|
|
2207
|
+
) -> Dict[int, int]:
|
|
2208
|
+
"""Get time for multiple tasks in ONE query instead of N queries.
|
|
2209
|
+
|
|
2210
|
+
Args:
|
|
2211
|
+
task_ids: List of task IDs to query
|
|
2212
|
+
period: "all", "today", or "week"
|
|
2213
|
+
|
|
2214
|
+
Returns:
|
|
2215
|
+
Dict mapping task_id to seconds spent
|
|
2216
|
+
"""
|
|
2217
|
+
if not task_ids:
|
|
2218
|
+
return {}
|
|
2219
|
+
|
|
2220
|
+
with self.connection() as conn:
|
|
2221
|
+
placeholders = ",".join("?" * len(task_ids))
|
|
2222
|
+
|
|
2223
|
+
if period == "today":
|
|
2224
|
+
# See get_task_time() above for why 'localtime' is dropped from date(start_time).
|
|
2225
|
+
query = f"""
|
|
2226
|
+
SELECT task_id, COALESCE(SUM(duration_seconds), 0) as total
|
|
2227
|
+
FROM sessions
|
|
2228
|
+
WHERE task_id IN ({placeholders}) AND date(start_time) = date('now', 'localtime')
|
|
2229
|
+
GROUP BY task_id
|
|
2230
|
+
"""
|
|
2231
|
+
elif period == "week":
|
|
2232
|
+
query = f"""
|
|
2233
|
+
SELECT task_id, COALESCE(SUM(duration_seconds), 0) as total
|
|
2234
|
+
FROM sessions
|
|
2235
|
+
WHERE task_id IN ({placeholders}) AND start_time >= datetime('now', 'localtime', '-7 days')
|
|
2236
|
+
GROUP BY task_id
|
|
2237
|
+
"""
|
|
2238
|
+
else: # all
|
|
2239
|
+
query = f"""
|
|
2240
|
+
SELECT task_id, COALESCE(SUM(duration_seconds), 0) as total
|
|
2241
|
+
FROM sessions
|
|
2242
|
+
WHERE task_id IN ({placeholders})
|
|
2243
|
+
GROUP BY task_id
|
|
2244
|
+
"""
|
|
2245
|
+
|
|
2246
|
+
rows = conn.execute(query, task_ids).fetchall()
|
|
2247
|
+
result = {row["task_id"]: row["total"] for row in rows}
|
|
2248
|
+
|
|
2249
|
+
# Fill in zeros for tasks with no sessions
|
|
2250
|
+
for task_id in task_ids:
|
|
2251
|
+
if task_id not in result:
|
|
2252
|
+
result[task_id] = 0
|
|
2253
|
+
|
|
2254
|
+
return result
|
|
2255
|
+
|
|
2256
|
+
def get_tasks_by_ids(self, task_ids: List[int]) -> List["Task"]:
|
|
2257
|
+
"""Get multiple tasks in ONE query instead of N queries.
|
|
2258
|
+
|
|
2259
|
+
Args:
|
|
2260
|
+
task_ids: List of task IDs to fetch
|
|
2261
|
+
|
|
2262
|
+
Returns:
|
|
2263
|
+
List of Task objects (order not guaranteed)
|
|
2264
|
+
"""
|
|
2265
|
+
if not task_ids:
|
|
2266
|
+
return []
|
|
2267
|
+
|
|
2268
|
+
with self.connection() as conn:
|
|
2269
|
+
placeholders = ",".join("?" * len(task_ids))
|
|
2270
|
+
rows = conn.execute(
|
|
2271
|
+
f"SELECT * FROM tasks WHERE id IN ({placeholders})", task_ids
|
|
2272
|
+
).fetchall()
|
|
2273
|
+
return [Task.from_row(dict(r)) for r in rows]
|
|
2274
|
+
|
|
2275
|
+
def get_current_session_time(self, task_id: Optional[int] = None) -> int:
|
|
2276
|
+
"""Get working time for current uninterrupted session (WakaTime-style).
|
|
2277
|
+
|
|
2278
|
+
Calculates time from recent heartbeats, accounting for idle gaps.
|
|
2279
|
+
Returns seconds of active working time in the current session.
|
|
2280
|
+
"""
|
|
2281
|
+
idle_timeout = self.idle_timeout_seconds
|
|
2282
|
+
|
|
2283
|
+
with self.connection() as conn:
|
|
2284
|
+
# Query recent heartbeats (last 8 hours) regardless of processed flag
|
|
2285
|
+
# The gap detection algorithm will find the current active session
|
|
2286
|
+
cutoff = (datetime.now() - timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S")
|
|
2287
|
+
|
|
2288
|
+
if task_id:
|
|
2289
|
+
heartbeats = conn.execute(
|
|
2290
|
+
"""SELECT timestamp FROM heartbeats
|
|
2291
|
+
WHERE task_id = ? AND timestamp > ?
|
|
2292
|
+
ORDER BY timestamp ASC""",
|
|
2293
|
+
(task_id, cutoff),
|
|
2294
|
+
).fetchall()
|
|
2295
|
+
else:
|
|
2296
|
+
heartbeats = conn.execute(
|
|
2297
|
+
"""SELECT timestamp FROM heartbeats
|
|
2298
|
+
WHERE timestamp > ?
|
|
2299
|
+
ORDER BY timestamp ASC""",
|
|
2300
|
+
(cutoff,),
|
|
2301
|
+
).fetchall()
|
|
2302
|
+
|
|
2303
|
+
if not heartbeats:
|
|
2304
|
+
return 0
|
|
2305
|
+
|
|
2306
|
+
# Calculate working time with WakaTime algorithm
|
|
2307
|
+
# Gaps > idle_timeout reset the session
|
|
2308
|
+
session_seconds = 0
|
|
2309
|
+
last_time = None
|
|
2310
|
+
|
|
2311
|
+
for hb in heartbeats:
|
|
2312
|
+
hb_time = datetime.fromisoformat(hb["timestamp"])
|
|
2313
|
+
if last_time:
|
|
2314
|
+
gap = (hb_time - last_time).total_seconds()
|
|
2315
|
+
if gap > idle_timeout:
|
|
2316
|
+
# Idle gap - reset current session, keep only recent
|
|
2317
|
+
session_seconds = 0
|
|
2318
|
+
else:
|
|
2319
|
+
session_seconds += gap
|
|
2320
|
+
last_time = hb_time
|
|
2321
|
+
|
|
2322
|
+
# Add assumed work time (2 min) for the last heartbeat to now
|
|
2323
|
+
# Only if we're within idle timeout
|
|
2324
|
+
if last_time:
|
|
2325
|
+
now = datetime.now()
|
|
2326
|
+
gap_to_now = (now - last_time).total_seconds()
|
|
2327
|
+
if gap_to_now <= idle_timeout:
|
|
2328
|
+
# Still active - add assumed work (capped at actual gap)
|
|
2329
|
+
assumed_work = min(120, gap_to_now)
|
|
2330
|
+
session_seconds += assumed_work
|
|
2331
|
+
|
|
2332
|
+
return int(session_seconds)
|
|
2333
|
+
|
|
2334
|
+
@staticmethod
|
|
2335
|
+
def format_duration(seconds: int) -> str:
|
|
2336
|
+
"""Format seconds as human-readable duration."""
|
|
2337
|
+
if seconds < 60:
|
|
2338
|
+
return f"{seconds}s"
|
|
2339
|
+
elif seconds < 3600:
|
|
2340
|
+
return f"{seconds // 60}m"
|
|
2341
|
+
else:
|
|
2342
|
+
hours = seconds // 3600
|
|
2343
|
+
minutes = (seconds % 3600) // 60
|
|
2344
|
+
return f"{hours}h {minutes}m" if minutes else f"{hours}h"
|
|
2345
|
+
|
|
2346
|
+
@staticmethod
|
|
2347
|
+
def format_time_ago(timestamp: Optional[str]) -> str:
|
|
2348
|
+
"""Format timestamp as relative time ago.
|
|
2349
|
+
|
|
2350
|
+
Assumes timestamps are in local time (matching SQLite's
|
|
2351
|
+
datetime('now', 'localtime')).
|
|
2352
|
+
"""
|
|
2353
|
+
if not timestamp:
|
|
2354
|
+
return "never"
|
|
2355
|
+
|
|
2356
|
+
try:
|
|
2357
|
+
dt = datetime.fromisoformat(timestamp)
|
|
2358
|
+
now = datetime.now()
|
|
2359
|
+
diff = now - dt
|
|
2360
|
+
|
|
2361
|
+
if diff.days > 7:
|
|
2362
|
+
return dt.strftime("%b %d")
|
|
2363
|
+
elif diff.days > 0:
|
|
2364
|
+
return f"{diff.days}d ago"
|
|
2365
|
+
elif diff.seconds > 3600:
|
|
2366
|
+
return f"{diff.seconds // 3600}h ago"
|
|
2367
|
+
elif diff.seconds > 60:
|
|
2368
|
+
return f"{diff.seconds // 60}m ago"
|
|
2369
|
+
else:
|
|
2370
|
+
return "just now"
|
|
2371
|
+
except Exception:
|
|
2372
|
+
return "unknown"
|
|
2373
|
+
|
|
2374
|
+
def get_effective_last_updated(self, task: "Task") -> Optional[str]:
|
|
2375
|
+
"""Get effective last updated timestamp for a task.
|
|
2376
|
+
|
|
2377
|
+
Uses the MORE RECENT of:
|
|
2378
|
+
1. Database last_worked_on (from heartbeats)
|
|
2379
|
+
2. File modification time of task files (context.md, tasks.md, etc.)
|
|
2380
|
+
|
|
2381
|
+
This ensures that if files were edited (accurate mtime) but heartbeats
|
|
2382
|
+
were incorrectly assigned to another task, the file mtime is still used.
|
|
2383
|
+
|
|
2384
|
+
Args:
|
|
2385
|
+
task: The Task object to get last updated time for
|
|
2386
|
+
|
|
2387
|
+
Returns:
|
|
2388
|
+
ISO format timestamp string or None if no data available
|
|
2389
|
+
"""
|
|
2390
|
+
db_timestamp = None
|
|
2391
|
+
file_timestamp = None
|
|
2392
|
+
|
|
2393
|
+
# Get database heartbeat timestamp
|
|
2394
|
+
if task.last_worked_on:
|
|
2395
|
+
try:
|
|
2396
|
+
db_timestamp = datetime.fromisoformat(task.last_worked_on)
|
|
2397
|
+
except (ValueError, TypeError):
|
|
2398
|
+
pass
|
|
2399
|
+
|
|
2400
|
+
# Get file modification time from centralized MissionCache root
|
|
2401
|
+
if task.full_path:
|
|
2402
|
+
task_dir = MISSIONCACHE_ROOT / task.full_path
|
|
2403
|
+
if task_dir.exists():
|
|
2404
|
+
task_name = task_dir.name
|
|
2405
|
+
candidate_files = [
|
|
2406
|
+
task_dir / "context.md",
|
|
2407
|
+
task_dir / "tasks.md",
|
|
2408
|
+
task_dir / f"{task_name}-context.md",
|
|
2409
|
+
task_dir / f"{task_name}-tasks.md",
|
|
2410
|
+
task_dir / "README.md",
|
|
2411
|
+
task_dir / "shared-context.md",
|
|
2412
|
+
]
|
|
2413
|
+
|
|
2414
|
+
latest_mtime = None
|
|
2415
|
+
for filepath in candidate_files:
|
|
2416
|
+
if filepath.exists():
|
|
2417
|
+
try:
|
|
2418
|
+
mtime = filepath.stat().st_mtime
|
|
2419
|
+
if latest_mtime is None or mtime > latest_mtime:
|
|
2420
|
+
latest_mtime = mtime
|
|
2421
|
+
except Exception:
|
|
2422
|
+
continue
|
|
2423
|
+
|
|
2424
|
+
if latest_mtime:
|
|
2425
|
+
file_timestamp = datetime.fromtimestamp(latest_mtime)
|
|
2426
|
+
|
|
2427
|
+
# Return the MORE RECENT of the two timestamps
|
|
2428
|
+
if db_timestamp and file_timestamp:
|
|
2429
|
+
effective = max(db_timestamp, file_timestamp)
|
|
2430
|
+
elif db_timestamp:
|
|
2431
|
+
effective = db_timestamp
|
|
2432
|
+
elif file_timestamp:
|
|
2433
|
+
effective = file_timestamp
|
|
2434
|
+
else:
|
|
2435
|
+
return None
|
|
2436
|
+
|
|
2437
|
+
return effective.strftime("%Y-%m-%d %H:%M:%S")
|
|
2438
|
+
|
|
2439
|
+
# =========================================================================
|
|
2440
|
+
# Claude Transcript Time Tracking
|
|
2441
|
+
# =========================================================================
|
|
2442
|
+
|
|
2443
|
+
@staticmethod
|
|
2444
|
+
def encode_path_for_claude(path: str) -> str:
|
|
2445
|
+
"""Encode a path to match Claude's project directory naming.
|
|
2446
|
+
|
|
2447
|
+
Claude encodes paths by replacing '/' with '-' and '_' with '-'.
|
|
2448
|
+
Example: /home/user/project -> -home-user-project
|
|
2449
|
+
"""
|
|
2450
|
+
return path.replace("/", "-").replace("_", "-")
|
|
2451
|
+
|
|
2452
|
+
def get_session_time_from_transcripts(
|
|
2453
|
+
self, task_name: str, repo_path: str
|
|
2454
|
+
) -> Dict[str, Any]:
|
|
2455
|
+
"""Get session time by parsing Claude JSONL transcripts.
|
|
2456
|
+
|
|
2457
|
+
Scans Claude's project directory for sessions that mention the task name.
|
|
2458
|
+
|
|
2459
|
+
Args:
|
|
2460
|
+
task_name: Name of the task to search for
|
|
2461
|
+
repo_path: Absolute path to the repository
|
|
2462
|
+
|
|
2463
|
+
Returns:
|
|
2464
|
+
Dict with time_total_seconds, session_count, last_session_timestamp
|
|
2465
|
+
"""
|
|
2466
|
+
projects_dir = Path.home() / ".claude" / "projects"
|
|
2467
|
+
encoded_path = self.encode_path_for_claude(repo_path)
|
|
2468
|
+
project_dir = projects_dir / encoded_path
|
|
2469
|
+
|
|
2470
|
+
if not project_dir.exists():
|
|
2471
|
+
return {
|
|
2472
|
+
"time_total_seconds": 0,
|
|
2473
|
+
"session_count": 0,
|
|
2474
|
+
"last_session_timestamp": None,
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
total_seconds = 0
|
|
2478
|
+
session_count = 0
|
|
2479
|
+
last_session_timestamp = None
|
|
2480
|
+
|
|
2481
|
+
# Scan all JSONL files
|
|
2482
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
2483
|
+
try:
|
|
2484
|
+
session_info = self._parse_session_for_task(jsonl_file, task_name)
|
|
2485
|
+
if session_info:
|
|
2486
|
+
total_seconds += session_info["duration_seconds"]
|
|
2487
|
+
session_count += 1
|
|
2488
|
+
if session_info["end_timestamp"]:
|
|
2489
|
+
if (
|
|
2490
|
+
last_session_timestamp is None
|
|
2491
|
+
or session_info["end_timestamp"] > last_session_timestamp
|
|
2492
|
+
):
|
|
2493
|
+
last_session_timestamp = session_info["end_timestamp"]
|
|
2494
|
+
except Exception:
|
|
2495
|
+
continue # Skip corrupted files
|
|
2496
|
+
|
|
2497
|
+
return {
|
|
2498
|
+
"time_total_seconds": total_seconds,
|
|
2499
|
+
"session_count": session_count,
|
|
2500
|
+
"last_session_timestamp": last_session_timestamp,
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
def _parse_session_for_task(
|
|
2504
|
+
self, jsonl_path: Path, task_name: str
|
|
2505
|
+
) -> Optional[Dict[str, Any]]:
|
|
2506
|
+
"""Parse a JSONL session file to check if it mentions the task.
|
|
2507
|
+
|
|
2508
|
+
Args:
|
|
2509
|
+
jsonl_path: Path to the JSONL file
|
|
2510
|
+
task_name: Task name to search for
|
|
2511
|
+
|
|
2512
|
+
Returns:
|
|
2513
|
+
Dict with duration_seconds and end_timestamp if task is mentioned, None otherwise
|
|
2514
|
+
"""
|
|
2515
|
+
first_timestamp = None
|
|
2516
|
+
last_timestamp = None
|
|
2517
|
+
task_mentioned = False
|
|
2518
|
+
|
|
2519
|
+
# Read file and check for task mentions
|
|
2520
|
+
# Search for task name in the raw line (faster and catches paths like dev/active/task-name)
|
|
2521
|
+
with open(jsonl_path, "r") as f:
|
|
2522
|
+
for line in f:
|
|
2523
|
+
line_stripped = line.strip()
|
|
2524
|
+
if not line_stripped:
|
|
2525
|
+
continue
|
|
2526
|
+
|
|
2527
|
+
# Check for task mention in raw line (catches paths and all references)
|
|
2528
|
+
if not task_mentioned and task_name in line:
|
|
2529
|
+
task_mentioned = True
|
|
2530
|
+
|
|
2531
|
+
try:
|
|
2532
|
+
entry = json.loads(line_stripped)
|
|
2533
|
+
except json.JSONDecodeError:
|
|
2534
|
+
continue
|
|
2535
|
+
|
|
2536
|
+
# Check for timestamp
|
|
2537
|
+
timestamp_str = entry.get("timestamp")
|
|
2538
|
+
if timestamp_str:
|
|
2539
|
+
try:
|
|
2540
|
+
timestamp = datetime.fromisoformat(
|
|
2541
|
+
timestamp_str.replace("Z", "+00:00")
|
|
2542
|
+
)
|
|
2543
|
+
if first_timestamp is None:
|
|
2544
|
+
first_timestamp = timestamp
|
|
2545
|
+
last_timestamp = timestamp
|
|
2546
|
+
except Exception:
|
|
2547
|
+
pass
|
|
2548
|
+
|
|
2549
|
+
if not task_mentioned or not first_timestamp or not last_timestamp:
|
|
2550
|
+
return None
|
|
2551
|
+
|
|
2552
|
+
duration = (last_timestamp - first_timestamp).total_seconds()
|
|
2553
|
+
return {
|
|
2554
|
+
"duration_seconds": int(duration),
|
|
2555
|
+
"end_timestamp": last_timestamp.isoformat(),
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
# =========================================================================
|
|
2559
|
+
# Orbit Progress Parsing
|
|
2560
|
+
# =========================================================================
|
|
2561
|
+
|
|
2562
|
+
def _parse_summary_field(self, content: str, field_name: str) -> str:
|
|
2563
|
+
"""Parse a summary field like **Remaining:** or **Summary:** from task content.
|
|
2564
|
+
|
|
2565
|
+
Args:
|
|
2566
|
+
content: Markdown content
|
|
2567
|
+
field_name: Field name to look for (e.g., "Remaining", "Summary")
|
|
2568
|
+
|
|
2569
|
+
Returns:
|
|
2570
|
+
The field value or empty string if not found
|
|
2571
|
+
"""
|
|
2572
|
+
# Match **Remaining:** value or **Summary:** value (single line)
|
|
2573
|
+
pattern = rf"\*\*{field_name}:\*\*\s*(.+?)(?:\n|$)"
|
|
2574
|
+
match = re.search(pattern, content, re.IGNORECASE)
|
|
2575
|
+
if match:
|
|
2576
|
+
return match.group(1).strip()
|
|
2577
|
+
return ""
|
|
2578
|
+
|
|
2579
|
+
def parse_orbit_progress(
|
|
2580
|
+
self, repo_path: str, task_full_path: str, parent_id: Optional[int] = None
|
|
2581
|
+
) -> Dict[str, Any]:
|
|
2582
|
+
"""Parse MissionCache task file to extract progress information.
|
|
2583
|
+
|
|
2584
|
+
Args:
|
|
2585
|
+
repo_path: Absolute path to the repository (legacy, used as fallback)
|
|
2586
|
+
task_full_path: Relative path like 'active/task-name' or legacy 'dev/active/task-name'
|
|
2587
|
+
parent_id: If set, this is a subtask
|
|
2588
|
+
|
|
2589
|
+
Returns:
|
|
2590
|
+
Dict with progress info or {"has_docs": False} if not found
|
|
2591
|
+
"""
|
|
2592
|
+
# Extract task name from path (last component)
|
|
2593
|
+
task_name = Path(task_full_path).name
|
|
2594
|
+
# Resolve via centralized MissionCache root (strip legacy dev/ prefix)
|
|
2595
|
+
normalized = (
|
|
2596
|
+
task_full_path[4:] if task_full_path.startswith("dev/") else task_full_path
|
|
2597
|
+
)
|
|
2598
|
+
task_dir = MISSIONCACHE_ROOT / normalized
|
|
2599
|
+
|
|
2600
|
+
# Try multiple file patterns in order of priority
|
|
2601
|
+
tasks_file = None
|
|
2602
|
+
context_file = None
|
|
2603
|
+
is_parent_task = False
|
|
2604
|
+
|
|
2605
|
+
# Pattern 1: Standalone task format ({task_name}-tasks.md)
|
|
2606
|
+
standalone_tasks = task_dir / f"{task_name}-tasks.md"
|
|
2607
|
+
standalone_context = task_dir / f"{task_name}-context.md"
|
|
2608
|
+
|
|
2609
|
+
# Pattern 2: Subtask format (tasks.md, context.md)
|
|
2610
|
+
subtask_tasks = task_dir / "tasks.md"
|
|
2611
|
+
subtask_context = task_dir / "context.md"
|
|
2612
|
+
|
|
2613
|
+
# Pattern 3: Parent task format (README.md or shared-context.md)
|
|
2614
|
+
parent_readme = task_dir / "README.md"
|
|
2615
|
+
parent_context = task_dir / "shared-context.md"
|
|
2616
|
+
|
|
2617
|
+
# Pattern 4: Completed folder (flat format)
|
|
2618
|
+
completed_tasks = MISSIONCACHE_ROOT / "completed" / f"{task_name}-tasks.md"
|
|
2619
|
+
completed_context = MISSIONCACHE_ROOT / "completed" / f"{task_name}-context.md"
|
|
2620
|
+
|
|
2621
|
+
# Pattern 5: Completed folder (subdirectory format)
|
|
2622
|
+
completed_subdir_tasks = (
|
|
2623
|
+
MISSIONCACHE_ROOT / "completed" / task_name / f"{task_name}-tasks.md"
|
|
2624
|
+
)
|
|
2625
|
+
completed_subdir_context = (
|
|
2626
|
+
MISSIONCACHE_ROOT / "completed" / task_name / f"{task_name}-context.md"
|
|
2627
|
+
)
|
|
2628
|
+
|
|
2629
|
+
if standalone_tasks.exists():
|
|
2630
|
+
tasks_file = standalone_tasks
|
|
2631
|
+
context_file = standalone_context if standalone_context.exists() else None
|
|
2632
|
+
elif subtask_tasks.exists():
|
|
2633
|
+
tasks_file = subtask_tasks
|
|
2634
|
+
context_file = subtask_context if subtask_context.exists() else None
|
|
2635
|
+
elif parent_readme.exists() or parent_context.exists():
|
|
2636
|
+
# Parent task - use README.md or shared-context.md
|
|
2637
|
+
is_parent_task = True
|
|
2638
|
+
tasks_file = parent_readme if parent_readme.exists() else parent_context
|
|
2639
|
+
context_file = parent_context if parent_context.exists() else parent_readme
|
|
2640
|
+
elif completed_tasks.exists():
|
|
2641
|
+
tasks_file = completed_tasks
|
|
2642
|
+
context_file = completed_context if completed_context.exists() else None
|
|
2643
|
+
elif completed_subdir_tasks.exists():
|
|
2644
|
+
tasks_file = completed_subdir_tasks
|
|
2645
|
+
context_file = (
|
|
2646
|
+
completed_subdir_context if completed_subdir_context.exists() else None
|
|
2647
|
+
)
|
|
2648
|
+
elif completed_subdir_context.exists():
|
|
2649
|
+
# Completed task with only context file (no tasks file)
|
|
2650
|
+
tasks_file = completed_subdir_context
|
|
2651
|
+
context_file = completed_subdir_context
|
|
2652
|
+
|
|
2653
|
+
if not tasks_file:
|
|
2654
|
+
return {"has_docs": False}
|
|
2655
|
+
|
|
2656
|
+
try:
|
|
2657
|
+
content = tasks_file.read_text()
|
|
2658
|
+
except Exception:
|
|
2659
|
+
return {"has_docs": False}
|
|
2660
|
+
|
|
2661
|
+
# Parse status
|
|
2662
|
+
status_match = re.search(r"\*\*Status:\*\*\s*(.+)", content)
|
|
2663
|
+
status = status_match.group(1).strip() if status_match else "Unknown"
|
|
2664
|
+
|
|
2665
|
+
# Parse started date
|
|
2666
|
+
started_match = re.search(r"\*\*Started:\*\*\s*(\d{4}-\d{2}-\d{2})", content)
|
|
2667
|
+
started = started_match.group(1) if started_match else None
|
|
2668
|
+
|
|
2669
|
+
# Parse last updated
|
|
2670
|
+
updated_match = re.search(
|
|
2671
|
+
r"\*\*Last Updated:\*\*\s*(\d{4}-\d{2}-\d{2})", content
|
|
2672
|
+
)
|
|
2673
|
+
last_updated = updated_match.group(1) if updated_match else None
|
|
2674
|
+
|
|
2675
|
+
# Count checkboxes
|
|
2676
|
+
completed_items = len(re.findall(r"- \[x\]", content, re.IGNORECASE))
|
|
2677
|
+
pending_items = len(re.findall(r"- \[ \]", content))
|
|
2678
|
+
total_items = completed_items + pending_items
|
|
2679
|
+
|
|
2680
|
+
# Calculate completion percentage
|
|
2681
|
+
completion_pct = (
|
|
2682
|
+
int((completed_items / total_items * 100)) if total_items > 0 else 0
|
|
2683
|
+
)
|
|
2684
|
+
|
|
2685
|
+
# Find phases and current phase
|
|
2686
|
+
phase_pattern = r"## (Phase \d+[:\s]+[^\n]+)"
|
|
2687
|
+
phases = re.findall(phase_pattern, content)
|
|
2688
|
+
|
|
2689
|
+
# Find current phase (first phase with unchecked items after it)
|
|
2690
|
+
current_phase = None
|
|
2691
|
+
phases_remaining = 0
|
|
2692
|
+
|
|
2693
|
+
# Split content by phases
|
|
2694
|
+
phase_sections = re.split(r"## Phase \d+", content)
|
|
2695
|
+
for i, section in enumerate(
|
|
2696
|
+
phase_sections[1:], 1
|
|
2697
|
+
): # Skip content before first phase
|
|
2698
|
+
if "- [ ]" in section:
|
|
2699
|
+
if current_phase is None and i <= len(phases):
|
|
2700
|
+
current_phase = phases[i - 1]
|
|
2701
|
+
phases_remaining += 1
|
|
2702
|
+
|
|
2703
|
+
# Parse **Remaining:** field from task file (written by Claude via /update-orbit)
|
|
2704
|
+
remaining_summary = self._parse_summary_field(content, "Remaining")
|
|
2705
|
+
if not remaining_summary:
|
|
2706
|
+
# Fallback to simple progress indicator
|
|
2707
|
+
if completion_pct == 100:
|
|
2708
|
+
remaining_summary = f"✓ Complete ({total_items} tasks)"
|
|
2709
|
+
elif completion_pct == 0:
|
|
2710
|
+
remaining_summary = f"Not started ({total_items} tasks)"
|
|
2711
|
+
else:
|
|
2712
|
+
remaining_summary = f"{pending_items} of {total_items} tasks remaining"
|
|
2713
|
+
|
|
2714
|
+
# Extract task description from context file
|
|
2715
|
+
description = ""
|
|
2716
|
+
if context_file and context_file.exists():
|
|
2717
|
+
try:
|
|
2718
|
+
ctx_content = context_file.read_text()
|
|
2719
|
+
|
|
2720
|
+
# Helper to check if a line is metadata or navigation
|
|
2721
|
+
def is_metadata(line: str) -> bool:
|
|
2722
|
+
line = line.strip()
|
|
2723
|
+
if not line:
|
|
2724
|
+
return True
|
|
2725
|
+
if (
|
|
2726
|
+
line.startswith("**") and ":" in line[:20]
|
|
2727
|
+
): # Bold metadata like **Status:**
|
|
2728
|
+
return True
|
|
2729
|
+
if line.startswith("|"): # Table row
|
|
2730
|
+
return True
|
|
2731
|
+
if line.startswith(">"): # Blockquote (often navigation)
|
|
2732
|
+
return True
|
|
2733
|
+
if re.match(r"^\[.*\]\(.*\)$", line): # Standalone link
|
|
2734
|
+
return True
|
|
2735
|
+
if "shared-context" in line.lower(): # Navigation to shared context
|
|
2736
|
+
return True
|
|
2737
|
+
return False
|
|
2738
|
+
|
|
2739
|
+
# Pattern 1: Look for dedicated "## Description" section first (highest priority)
|
|
2740
|
+
desc_match = re.search(
|
|
2741
|
+
r"##\s*Description\s*\n+((?:[^\n#]+\n?)+)",
|
|
2742
|
+
ctx_content,
|
|
2743
|
+
re.IGNORECASE,
|
|
2744
|
+
)
|
|
2745
|
+
if desc_match:
|
|
2746
|
+
lines = desc_match.group(1).strip().split("\n")
|
|
2747
|
+
# Filter out metadata, bullets, and numbered lists - we want prose
|
|
2748
|
+
prose_lines = []
|
|
2749
|
+
for line in lines:
|
|
2750
|
+
line = line.strip()
|
|
2751
|
+
if not line:
|
|
2752
|
+
continue
|
|
2753
|
+
if is_metadata(line):
|
|
2754
|
+
continue
|
|
2755
|
+
# Skip bullets and numbered lists
|
|
2756
|
+
if re.match(r"^[-*•]\s", line) or re.match(r"^\d+\.\s", line):
|
|
2757
|
+
continue
|
|
2758
|
+
# Skip lines that look like code or technical (all caps, backticks)
|
|
2759
|
+
if "`" in line or line.isupper():
|
|
2760
|
+
continue
|
|
2761
|
+
prose_lines.append(line)
|
|
2762
|
+
if prose_lines:
|
|
2763
|
+
description = " ".join(prose_lines[:2])
|
|
2764
|
+
|
|
2765
|
+
# Pattern 2: Look for other descriptive sections
|
|
2766
|
+
if not description:
|
|
2767
|
+
for section_name in [
|
|
2768
|
+
"Overview",
|
|
2769
|
+
"Summary",
|
|
2770
|
+
"Goal",
|
|
2771
|
+
"What",
|
|
2772
|
+
"About",
|
|
2773
|
+
]:
|
|
2774
|
+
section_match = re.search(
|
|
2775
|
+
rf"##\s*{section_name}[^\n]*\n+((?:[^\n#]+\n?)+)",
|
|
2776
|
+
ctx_content,
|
|
2777
|
+
re.IGNORECASE,
|
|
2778
|
+
)
|
|
2779
|
+
if section_match:
|
|
2780
|
+
lines = section_match.group(1).strip().split("\n")
|
|
2781
|
+
content_lines = [l for l in lines if not is_metadata(l)]
|
|
2782
|
+
if content_lines:
|
|
2783
|
+
description = " ".join(content_lines[:2])
|
|
2784
|
+
break
|
|
2785
|
+
|
|
2786
|
+
# Pattern 2: First non-metadata paragraph after any heading
|
|
2787
|
+
if not description:
|
|
2788
|
+
paragraphs = re.split(r"\n\n+", ctx_content)
|
|
2789
|
+
for para in paragraphs:
|
|
2790
|
+
para = para.strip()
|
|
2791
|
+
# Skip headings and metadata
|
|
2792
|
+
if para.startswith("#") or is_metadata(para):
|
|
2793
|
+
continue
|
|
2794
|
+
# Skip if it's a section of metadata lines
|
|
2795
|
+
lines = para.split("\n")
|
|
2796
|
+
content_lines = [l for l in lines if not is_metadata(l)]
|
|
2797
|
+
if content_lines:
|
|
2798
|
+
description = " ".join(content_lines[:2])
|
|
2799
|
+
break
|
|
2800
|
+
|
|
2801
|
+
# Clean up description (let dashboard handle display truncation)
|
|
2802
|
+
description = re.sub(r"\s+", " ", description).strip()
|
|
2803
|
+
# Keep full single sentence (up to ~100 chars for flexibility)
|
|
2804
|
+
if len(description) > 100:
|
|
2805
|
+
description = description[:97] + "..."
|
|
2806
|
+
except Exception:
|
|
2807
|
+
pass
|
|
2808
|
+
|
|
2809
|
+
# For parent tasks without parsed phases, try to count subtasks
|
|
2810
|
+
if is_parent_task and total_items == 0:
|
|
2811
|
+
# Count subdirectories as subtasks
|
|
2812
|
+
subtask_dirs = [
|
|
2813
|
+
d
|
|
2814
|
+
for d in task_dir.iterdir()
|
|
2815
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
2816
|
+
]
|
|
2817
|
+
if subtask_dirs:
|
|
2818
|
+
total_items = len(subtask_dirs)
|
|
2819
|
+
remaining_summary = f"Parent task with {total_items} subtasks"
|
|
2820
|
+
|
|
2821
|
+
# Parse **Summary:** field from task file (written by Claude via /update-orbit)
|
|
2822
|
+
completed_summary = self._parse_summary_field(content, "Summary")
|
|
2823
|
+
if not completed_summary:
|
|
2824
|
+
# Fallback to simple completion indicator
|
|
2825
|
+
if total_items > 0:
|
|
2826
|
+
completed_summary = f"Completed {total_items} tasks"
|
|
2827
|
+
else:
|
|
2828
|
+
completed_summary = "Task completed"
|
|
2829
|
+
|
|
2830
|
+
return {
|
|
2831
|
+
"has_docs": True,
|
|
2832
|
+
"status": status,
|
|
2833
|
+
"started": started,
|
|
2834
|
+
"last_updated": last_updated,
|
|
2835
|
+
"completion_pct": completion_pct,
|
|
2836
|
+
"completed_count": completed_items,
|
|
2837
|
+
"total_count": total_items,
|
|
2838
|
+
"current_phase": current_phase,
|
|
2839
|
+
"remaining_summary": remaining_summary,
|
|
2840
|
+
"completed_summary": completed_summary,
|
|
2841
|
+
"phases_remaining": phases_remaining,
|
|
2842
|
+
"phases_total": len(phases),
|
|
2843
|
+
"description": description,
|
|
2844
|
+
"is_parent_task": is_parent_task,
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
# =========================================================================
|
|
2848
|
+
# Pruning
|
|
2849
|
+
# =========================================================================
|
|
2850
|
+
|
|
2851
|
+
def prune_completed_tasks(self, retention_days: Optional[int] = None) -> int:
|
|
2852
|
+
"""Archive completed tasks older than retention period."""
|
|
2853
|
+
days = retention_days or self.prune_after_days
|
|
2854
|
+
|
|
2855
|
+
with self.connection() as conn:
|
|
2856
|
+
cursor = conn.execute(
|
|
2857
|
+
"""UPDATE tasks SET status = 'archived', archived_at = datetime('now', 'localtime')
|
|
2858
|
+
WHERE status = 'completed'
|
|
2859
|
+
AND completed_at IS NOT NULL
|
|
2860
|
+
AND julianday('now') - julianday(completed_at) > ?""",
|
|
2861
|
+
(days,),
|
|
2862
|
+
)
|
|
2863
|
+
conn.commit()
|
|
2864
|
+
return cursor.rowcount
|
|
2865
|
+
|
|
2866
|
+
# =========================================================================
|
|
2867
|
+
# Auto Execution Management
|
|
2868
|
+
# =========================================================================
|
|
2869
|
+
|
|
2870
|
+
def create_auto_execution(
|
|
2871
|
+
self,
|
|
2872
|
+
task_id: int,
|
|
2873
|
+
mode: str = "parallel",
|
|
2874
|
+
worker_count: Optional[int] = None,
|
|
2875
|
+
total_subtasks: int = 0,
|
|
2876
|
+
) -> int:
|
|
2877
|
+
"""Create a new auto execution run.
|
|
2878
|
+
|
|
2879
|
+
Returns the execution ID.
|
|
2880
|
+
"""
|
|
2881
|
+
with self.connection() as conn:
|
|
2882
|
+
cursor = conn.execute(
|
|
2883
|
+
"""INSERT INTO auto_executions
|
|
2884
|
+
(task_id, mode, worker_count, total_subtasks)
|
|
2885
|
+
VALUES (?, ?, ?, ?)""",
|
|
2886
|
+
(task_id, mode, worker_count, total_subtasks),
|
|
2887
|
+
)
|
|
2888
|
+
conn.commit()
|
|
2889
|
+
return cursor.lastrowid
|
|
2890
|
+
|
|
2891
|
+
def get_auto_execution(self, execution_id: int) -> Optional[AutoExecution]:
|
|
2892
|
+
"""Get an auto execution by ID."""
|
|
2893
|
+
with self.connection() as conn:
|
|
2894
|
+
cursor = conn.execute(
|
|
2895
|
+
"SELECT * FROM auto_executions WHERE id = ?",
|
|
2896
|
+
(execution_id,),
|
|
2897
|
+
)
|
|
2898
|
+
row = cursor.fetchone()
|
|
2899
|
+
return AutoExecution.from_row(row) if row else None
|
|
2900
|
+
|
|
2901
|
+
def get_auto_executions_for_task(
|
|
2902
|
+
self, task_id: int, limit: int = 10
|
|
2903
|
+
) -> List[AutoExecution]:
|
|
2904
|
+
"""Get recent auto executions for a task."""
|
|
2905
|
+
with self.connection() as conn:
|
|
2906
|
+
cursor = conn.execute(
|
|
2907
|
+
"""SELECT * FROM auto_executions
|
|
2908
|
+
WHERE task_id = ?
|
|
2909
|
+
ORDER BY started_at DESC
|
|
2910
|
+
LIMIT ?""",
|
|
2911
|
+
(task_id, limit),
|
|
2912
|
+
)
|
|
2913
|
+
return [AutoExecution.from_row(row) for row in cursor.fetchall()]
|
|
2914
|
+
|
|
2915
|
+
def get_running_auto_executions(self) -> List[AutoExecution]:
|
|
2916
|
+
"""Get all currently running auto executions."""
|
|
2917
|
+
with self.connection() as conn:
|
|
2918
|
+
cursor = conn.execute(
|
|
2919
|
+
"SELECT * FROM auto_executions WHERE status = 'running' ORDER BY started_at DESC"
|
|
2920
|
+
)
|
|
2921
|
+
return [AutoExecution.from_row(row) for row in cursor.fetchall()]
|
|
2922
|
+
|
|
2923
|
+
def update_auto_execution(
|
|
2924
|
+
self,
|
|
2925
|
+
execution_id: int,
|
|
2926
|
+
status: Optional[str] = None,
|
|
2927
|
+
completed_subtasks: Optional[int] = None,
|
|
2928
|
+
failed_subtasks: Optional[int] = None,
|
|
2929
|
+
error_message: Optional[str] = None,
|
|
2930
|
+
) -> bool:
|
|
2931
|
+
"""Update an auto execution's status/progress."""
|
|
2932
|
+
updates = []
|
|
2933
|
+
values = []
|
|
2934
|
+
|
|
2935
|
+
if status is not None:
|
|
2936
|
+
updates.append("status = ?")
|
|
2937
|
+
values.append(status)
|
|
2938
|
+
if status in ("completed", "failed", "cancelled"):
|
|
2939
|
+
updates.append("completed_at = datetime('now', 'localtime')")
|
|
2940
|
+
|
|
2941
|
+
if completed_subtasks is not None:
|
|
2942
|
+
updates.append("completed_subtasks = ?")
|
|
2943
|
+
values.append(completed_subtasks)
|
|
2944
|
+
|
|
2945
|
+
if failed_subtasks is not None:
|
|
2946
|
+
updates.append("failed_subtasks = ?")
|
|
2947
|
+
values.append(failed_subtasks)
|
|
2948
|
+
|
|
2949
|
+
if error_message is not None:
|
|
2950
|
+
updates.append("error_message = ?")
|
|
2951
|
+
values.append(error_message)
|
|
2952
|
+
|
|
2953
|
+
if not updates:
|
|
2954
|
+
return False
|
|
2955
|
+
|
|
2956
|
+
values.append(execution_id)
|
|
2957
|
+
|
|
2958
|
+
with self.connection() as conn:
|
|
2959
|
+
cursor = conn.execute(
|
|
2960
|
+
f"UPDATE auto_executions SET {', '.join(updates)} WHERE id = ?",
|
|
2961
|
+
values,
|
|
2962
|
+
)
|
|
2963
|
+
conn.commit()
|
|
2964
|
+
return cursor.rowcount > 0
|
|
2965
|
+
|
|
2966
|
+
def add_auto_execution_log(
|
|
2967
|
+
self,
|
|
2968
|
+
execution_id: int,
|
|
2969
|
+
message: str,
|
|
2970
|
+
level: str = "info",
|
|
2971
|
+
worker_id: Optional[int] = None,
|
|
2972
|
+
subtask_id: Optional[str] = None,
|
|
2973
|
+
) -> int:
|
|
2974
|
+
"""Add a log entry to an auto execution.
|
|
2975
|
+
|
|
2976
|
+
Returns the log entry ID.
|
|
2977
|
+
"""
|
|
2978
|
+
with self.connection() as conn:
|
|
2979
|
+
cursor = conn.execute(
|
|
2980
|
+
"""INSERT INTO auto_execution_logs
|
|
2981
|
+
(execution_id, message, level, worker_id, subtask_id)
|
|
2982
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
2983
|
+
(execution_id, message, level, worker_id, subtask_id),
|
|
2984
|
+
)
|
|
2985
|
+
conn.commit()
|
|
2986
|
+
return cursor.lastrowid
|
|
2987
|
+
|
|
2988
|
+
def get_auto_execution_logs(
|
|
2989
|
+
self,
|
|
2990
|
+
execution_id: int,
|
|
2991
|
+
since_id: Optional[int] = None,
|
|
2992
|
+
limit: int = 1000,
|
|
2993
|
+
level: Optional[str] = None,
|
|
2994
|
+
worker_id: Optional[int] = None,
|
|
2995
|
+
subtask_id: Optional[str] = None,
|
|
2996
|
+
) -> List[AutoExecutionLog]:
|
|
2997
|
+
"""Get log entries for an auto execution.
|
|
2998
|
+
|
|
2999
|
+
Args:
|
|
3000
|
+
execution_id: The execution to get logs for
|
|
3001
|
+
since_id: Only return logs with ID > this value (for streaming)
|
|
3002
|
+
limit: Maximum number of logs to return
|
|
3003
|
+
level: Filter by log level
|
|
3004
|
+
worker_id: Filter by worker
|
|
3005
|
+
subtask_id: Filter by subtask
|
|
3006
|
+
|
|
3007
|
+
Returns list of log entries ordered by timestamp.
|
|
3008
|
+
"""
|
|
3009
|
+
conditions = ["execution_id = ?"]
|
|
3010
|
+
values: List[Any] = [execution_id]
|
|
3011
|
+
|
|
3012
|
+
if since_id is not None:
|
|
3013
|
+
conditions.append("id > ?")
|
|
3014
|
+
values.append(since_id)
|
|
3015
|
+
|
|
3016
|
+
if level is not None:
|
|
3017
|
+
conditions.append("level = ?")
|
|
3018
|
+
values.append(level)
|
|
3019
|
+
|
|
3020
|
+
if worker_id is not None:
|
|
3021
|
+
conditions.append("worker_id = ?")
|
|
3022
|
+
values.append(worker_id)
|
|
3023
|
+
|
|
3024
|
+
if subtask_id is not None:
|
|
3025
|
+
conditions.append("subtask_id = ?")
|
|
3026
|
+
values.append(subtask_id)
|
|
3027
|
+
|
|
3028
|
+
values.append(limit)
|
|
3029
|
+
|
|
3030
|
+
with self.connection() as conn:
|
|
3031
|
+
cursor = conn.execute(
|
|
3032
|
+
f"""SELECT * FROM auto_execution_logs
|
|
3033
|
+
WHERE {" AND ".join(conditions)}
|
|
3034
|
+
ORDER BY timestamp ASC, id ASC
|
|
3035
|
+
LIMIT ?""",
|
|
3036
|
+
values,
|
|
3037
|
+
)
|
|
3038
|
+
return [AutoExecutionLog.from_row(row) for row in cursor.fetchall()]
|
|
3039
|
+
|
|
3040
|
+
def delete_auto_execution_logs(
|
|
3041
|
+
self, execution_id: int, older_than_days: int = 7
|
|
3042
|
+
) -> int:
|
|
3043
|
+
"""Delete old log entries for cleanup.
|
|
3044
|
+
|
|
3045
|
+
Returns count of deleted entries.
|
|
3046
|
+
"""
|
|
3047
|
+
with self.connection() as conn:
|
|
3048
|
+
cursor = conn.execute(
|
|
3049
|
+
"""DELETE FROM auto_execution_logs
|
|
3050
|
+
WHERE execution_id = ?
|
|
3051
|
+
AND julianday('now') - julianday(timestamp) > ?""",
|
|
3052
|
+
(execution_id, older_than_days),
|
|
3053
|
+
)
|
|
3054
|
+
conn.commit()
|
|
3055
|
+
return cursor.rowcount
|
|
3056
|
+
|
|
3057
|
+
def cleanup_old_auto_executions(
|
|
3058
|
+
self,
|
|
3059
|
+
keep_per_task: int = 10,
|
|
3060
|
+
older_than_days: int = 30,
|
|
3061
|
+
) -> dict:
|
|
3062
|
+
"""Clean up old auto executions and their logs.
|
|
3063
|
+
|
|
3064
|
+
This method implements a retention policy:
|
|
3065
|
+
1. Keep at most `keep_per_task` executions per task
|
|
3066
|
+
2. Delete executions older than `older_than_days` days
|
|
3067
|
+
3. Cascade deletes logs via foreign key constraint
|
|
3068
|
+
|
|
3069
|
+
Returns dict with counts of deleted executions and logs.
|
|
3070
|
+
"""
|
|
3071
|
+
with self.connection() as conn:
|
|
3072
|
+
# First, find executions to delete based on age
|
|
3073
|
+
old_executions = conn.execute(
|
|
3074
|
+
"""SELECT id FROM auto_executions
|
|
3075
|
+
WHERE julianday('now') - julianday(started_at) > ?
|
|
3076
|
+
AND status != 'running'""",
|
|
3077
|
+
(older_than_days,),
|
|
3078
|
+
).fetchall()
|
|
3079
|
+
old_ids = {row[0] for row in old_executions}
|
|
3080
|
+
|
|
3081
|
+
# Find executions to delete based on per-task limit
|
|
3082
|
+
# Keep the most recent N per task
|
|
3083
|
+
excess_executions = conn.execute(
|
|
3084
|
+
"""SELECT id FROM auto_executions
|
|
3085
|
+
WHERE id NOT IN (
|
|
3086
|
+
SELECT id FROM (
|
|
3087
|
+
SELECT id, task_id,
|
|
3088
|
+
ROW_NUMBER() OVER (
|
|
3089
|
+
PARTITION BY task_id
|
|
3090
|
+
ORDER BY started_at DESC
|
|
3091
|
+
) as rn
|
|
3092
|
+
FROM auto_executions
|
|
3093
|
+
) WHERE rn <= ?
|
|
3094
|
+
)
|
|
3095
|
+
AND status != 'running'""",
|
|
3096
|
+
(keep_per_task,),
|
|
3097
|
+
).fetchall()
|
|
3098
|
+
excess_ids = {row[0] for row in excess_executions}
|
|
3099
|
+
|
|
3100
|
+
# Combine IDs to delete
|
|
3101
|
+
ids_to_delete = old_ids | excess_ids
|
|
3102
|
+
if not ids_to_delete:
|
|
3103
|
+
return {"executions_deleted": 0, "logs_deleted": 0}
|
|
3104
|
+
|
|
3105
|
+
# Count logs that will be deleted
|
|
3106
|
+
placeholders = ",".join("?" * len(ids_to_delete))
|
|
3107
|
+
logs_count = conn.execute(
|
|
3108
|
+
f"SELECT COUNT(*) FROM auto_execution_logs WHERE execution_id IN ({placeholders})",
|
|
3109
|
+
list(ids_to_delete),
|
|
3110
|
+
).fetchone()[0]
|
|
3111
|
+
|
|
3112
|
+
# Delete executions (logs cascade due to foreign key)
|
|
3113
|
+
conn.execute(
|
|
3114
|
+
f"DELETE FROM auto_executions WHERE id IN ({placeholders})",
|
|
3115
|
+
list(ids_to_delete),
|
|
3116
|
+
)
|
|
3117
|
+
conn.commit()
|
|
3118
|
+
|
|
3119
|
+
return {
|
|
3120
|
+
"executions_deleted": len(ids_to_delete),
|
|
3121
|
+
"logs_deleted": logs_count,
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
|
|
3125
|
+
# =============================================================================
|
|
3126
|
+
# Tree Rendering
|
|
3127
|
+
# =============================================================================
|
|
3128
|
+
|
|
3129
|
+
|
|
3130
|
+
def render_task_tree(db: TaskDB, hierarchy: Dict[str, Any]) -> List[str]:
|
|
3131
|
+
"""Render hierarchical task list with tree connectors.
|
|
3132
|
+
|
|
3133
|
+
Args:
|
|
3134
|
+
db: TaskDB instance for time lookups
|
|
3135
|
+
hierarchy: Output from get_active_tasks_hierarchical()
|
|
3136
|
+
|
|
3137
|
+
Returns:
|
|
3138
|
+
List of formatted output lines
|
|
3139
|
+
"""
|
|
3140
|
+
lines = []
|
|
3141
|
+
for task in hierarchy["top_level"]:
|
|
3142
|
+
repo = db.get_repo(task.repo_id)
|
|
3143
|
+
time_total = db.get_task_time(task.id, "all")
|
|
3144
|
+
child_tasks = hierarchy["children"].get(task.id, [])
|
|
3145
|
+
|
|
3146
|
+
# Build time string with subtask aggregate
|
|
3147
|
+
time_str = db.format_duration(time_total)
|
|
3148
|
+
if child_tasks:
|
|
3149
|
+
subtask_time = db.get_subtask_time_total(task.id)
|
|
3150
|
+
if subtask_time > 0:
|
|
3151
|
+
time_str = f"{time_str} (subtasks: {db.format_duration(subtask_time)})"
|
|
3152
|
+
|
|
3153
|
+
repo_name = repo.short_name if repo else "?"
|
|
3154
|
+
lines.append(
|
|
3155
|
+
f"[{task.id}] {task.name} [{repo_name}] - {time_str} - "
|
|
3156
|
+
f"{db.format_time_ago(db.get_effective_last_updated(task))}"
|
|
3157
|
+
)
|
|
3158
|
+
|
|
3159
|
+
# Render children with tree connectors
|
|
3160
|
+
for i, child in enumerate(child_tasks):
|
|
3161
|
+
connector = "└──" if i == len(child_tasks) - 1 else "├──"
|
|
3162
|
+
child_time = db.get_task_time(child.id, "all")
|
|
3163
|
+
lines.append(
|
|
3164
|
+
f" {connector} [{child.id}] {child.name} - "
|
|
3165
|
+
f"{db.format_duration(child_time)} - "
|
|
3166
|
+
f"{db.format_time_ago(db.get_effective_last_updated(child))}"
|
|
3167
|
+
)
|
|
3168
|
+
|
|
3169
|
+
return lines
|
|
3170
|
+
|
|
3171
|
+
|
|
3172
|
+
# =============================================================================
|
|
3173
|
+
# CLI Interface
|
|
3174
|
+
# =============================================================================
|
|
3175
|
+
|
|
3176
|
+
|
|
3177
|
+
def main():
|
|
3178
|
+
if len(sys.argv) < 2:
|
|
3179
|
+
print(__doc__)
|
|
3180
|
+
sys.exit(1)
|
|
3181
|
+
|
|
3182
|
+
command = sys.argv[1]
|
|
3183
|
+
db = TaskDB()
|
|
3184
|
+
|
|
3185
|
+
try:
|
|
3186
|
+
if command == "init":
|
|
3187
|
+
db.initialize()
|
|
3188
|
+
print(f"Database initialized at {db.db_path}")
|
|
3189
|
+
|
|
3190
|
+
elif command == "add-repo":
|
|
3191
|
+
if len(sys.argv) < 3:
|
|
3192
|
+
print("Usage: missioncache_db.py add-repo <path> [short_name]")
|
|
3193
|
+
sys.exit(1)
|
|
3194
|
+
path = sys.argv[2]
|
|
3195
|
+
short_name = sys.argv[3] if len(sys.argv) > 3 else None
|
|
3196
|
+
repo_id = db.add_repo(path, short_name)
|
|
3197
|
+
print(f"Added repo {path} with id {repo_id}")
|
|
3198
|
+
|
|
3199
|
+
elif command == "add-repos-glob":
|
|
3200
|
+
if len(sys.argv) < 3:
|
|
3201
|
+
print("Usage: missioncache_db.py add-repos-glob <pattern>")
|
|
3202
|
+
sys.exit(1)
|
|
3203
|
+
pattern = sys.argv[2]
|
|
3204
|
+
repo_ids = db.add_repos_from_glob(pattern)
|
|
3205
|
+
print(f"Added {len(repo_ids)} repos from pattern {pattern}")
|
|
3206
|
+
|
|
3207
|
+
elif command == "scan":
|
|
3208
|
+
repo_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
3209
|
+
if repo_id:
|
|
3210
|
+
tasks = db.scan_repo(repo_id)
|
|
3211
|
+
print(f"Discovered {len(tasks)} tasks in repo {repo_id}")
|
|
3212
|
+
else:
|
|
3213
|
+
tasks = db.scan_all_repos()
|
|
3214
|
+
print(f"Discovered {len(tasks)} tasks across all repos")
|
|
3215
|
+
|
|
3216
|
+
elif command == "list-repos":
|
|
3217
|
+
repos = db.get_repos()
|
|
3218
|
+
for repo in repos:
|
|
3219
|
+
print(f"[{repo.id}] {repo.short_name}: {repo.path}")
|
|
3220
|
+
|
|
3221
|
+
elif command == "list-active":
|
|
3222
|
+
flat_mode = "--flat" in sys.argv
|
|
3223
|
+
|
|
3224
|
+
if flat_mode:
|
|
3225
|
+
# Original flat output for backward compatibility
|
|
3226
|
+
tasks = db.get_active_tasks()
|
|
3227
|
+
for task in tasks:
|
|
3228
|
+
repo = db.get_repo(task.repo_id)
|
|
3229
|
+
time_total = db.get_task_time(task.id, "all")
|
|
3230
|
+
print(
|
|
3231
|
+
f"[{task.id}] {task.name} [{repo.short_name if repo else '?'}] - {db.format_duration(time_total)} - {db.format_time_ago(db.get_effective_last_updated(task))}"
|
|
3232
|
+
)
|
|
3233
|
+
else:
|
|
3234
|
+
# New hierarchical output
|
|
3235
|
+
hierarchy = db.get_active_tasks_hierarchical()
|
|
3236
|
+
for line in render_task_tree(db, hierarchy):
|
|
3237
|
+
print(line)
|
|
3238
|
+
|
|
3239
|
+
elif command == "heartbeat":
|
|
3240
|
+
if len(sys.argv) < 3:
|
|
3241
|
+
print("Usage: missioncache_db.py heartbeat <task_id> [session_id]")
|
|
3242
|
+
sys.exit(1)
|
|
3243
|
+
task_id = int(sys.argv[2])
|
|
3244
|
+
session_id = sys.argv[3] if len(sys.argv) > 3 else None
|
|
3245
|
+
hb_id = db.record_heartbeat(task_id, session_id)
|
|
3246
|
+
print(f"Recorded heartbeat {hb_id}")
|
|
3247
|
+
|
|
3248
|
+
elif command == "heartbeat-auto":
|
|
3249
|
+
cwd = os.getcwd()
|
|
3250
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID")
|
|
3251
|
+
hb_id = db.record_heartbeat_auto(cwd, session_id)
|
|
3252
|
+
if hb_id:
|
|
3253
|
+
print(f"Recorded heartbeat {hb_id}")
|
|
3254
|
+
else:
|
|
3255
|
+
print("No task found for current directory")
|
|
3256
|
+
|
|
3257
|
+
elif command == "process-heartbeats":
|
|
3258
|
+
count = db.process_heartbeats()
|
|
3259
|
+
print(f"Processed {count} heartbeats")
|
|
3260
|
+
|
|
3261
|
+
elif command == "task-time":
|
|
3262
|
+
if len(sys.argv) < 3:
|
|
3263
|
+
print("Usage: missioncache_db.py task-time <task_id> [period]")
|
|
3264
|
+
sys.exit(1)
|
|
3265
|
+
task_id = int(sys.argv[2])
|
|
3266
|
+
period = sys.argv[3] if len(sys.argv) > 3 else "all"
|
|
3267
|
+
seconds = db.get_task_time(task_id, period)
|
|
3268
|
+
print(db.format_duration(seconds))
|
|
3269
|
+
|
|
3270
|
+
elif command == "current-session":
|
|
3271
|
+
# Get current session working time (WakaTime-style)
|
|
3272
|
+
# Optional task_id, otherwise calculates from all unprocessed heartbeats
|
|
3273
|
+
task_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
3274
|
+
seconds = db.get_current_session_time(task_id)
|
|
3275
|
+
print(db.format_duration(seconds))
|
|
3276
|
+
|
|
3277
|
+
elif command == "prune":
|
|
3278
|
+
days = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
3279
|
+
count = db.prune_completed_tasks(days)
|
|
3280
|
+
print(f"Archived {count} completed tasks")
|
|
3281
|
+
|
|
3282
|
+
elif command == "get-task":
|
|
3283
|
+
if len(sys.argv) < 3:
|
|
3284
|
+
print("Usage: missioncache_db.py get-task <task_id>")
|
|
3285
|
+
sys.exit(1)
|
|
3286
|
+
task_id = int(sys.argv[2])
|
|
3287
|
+
task = db.get_task(task_id)
|
|
3288
|
+
if task:
|
|
3289
|
+
repo = db.get_repo(task.repo_id)
|
|
3290
|
+
output = {
|
|
3291
|
+
"id": task.id,
|
|
3292
|
+
"name": task.name,
|
|
3293
|
+
"full_path": task.full_path,
|
|
3294
|
+
"parent_id": task.parent_id,
|
|
3295
|
+
"repo_id": task.repo_id,
|
|
3296
|
+
"repo_path": repo.path if repo else None,
|
|
3297
|
+
"repo_name": repo.short_name if repo else None,
|
|
3298
|
+
"status": task.status,
|
|
3299
|
+
"jira_key": task.jira_key,
|
|
3300
|
+
"branch": task.branch,
|
|
3301
|
+
"pr_url": task.pr_url,
|
|
3302
|
+
"last_worked_on": task.last_worked_on,
|
|
3303
|
+
}
|
|
3304
|
+
# If this is a subtask, also get parent info
|
|
3305
|
+
if task.parent_id:
|
|
3306
|
+
parent = db.get_task(task.parent_id)
|
|
3307
|
+
if parent:
|
|
3308
|
+
output["parent_name"] = parent.name
|
|
3309
|
+
output["parent_full_path"] = parent.full_path
|
|
3310
|
+
print(json.dumps(output, indent=2))
|
|
3311
|
+
else:
|
|
3312
|
+
print(json.dumps({"error": f"Task {task_id} not found"}))
|
|
3313
|
+
sys.exit(1)
|
|
3314
|
+
|
|
3315
|
+
elif command == "complete-task":
|
|
3316
|
+
if len(sys.argv) < 3:
|
|
3317
|
+
print("Usage: missioncache_db.py complete-task <task_id>")
|
|
3318
|
+
sys.exit(1)
|
|
3319
|
+
task_id = int(sys.argv[2])
|
|
3320
|
+
task = db.get_task(task_id)
|
|
3321
|
+
if not task:
|
|
3322
|
+
print(json.dumps({"error": f"Task {task_id} not found"}))
|
|
3323
|
+
sys.exit(1)
|
|
3324
|
+
|
|
3325
|
+
# Process any pending heartbeats first
|
|
3326
|
+
db.process_heartbeats()
|
|
3327
|
+
|
|
3328
|
+
# Get final time stats before marking complete
|
|
3329
|
+
total_time = db.get_task_time(task_id, "all")
|
|
3330
|
+
session_count = db.get_task_session_count(task_id)
|
|
3331
|
+
|
|
3332
|
+
# Update status to completed
|
|
3333
|
+
updated_task = db.update_task_status(task_id, "completed")
|
|
3334
|
+
repo = db.get_repo(task.repo_id)
|
|
3335
|
+
|
|
3336
|
+
output = {
|
|
3337
|
+
"id": task_id,
|
|
3338
|
+
"name": task.name,
|
|
3339
|
+
"full_path": task.full_path,
|
|
3340
|
+
"repo_path": repo.path if repo else None,
|
|
3341
|
+
"repo_name": repo.short_name if repo else None,
|
|
3342
|
+
"status": "completed",
|
|
3343
|
+
"total_time_seconds": total_time,
|
|
3344
|
+
"total_time_formatted": db.format_duration(total_time),
|
|
3345
|
+
"session_count": session_count,
|
|
3346
|
+
}
|
|
3347
|
+
print(json.dumps(output, indent=2))
|
|
3348
|
+
|
|
3349
|
+
elif command == "create-task":
|
|
3350
|
+
# Parse arguments
|
|
3351
|
+
task_type = "coding"
|
|
3352
|
+
name = None
|
|
3353
|
+
jira_key = None
|
|
3354
|
+
|
|
3355
|
+
i = 2
|
|
3356
|
+
while i < len(sys.argv):
|
|
3357
|
+
if sys.argv[i] == "--type" and i + 1 < len(sys.argv):
|
|
3358
|
+
task_type = sys.argv[i + 1]
|
|
3359
|
+
i += 2
|
|
3360
|
+
elif sys.argv[i] == "--name" and i + 1 < len(sys.argv):
|
|
3361
|
+
name = sys.argv[i + 1]
|
|
3362
|
+
i += 2
|
|
3363
|
+
elif sys.argv[i] == "--jira" and i + 1 < len(sys.argv):
|
|
3364
|
+
jira_key = sys.argv[i + 1]
|
|
3365
|
+
i += 2
|
|
3366
|
+
elif not name:
|
|
3367
|
+
# First positional arg is the name
|
|
3368
|
+
name = sys.argv[i]
|
|
3369
|
+
i += 1
|
|
3370
|
+
else:
|
|
3371
|
+
i += 1
|
|
3372
|
+
|
|
3373
|
+
if not name:
|
|
3374
|
+
print(
|
|
3375
|
+
"Usage: missioncache_db.py create-task [--type coding|non-coding] [--jira TICKET] <name>"
|
|
3376
|
+
)
|
|
3377
|
+
print(
|
|
3378
|
+
" missioncache_db.py create-task --type non-coding --name 'Sprint planning'"
|
|
3379
|
+
)
|
|
3380
|
+
sys.exit(1)
|
|
3381
|
+
|
|
3382
|
+
task = db.create_task(name, task_type=task_type, jira_key=jira_key)
|
|
3383
|
+
output = {
|
|
3384
|
+
"id": task.id,
|
|
3385
|
+
"name": task.name,
|
|
3386
|
+
"type": task.task_type,
|
|
3387
|
+
"tags": task.tags,
|
|
3388
|
+
"jira_key": task.jira_key,
|
|
3389
|
+
"status": task.status,
|
|
3390
|
+
}
|
|
3391
|
+
print(json.dumps(output, indent=2))
|
|
3392
|
+
|
|
3393
|
+
elif command == "add-update":
|
|
3394
|
+
if len(sys.argv) < 4:
|
|
3395
|
+
print("Usage: missioncache_db.py add-update <task_id> <note>")
|
|
3396
|
+
sys.exit(1)
|
|
3397
|
+
task_id = int(sys.argv[2])
|
|
3398
|
+
note = " ".join(sys.argv[3:]) # Join remaining args as note
|
|
3399
|
+
update_id = db.add_task_update(task_id, note)
|
|
3400
|
+
task = db.get_task(task_id)
|
|
3401
|
+
print(
|
|
3402
|
+
json.dumps(
|
|
3403
|
+
{
|
|
3404
|
+
"update_id": update_id,
|
|
3405
|
+
"task_id": task_id,
|
|
3406
|
+
"task_name": task.name if task else None,
|
|
3407
|
+
"note": note,
|
|
3408
|
+
},
|
|
3409
|
+
indent=2,
|
|
3410
|
+
)
|
|
3411
|
+
)
|
|
3412
|
+
|
|
3413
|
+
elif command == "get-updates":
|
|
3414
|
+
if len(sys.argv) < 3:
|
|
3415
|
+
print("Usage: missioncache_db.py get-updates <task_id> [limit]")
|
|
3416
|
+
sys.exit(1)
|
|
3417
|
+
task_id = int(sys.argv[2])
|
|
3418
|
+
limit = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
|
3419
|
+
updates = db.get_task_updates(task_id, limit)
|
|
3420
|
+
task = db.get_task(task_id)
|
|
3421
|
+
print(
|
|
3422
|
+
json.dumps(
|
|
3423
|
+
{
|
|
3424
|
+
"task_id": task_id,
|
|
3425
|
+
"task_name": task.name if task else None,
|
|
3426
|
+
"updates": updates,
|
|
3427
|
+
},
|
|
3428
|
+
indent=2,
|
|
3429
|
+
)
|
|
3430
|
+
)
|
|
3431
|
+
|
|
3432
|
+
elif command == "today-updates":
|
|
3433
|
+
task_id = int(sys.argv[2]) if len(sys.argv) > 2 else None
|
|
3434
|
+
updates = db.get_today_updates(task_id)
|
|
3435
|
+
print(json.dumps({"updates": updates}, indent=2))
|
|
3436
|
+
|
|
3437
|
+
elif command == "set-jira":
|
|
3438
|
+
if len(sys.argv) < 4:
|
|
3439
|
+
print("Usage: missioncache_db.py set-jira <task_id> <jira_key>")
|
|
3440
|
+
sys.exit(1)
|
|
3441
|
+
task_id = int(sys.argv[2])
|
|
3442
|
+
jira_key = sys.argv[3]
|
|
3443
|
+
with db.connection() as conn:
|
|
3444
|
+
conn.execute(
|
|
3445
|
+
"UPDATE tasks SET jira_key = ? WHERE id = ?", (jira_key, task_id)
|
|
3446
|
+
)
|
|
3447
|
+
conn.commit()
|
|
3448
|
+
task = db.get_task(task_id)
|
|
3449
|
+
print(
|
|
3450
|
+
json.dumps(
|
|
3451
|
+
{
|
|
3452
|
+
"id": task_id,
|
|
3453
|
+
"name": task.name if task else None,
|
|
3454
|
+
"jira_key": jira_key,
|
|
3455
|
+
"message": f"Set JIRA key to {jira_key}",
|
|
3456
|
+
},
|
|
3457
|
+
indent=2,
|
|
3458
|
+
)
|
|
3459
|
+
)
|
|
3460
|
+
|
|
3461
|
+
elif command == "add-keyword":
|
|
3462
|
+
if len(sys.argv) < 3:
|
|
3463
|
+
print("Usage: missioncache_db.py add-keyword <keyword>")
|
|
3464
|
+
sys.exit(1)
|
|
3465
|
+
keyword = sys.argv[2]
|
|
3466
|
+
if db.add_keyword(keyword):
|
|
3467
|
+
print(f"Added keyword: {keyword}")
|
|
3468
|
+
else:
|
|
3469
|
+
print(f"Keyword already exists: {keyword}")
|
|
3470
|
+
sys.exit(1)
|
|
3471
|
+
|
|
3472
|
+
elif command == "remove-keyword":
|
|
3473
|
+
if len(sys.argv) < 3:
|
|
3474
|
+
print("Usage: missioncache_db.py remove-keyword <keyword>")
|
|
3475
|
+
sys.exit(1)
|
|
3476
|
+
keyword = sys.argv[2]
|
|
3477
|
+
if db.remove_keyword(keyword):
|
|
3478
|
+
print(f"Removed keyword: {keyword}")
|
|
3479
|
+
else:
|
|
3480
|
+
print(f"Keyword not found in custom list: {keyword}")
|
|
3481
|
+
sys.exit(1)
|
|
3482
|
+
|
|
3483
|
+
elif command == "list-keywords":
|
|
3484
|
+
keywords = db.list_keywords()
|
|
3485
|
+
print(f"Default keywords ({len(keywords['default'])}):")
|
|
3486
|
+
print(" " + ", ".join(keywords["default"][:20]) + "...")
|
|
3487
|
+
print(f"\nCustom keywords ({len(keywords['custom'])}):")
|
|
3488
|
+
if keywords["custom"]:
|
|
3489
|
+
print(" " + ", ".join(keywords["custom"]))
|
|
3490
|
+
else:
|
|
3491
|
+
print(" (none)")
|
|
3492
|
+
|
|
3493
|
+
elif command == "backfill-tags":
|
|
3494
|
+
# One-time operation to add tags to existing tasks
|
|
3495
|
+
count = 0
|
|
3496
|
+
with db.connection() as conn:
|
|
3497
|
+
tasks = conn.execute(
|
|
3498
|
+
"SELECT id, name, tags FROM tasks WHERE tags = '[]'"
|
|
3499
|
+
).fetchall()
|
|
3500
|
+
for task in tasks:
|
|
3501
|
+
tags = extract_tags(task["name"])
|
|
3502
|
+
if tags:
|
|
3503
|
+
conn.execute(
|
|
3504
|
+
"UPDATE tasks SET tags = ? WHERE id = ?",
|
|
3505
|
+
(json.dumps(tags), task["id"]),
|
|
3506
|
+
)
|
|
3507
|
+
count += 1
|
|
3508
|
+
print(f" [{task['id']}] {task['name']} -> {tags}")
|
|
3509
|
+
conn.commit()
|
|
3510
|
+
print(f"\nBackfilled tags for {count} tasks")
|
|
3511
|
+
|
|
3512
|
+
elif command == "list-completed":
|
|
3513
|
+
days = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
|
3514
|
+
tasks = db.get_recent_completed(days)
|
|
3515
|
+
if not tasks:
|
|
3516
|
+
print(f"No completed tasks in the last {days} days")
|
|
3517
|
+
else:
|
|
3518
|
+
for task in tasks:
|
|
3519
|
+
repo = db.get_repo(task.repo_id)
|
|
3520
|
+
completed_ago = db.format_time_ago(task.completed_at)
|
|
3521
|
+
print(
|
|
3522
|
+
f"[{task.id}] {task.name} [{repo.short_name if repo else '?'}] - completed {completed_ago}"
|
|
3523
|
+
)
|
|
3524
|
+
|
|
3525
|
+
elif command == "list-names":
|
|
3526
|
+
# Output task names only (for shell completion)
|
|
3527
|
+
status = sys.argv[2] if len(sys.argv) > 2 else "active"
|
|
3528
|
+
if status == "active":
|
|
3529
|
+
tasks = db.get_active_tasks()
|
|
3530
|
+
elif status == "completed":
|
|
3531
|
+
tasks = db.get_recent_completed(days=90)
|
|
3532
|
+
else:
|
|
3533
|
+
tasks = []
|
|
3534
|
+
for task in tasks:
|
|
3535
|
+
print(task.name)
|
|
3536
|
+
|
|
3537
|
+
elif command == "reopen-task":
|
|
3538
|
+
if len(sys.argv) < 3:
|
|
3539
|
+
print("Usage: missioncache_db.py reopen-task <task_id>")
|
|
3540
|
+
sys.exit(1)
|
|
3541
|
+
task_id = int(sys.argv[2])
|
|
3542
|
+
task = db.get_task(task_id)
|
|
3543
|
+
if not task:
|
|
3544
|
+
print(json.dumps({"error": f"Task {task_id} not found"}))
|
|
3545
|
+
sys.exit(1)
|
|
3546
|
+
if task.status != "completed":
|
|
3547
|
+
print(
|
|
3548
|
+
json.dumps(
|
|
3549
|
+
{
|
|
3550
|
+
"error": f"Task {task_id} is not completed (status: {task.status})"
|
|
3551
|
+
}
|
|
3552
|
+
)
|
|
3553
|
+
)
|
|
3554
|
+
sys.exit(1)
|
|
3555
|
+
|
|
3556
|
+
# Get time stats before reopening
|
|
3557
|
+
total_time = db.get_task_time(task_id, "all")
|
|
3558
|
+
session_count = db.get_task_session_count(task_id)
|
|
3559
|
+
|
|
3560
|
+
# Reopen the task
|
|
3561
|
+
updated_task = db.reopen_task(task_id)
|
|
3562
|
+
repo = db.get_repo(task.repo_id)
|
|
3563
|
+
|
|
3564
|
+
output = {
|
|
3565
|
+
"id": task_id,
|
|
3566
|
+
"name": task.name,
|
|
3567
|
+
"full_path": task.full_path,
|
|
3568
|
+
"repo_path": repo.path if repo else None,
|
|
3569
|
+
"repo_name": repo.short_name if repo else None,
|
|
3570
|
+
"status": "active",
|
|
3571
|
+
"previous_time_seconds": total_time,
|
|
3572
|
+
"previous_time_formatted": db.format_duration(total_time),
|
|
3573
|
+
"session_count": session_count,
|
|
3574
|
+
}
|
|
3575
|
+
print(json.dumps(output, indent=2))
|
|
3576
|
+
|
|
3577
|
+
elif command == "rename-task":
|
|
3578
|
+
# Usage: missioncache-db rename-task <old-name> <new-name>
|
|
3579
|
+
if len(sys.argv) < 4:
|
|
3580
|
+
print("Usage: missioncache-db rename-task <old-name> <new-name>")
|
|
3581
|
+
sys.exit(1)
|
|
3582
|
+
old_name = sys.argv[2]
|
|
3583
|
+
new_name_input = sys.argv[3]
|
|
3584
|
+
task = db.get_task_by_name(old_name)
|
|
3585
|
+
if not task:
|
|
3586
|
+
print(
|
|
3587
|
+
json.dumps({"error": f"No project found with name '{old_name}'."})
|
|
3588
|
+
)
|
|
3589
|
+
sys.exit(1)
|
|
3590
|
+
try:
|
|
3591
|
+
result = db.rename_task(task.id, new_name_input)
|
|
3592
|
+
except (
|
|
3593
|
+
NameCollisionError,
|
|
3594
|
+
FilesystemCollisionError,
|
|
3595
|
+
AutoRunActiveError,
|
|
3596
|
+
ValueError,
|
|
3597
|
+
) as e:
|
|
3598
|
+
print(json.dumps({"error": str(e)}))
|
|
3599
|
+
sys.exit(1)
|
|
3600
|
+
# Successful path: print canonical name and let the user know
|
|
3601
|
+
# if their input was normalized.
|
|
3602
|
+
if result["normalized"]:
|
|
3603
|
+
print(
|
|
3604
|
+
f"Renamed: {result['old_name']} -> {result['name']} "
|
|
3605
|
+
f"(normalized from '{new_name_input}')"
|
|
3606
|
+
)
|
|
3607
|
+
else:
|
|
3608
|
+
print(f"Renamed: {result['old_name']} -> {result['name']}")
|
|
3609
|
+
if result["h1_skipped"]:
|
|
3610
|
+
print(
|
|
3611
|
+
" Skipped H1 rewrite for (edited content): "
|
|
3612
|
+
+ ", ".join(result["h1_skipped"])
|
|
3613
|
+
)
|
|
3614
|
+
if result["sessions_updated"]:
|
|
3615
|
+
print(
|
|
3616
|
+
f" Updated {result['sessions_updated']} session pointer(s)."
|
|
3617
|
+
)
|
|
3618
|
+
|
|
3619
|
+
elif command == "get-task-by-name":
|
|
3620
|
+
if len(sys.argv) < 3:
|
|
3621
|
+
print("Usage: missioncache_db.py get-task-by-name <name> [--status <status>]")
|
|
3622
|
+
sys.exit(1)
|
|
3623
|
+
name = sys.argv[2]
|
|
3624
|
+
status = None
|
|
3625
|
+
# Parse --status flag
|
|
3626
|
+
if "--status" in sys.argv:
|
|
3627
|
+
idx = sys.argv.index("--status")
|
|
3628
|
+
if idx + 1 < len(sys.argv):
|
|
3629
|
+
status = sys.argv[idx + 1]
|
|
3630
|
+
|
|
3631
|
+
task = db.get_task_by_name(name, status)
|
|
3632
|
+
if task:
|
|
3633
|
+
repo = db.get_repo(task.repo_id)
|
|
3634
|
+
output = {
|
|
3635
|
+
"id": task.id,
|
|
3636
|
+
"name": task.name,
|
|
3637
|
+
"full_path": task.full_path,
|
|
3638
|
+
"repo_id": task.repo_id,
|
|
3639
|
+
"repo_path": repo.path if repo else None,
|
|
3640
|
+
"repo_name": repo.short_name if repo else None,
|
|
3641
|
+
"status": task.status,
|
|
3642
|
+
"completed_at": task.completed_at,
|
|
3643
|
+
}
|
|
3644
|
+
print(json.dumps(output, indent=2))
|
|
3645
|
+
else:
|
|
3646
|
+
status_msg = f" with status '{status}'" if status else ""
|
|
3647
|
+
print(json.dumps({"error": f"Task '{name}'{status_msg} not found"}))
|
|
3648
|
+
sys.exit(1)
|
|
3649
|
+
|
|
3650
|
+
elif command == "migrate-orbit-docs":
|
|
3651
|
+
import shutil
|
|
3652
|
+
|
|
3653
|
+
dry_run = "--dry-run" in sys.argv
|
|
3654
|
+
orbit_active = MISSIONCACHE_ROOT / "active"
|
|
3655
|
+
orbit_completed = MISSIONCACHE_ROOT / "completed"
|
|
3656
|
+
|
|
3657
|
+
if not dry_run:
|
|
3658
|
+
orbit_active.mkdir(parents=True, exist_ok=True)
|
|
3659
|
+
orbit_completed.mkdir(parents=True, exist_ok=True)
|
|
3660
|
+
|
|
3661
|
+
moved = 0
|
|
3662
|
+
skipped = 0
|
|
3663
|
+
|
|
3664
|
+
# Move files from repo-local dev/ to centralized MissionCache root
|
|
3665
|
+
for repo in db.get_repos():
|
|
3666
|
+
repo_path = Path(repo.path)
|
|
3667
|
+
for status, target_dir in [
|
|
3668
|
+
("active", orbit_active),
|
|
3669
|
+
("completed", orbit_completed),
|
|
3670
|
+
]:
|
|
3671
|
+
source_dir = repo_path / "dev" / status
|
|
3672
|
+
if not source_dir.exists():
|
|
3673
|
+
continue
|
|
3674
|
+
for task_dir in source_dir.iterdir():
|
|
3675
|
+
if not task_dir.is_dir() or task_dir.name.startswith("."):
|
|
3676
|
+
continue
|
|
3677
|
+
dest = target_dir / task_dir.name
|
|
3678
|
+
if dest.exists():
|
|
3679
|
+
print(f" SKIP (exists): {task_dir} -> {dest}")
|
|
3680
|
+
skipped += 1
|
|
3681
|
+
continue
|
|
3682
|
+
if dry_run:
|
|
3683
|
+
print(f" WOULD MOVE: {task_dir} -> {dest}")
|
|
3684
|
+
else:
|
|
3685
|
+
shutil.move(str(task_dir), str(dest))
|
|
3686
|
+
print(f" MOVED: {task_dir} -> {dest}")
|
|
3687
|
+
moved += 1
|
|
3688
|
+
|
|
3689
|
+
# Update DB full_path entries
|
|
3690
|
+
with db.connection() as conn:
|
|
3691
|
+
rows = conn.execute(
|
|
3692
|
+
"SELECT id, full_path FROM tasks WHERE full_path LIKE 'dev/%'"
|
|
3693
|
+
).fetchall()
|
|
3694
|
+
for row in rows:
|
|
3695
|
+
old_path = row["full_path"]
|
|
3696
|
+
new_path = old_path[4:] # strip "dev/" prefix
|
|
3697
|
+
if dry_run:
|
|
3698
|
+
print(
|
|
3699
|
+
f" WOULD UPDATE DB: [{row['id']}] {old_path} -> {new_path}"
|
|
3700
|
+
)
|
|
3701
|
+
else:
|
|
3702
|
+
conn.execute(
|
|
3703
|
+
"UPDATE tasks SET full_path = ? WHERE id = ?",
|
|
3704
|
+
(new_path, row["id"]),
|
|
3705
|
+
)
|
|
3706
|
+
if not dry_run:
|
|
3707
|
+
conn.commit()
|
|
3708
|
+
print(
|
|
3709
|
+
f"\n{'Would update' if dry_run else 'Updated'} {len(rows)} DB entries"
|
|
3710
|
+
)
|
|
3711
|
+
|
|
3712
|
+
prefix = "DRY RUN: Would move" if dry_run else "Moved"
|
|
3713
|
+
print(f"\n{prefix} {moved} task dirs ({skipped} skipped)")
|
|
3714
|
+
|
|
3715
|
+
elif command == "cleanup":
|
|
3716
|
+
import shutil
|
|
3717
|
+
|
|
3718
|
+
dry_run = "--dry-run" in sys.argv
|
|
3719
|
+
prefix = "DRY RUN: " if dry_run else ""
|
|
3720
|
+
archived_count = 0
|
|
3721
|
+
merged_count = 0
|
|
3722
|
+
|
|
3723
|
+
# --- B1: Archive orphaned active tasks (no files, no/minimal work) ---
|
|
3724
|
+
print("=== B1: Archive orphaned active tasks ===")
|
|
3725
|
+
orphan_ids = []
|
|
3726
|
+
with db.connection() as conn:
|
|
3727
|
+
rows = conn.execute(
|
|
3728
|
+
"SELECT id, name, full_path FROM tasks "
|
|
3729
|
+
"WHERE status = 'active' AND type = 'coding'"
|
|
3730
|
+
).fetchall()
|
|
3731
|
+
for row in rows:
|
|
3732
|
+
task_dir = MISSIONCACHE_ROOT / row["full_path"]
|
|
3733
|
+
has_files = (
|
|
3734
|
+
task_dir.exists()
|
|
3735
|
+
and task_dir.is_dir()
|
|
3736
|
+
and any(task_dir.iterdir())
|
|
3737
|
+
)
|
|
3738
|
+
if not has_files:
|
|
3739
|
+
# Check if it's a duplicate (handled in B3)
|
|
3740
|
+
dupes = conn.execute(
|
|
3741
|
+
"SELECT COUNT(*) FROM tasks WHERE name = ?",
|
|
3742
|
+
(row["name"],),
|
|
3743
|
+
).fetchone()[0]
|
|
3744
|
+
if dupes > 1:
|
|
3745
|
+
continue # handled in B3
|
|
3746
|
+
orphan_ids.append(row["id"])
|
|
3747
|
+
print(f" {prefix}Archive ID={row['id']} name={row['name']}")
|
|
3748
|
+
|
|
3749
|
+
if orphan_ids and not dry_run:
|
|
3750
|
+
placeholders = ",".join("?" * len(orphan_ids))
|
|
3751
|
+
conn.execute(
|
|
3752
|
+
f"UPDATE tasks SET status = 'archived', "
|
|
3753
|
+
f"archived_at = datetime('now') "
|
|
3754
|
+
f"WHERE id IN ({placeholders})",
|
|
3755
|
+
orphan_ids,
|
|
3756
|
+
)
|
|
3757
|
+
conn.commit()
|
|
3758
|
+
archived_count += len(orphan_ids)
|
|
3759
|
+
print(f" {prefix}{len(orphan_ids)} tasks archived\n")
|
|
3760
|
+
|
|
3761
|
+
# --- B2: Move orphaned repo-local files ---
|
|
3762
|
+
print("=== B2: Move orphaned repo-local files ===")
|
|
3763
|
+
dev_dir = MISSIONCACHE_ROOT
|
|
3764
|
+
files_moved = 0
|
|
3765
|
+
|
|
3766
|
+
# statusline-layout-improvement files
|
|
3767
|
+
src_completed = dev_dir / "completed"
|
|
3768
|
+
if src_completed.exists():
|
|
3769
|
+
sl_files = list(src_completed.glob("statusline-layout-improvement-*"))
|
|
3770
|
+
if sl_files:
|
|
3771
|
+
dest_dir = (
|
|
3772
|
+
MISSIONCACHE_ROOT / "completed" / "statusline-layout-improvement"
|
|
3773
|
+
)
|
|
3774
|
+
if dry_run:
|
|
3775
|
+
print(f" {prefix}Move {len(sl_files)} files -> {dest_dir}")
|
|
3776
|
+
else:
|
|
3777
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
3778
|
+
for f in sl_files:
|
|
3779
|
+
shutil.move(str(f), str(dest_dir / f.name))
|
|
3780
|
+
print(f" Moved {f.name}")
|
|
3781
|
+
files_moved += len(sl_files)
|
|
3782
|
+
|
|
3783
|
+
# Clean up .playwright-mcp artifacts
|
|
3784
|
+
pw_dir = dev_dir / "active" / ".playwright-mcp"
|
|
3785
|
+
if pw_dir.exists():
|
|
3786
|
+
if dry_run:
|
|
3787
|
+
print(f" {prefix}Remove {pw_dir}")
|
|
3788
|
+
else:
|
|
3789
|
+
shutil.rmtree(pw_dir)
|
|
3790
|
+
print(f" Removed {pw_dir}")
|
|
3791
|
+
|
|
3792
|
+
# Remove empty dev/ subdirs
|
|
3793
|
+
for subdir_name in ["active", "completed"]:
|
|
3794
|
+
subdir = dev_dir / subdir_name
|
|
3795
|
+
if subdir.exists():
|
|
3796
|
+
# Remove .DS_Store files
|
|
3797
|
+
ds_store = subdir / ".DS_Store"
|
|
3798
|
+
if ds_store.exists() and not dry_run:
|
|
3799
|
+
ds_store.unlink()
|
|
3800
|
+
remaining = [f for f in subdir.iterdir() if f.name != ".DS_Store"]
|
|
3801
|
+
if not remaining:
|
|
3802
|
+
if dry_run:
|
|
3803
|
+
print(f" {prefix}Remove empty {subdir}")
|
|
3804
|
+
else:
|
|
3805
|
+
shutil.rmtree(subdir)
|
|
3806
|
+
print(f" Removed empty {subdir}")
|
|
3807
|
+
|
|
3808
|
+
# Remove dev/ itself if empty
|
|
3809
|
+
if dev_dir.exists() and not dry_run:
|
|
3810
|
+
ds_store = dev_dir / ".DS_Store"
|
|
3811
|
+
if ds_store.exists():
|
|
3812
|
+
ds_store.unlink()
|
|
3813
|
+
remaining = [f for f in dev_dir.iterdir() if f.name != ".DS_Store"]
|
|
3814
|
+
if not remaining:
|
|
3815
|
+
dev_dir.rmdir()
|
|
3816
|
+
print(f" Removed empty {dev_dir}")
|
|
3817
|
+
|
|
3818
|
+
print(f" {prefix}{files_moved} files moved\n")
|
|
3819
|
+
|
|
3820
|
+
# --- B3: Resolve duplicates ---
|
|
3821
|
+
print("=== B3: Resolve duplicate task names ===")
|
|
3822
|
+
# Each entry: (keep_id, archive_ids)
|
|
3823
|
+
# Determined by: has files > recent work > more time
|
|
3824
|
+
duplicates = [
|
|
3825
|
+
# 05-module-aware-env-defaults: 69 active+files, 68 completed manual/
|
|
3826
|
+
(69, [68]),
|
|
3827
|
+
# 06-cicd-image-builds: 71 has more time (4182s vs 972s)
|
|
3828
|
+
(71, [73]),
|
|
3829
|
+
# 07-argo-workflow-server-nightly: 74 active+files+recent, 72 manual/ 75 empty
|
|
3830
|
+
(74, [72, 75]),
|
|
3831
|
+
# claude-activity-timezone-fix: 50 active+files, 48 manual/
|
|
3832
|
+
(50, [48]),
|
|
3833
|
+
# dynamic-workflow-registration: 60 completed+time, 62 active/no files
|
|
3834
|
+
(60, [62]),
|
|
3835
|
+
# eval-graders-system: 35 has last_worked, 28 manual/
|
|
3836
|
+
(35, [28]),
|
|
3837
|
+
# fix-ddc-lldc-test-failures: 16 completed+33h, 29 active/0 time
|
|
3838
|
+
(16, [29]),
|
|
3839
|
+
# orbit-loop-test: 40 more time (2674s vs 300s), 41
|
|
3840
|
+
(40, [41]),
|
|
3841
|
+
]
|
|
3842
|
+
|
|
3843
|
+
with db.connection() as conn:
|
|
3844
|
+
for keep_id, archive_ids in duplicates:
|
|
3845
|
+
keep = conn.execute(
|
|
3846
|
+
"SELECT id, name, status FROM tasks WHERE id = ?",
|
|
3847
|
+
(keep_id,),
|
|
3848
|
+
).fetchone()
|
|
3849
|
+
if not keep:
|
|
3850
|
+
continue
|
|
3851
|
+
|
|
3852
|
+
for aid in archive_ids:
|
|
3853
|
+
victim = conn.execute(
|
|
3854
|
+
"SELECT id, name, status FROM tasks WHERE id = ?",
|
|
3855
|
+
(aid,),
|
|
3856
|
+
).fetchone()
|
|
3857
|
+
if not victim:
|
|
3858
|
+
continue
|
|
3859
|
+
|
|
3860
|
+
# Migrate time data (sessions + heartbeats)
|
|
3861
|
+
session_count = conn.execute(
|
|
3862
|
+
"SELECT COUNT(*) FROM sessions WHERE task_id = ?",
|
|
3863
|
+
(aid,),
|
|
3864
|
+
).fetchone()[0]
|
|
3865
|
+
hb_count = conn.execute(
|
|
3866
|
+
"SELECT COUNT(*) FROM heartbeats WHERE task_id = ?",
|
|
3867
|
+
(aid,),
|
|
3868
|
+
).fetchone()[0]
|
|
3869
|
+
|
|
3870
|
+
if session_count > 0 or hb_count > 0:
|
|
3871
|
+
print(
|
|
3872
|
+
f" {prefix}Merge ID={aid} -> ID={keep_id} "
|
|
3873
|
+
f"({keep['name']}): "
|
|
3874
|
+
f"{session_count} sessions, {hb_count} heartbeats"
|
|
3875
|
+
)
|
|
3876
|
+
if not dry_run:
|
|
3877
|
+
conn.execute(
|
|
3878
|
+
"UPDATE sessions SET task_id = ? WHERE task_id = ?",
|
|
3879
|
+
(keep_id, aid),
|
|
3880
|
+
)
|
|
3881
|
+
conn.execute(
|
|
3882
|
+
"UPDATE heartbeats SET task_id = ? "
|
|
3883
|
+
"WHERE task_id = ?",
|
|
3884
|
+
(keep_id, aid),
|
|
3885
|
+
)
|
|
3886
|
+
merged_count += 1
|
|
3887
|
+
|
|
3888
|
+
print(
|
|
3889
|
+
f" {prefix}Archive ID={aid} "
|
|
3890
|
+
f"name={victim['name']} "
|
|
3891
|
+
f"(keeping ID={keep_id})"
|
|
3892
|
+
)
|
|
3893
|
+
if not dry_run:
|
|
3894
|
+
conn.execute(
|
|
3895
|
+
"UPDATE tasks SET status = 'archived', "
|
|
3896
|
+
"archived_at = datetime('now') "
|
|
3897
|
+
"WHERE id = ?",
|
|
3898
|
+
(aid,),
|
|
3899
|
+
)
|
|
3900
|
+
archived_count += 1
|
|
3901
|
+
|
|
3902
|
+
if not dry_run:
|
|
3903
|
+
conn.commit()
|
|
3904
|
+
print(
|
|
3905
|
+
f" {prefix}{merged_count} time merges, "
|
|
3906
|
+
f"{archived_count - len(orphan_ids)} duplicates archived\n"
|
|
3907
|
+
)
|
|
3908
|
+
|
|
3909
|
+
# --- B4: Normalize non-standard paths ---
|
|
3910
|
+
print("=== B4: Normalize non-standard paths ===")
|
|
3911
|
+
normalized = 0
|
|
3912
|
+
with db.connection() as conn:
|
|
3913
|
+
# manual/* completed coding tasks -> completed/*
|
|
3914
|
+
# Skip archived - they're dead duplicates and would
|
|
3915
|
+
# collide on UNIQUE(repo_id, full_path)
|
|
3916
|
+
rows = conn.execute(
|
|
3917
|
+
"SELECT id, name, full_path, status FROM tasks "
|
|
3918
|
+
"WHERE full_path LIKE 'manual/%' AND type = 'coding' "
|
|
3919
|
+
"AND status = 'completed'"
|
|
3920
|
+
).fetchall()
|
|
3921
|
+
for row in rows:
|
|
3922
|
+
new_path = f"completed/{row['name']}"
|
|
3923
|
+
print(f" {prefix}ID={row['id']} {row['full_path']} -> {new_path}")
|
|
3924
|
+
if not dry_run:
|
|
3925
|
+
conn.execute(
|
|
3926
|
+
"UPDATE tasks SET full_path = ? WHERE id = ?",
|
|
3927
|
+
(new_path, row["id"]),
|
|
3928
|
+
)
|
|
3929
|
+
normalized += 1
|
|
3930
|
+
|
|
3931
|
+
# active/* completed tasks -> completed/*
|
|
3932
|
+
# Skip subtasks (parent_id set) - preserve parent path
|
|
3933
|
+
# Skip archived - same UNIQUE constraint reason
|
|
3934
|
+
rows = conn.execute(
|
|
3935
|
+
"SELECT id, name, full_path, status, parent_id FROM tasks "
|
|
3936
|
+
"WHERE full_path LIKE 'active/%' "
|
|
3937
|
+
"AND status = 'completed' "
|
|
3938
|
+
"AND parent_id IS NULL"
|
|
3939
|
+
).fetchall()
|
|
3940
|
+
for row in rows:
|
|
3941
|
+
new_path = f"completed/{row['name']}"
|
|
3942
|
+
print(f" {prefix}ID={row['id']} {row['full_path']} -> {new_path}")
|
|
3943
|
+
if not dry_run:
|
|
3944
|
+
conn.execute(
|
|
3945
|
+
"UPDATE tasks SET full_path = ? WHERE id = ?",
|
|
3946
|
+
(new_path, row["id"]),
|
|
3947
|
+
)
|
|
3948
|
+
normalized += 1
|
|
3949
|
+
|
|
3950
|
+
if not dry_run:
|
|
3951
|
+
conn.commit()
|
|
3952
|
+
print(f" {prefix}{normalized} paths normalized\n")
|
|
3953
|
+
|
|
3954
|
+
# --- Summary ---
|
|
3955
|
+
print("=== Summary ===")
|
|
3956
|
+
print(f" Orphans archived: {len(orphan_ids)}")
|
|
3957
|
+
print(f" Duplicates resolved: {len(duplicates)}")
|
|
3958
|
+
print(f" Time merges: {merged_count}")
|
|
3959
|
+
print(f" Paths normalized: {normalized}")
|
|
3960
|
+
print(f" Files moved: {files_moved}")
|
|
3961
|
+
if dry_run:
|
|
3962
|
+
print("\n Run without --dry-run to apply changes.")
|
|
3963
|
+
|
|
3964
|
+
else:
|
|
3965
|
+
print(f"Unknown command: {command}")
|
|
3966
|
+
print(__doc__)
|
|
3967
|
+
sys.exit(1)
|
|
3968
|
+
|
|
3969
|
+
finally:
|
|
3970
|
+
db.close()
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
if __name__ == "__main__":
|
|
3974
|
+
main()
|