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.
@@ -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()