agent-todo-tracker/agent_todos/store.py
Hermes Agent 7a03de305a feat(box): ADHD-friendly time-boxing mode (issue #16)
- TimeBox model with explicit recoverable BoxState state machine
- Store layer: time_boxes schema, CRUD, active-box recovery
- BoxController: start/pause/resume/interrupt/complete/expire with elapsed math
- CLI  group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus)
- Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit
- Interruptions logged with timestamp + reason for pattern recognition
- Non-interactive helpers + auto-resume on entry
- 65 tests green, strict vertical RED-GREEN TDD
2026-08-22 21:49:35 +00:00

280 lines
10 KiB
Python

from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from .models import Priority, Status, Todo
from .timebox import BoxState, TimeBox
SCHEMA = """
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
repo TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
priority TEXT NOT NULL DEFAULT 'medium',
assignee TEXT DEFAULT '',
tags TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
resolved_at TEXT DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
CREATE INDEX IF NOT EXISTS idx_todos_repo ON todos(repo);
CREATE TABLE IF NOT EXISTS time_boxes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
todo_id INTEGER REFERENCES todos(id),
duration_seconds INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
accumulated_seconds REAL NOT NULL DEFAULT 0.0,
last_started_at TEXT,
interruptions TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_timebox_state ON time_boxes(state);
"""
class Store:
def __init__(self, db_path: str = ".agent_todos.db") -> None:
self.db_path = db_path
self._ensure()
def _ensure(self) -> None:
first = not Path(self.db_path).exists()
with sqlite3.connect(self.db_path) as con:
con.executescript(SCHEMA)
con.commit()
if first:
self.seed()
def seed(self) -> None:
now = datetime.utcnow().isoformat() + "Z"
self.create(Todo(
id=None,
title="Scaffold agent-todo-tracker CLI",
repo="stackchain/agent-todo-tracker",
status=Status.resolved,
priority=Priority.high,
tags=["hackathon", "p2"],
created_at=now,
updated_at=now,
resolved_at=now,
))
self.create(Todo(
id=None,
title="Add Gitea issue sync for TODOs",
repo="stackchain/stackchain-hackathon",
status=Status.open,
priority=Priority.medium,
tags=["hackathon", "p1"],
created_at=now,
updated_at=now,
))
def create(self, todo: Todo) -> Todo:
now = datetime.utcnow().isoformat() + "Z"
todo.created_at = now
todo.updated_at = now
with sqlite3.connect(self.db_path) as con:
cur = con.execute(
"INSERT INTO todos (title, repo, status, priority, assignee, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
todo.title,
todo.repo,
todo.status.value,
todo.priority.value,
todo.assignee,
",".join(todo.tags),
todo.created_at,
todo.updated_at,
),
)
todo.id = cur.lastrowid
con.commit()
return todo
def list(self, status: Optional[Status] = None, repo: Optional[str] = None) -> list[Todo]:
query = "SELECT * FROM todos WHERE 1=1"
params: list = []
if status:
query += " AND status = ?"
params.append(status.value)
if repo:
query += " AND repo = ?"
params.append(repo)
query += " ORDER BY created_at DESC"
with sqlite3.connect(self.db_path) as con:
con.row_factory = sqlite3.Row
rows = con.execute(query, params).fetchall()
return [self._row_to_todo(r) for r in rows]
def update(self, todo_id: int, **kwargs) -> Optional[Todo]:
todo = self.get(todo_id)
if not todo:
return None
allowed = {"title", "repo", "status", "priority", "assignee", "tags", "resolved_at"}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return todo
now = datetime.utcnow().isoformat() + "Z"
sets = ["updated_at = ?"]
params = [now]
for k, v in fields.items():
if k == "tags" and isinstance(v, list):
v = ",".join(v)
if k == "status":
v = v.value if isinstance(v, Status) else v
if k == "priority":
v = v.value if isinstance(v, Priority) else v
sets.append(f"{k} = ?")
params.append(v)
if fields.get("status") == Status.resolved.value:
sets.append("resolved_at = ?")
params.append(now)
params.append(todo_id)
with sqlite3.connect(self.db_path) as con:
con.execute(f"UPDATE todos SET {', '.join(sets)} WHERE id = ?", params)
con.commit()
return self.get(todo_id)
def get(self, todo_id: int) -> Optional[Todo]:
with sqlite3.connect(self.db_path) as con:
con.row_factory = sqlite3.Row
row = con.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
return self._row_to_todo(row) if row else None
def _row_to_todo(self, row: sqlite3.Row) -> Todo:
return Todo(
id=row["id"],
title=row["title"],
repo=row["repo"],
status=Status(row["status"]),
priority=Priority(row["priority"]),
assignee=row["assignee"],
tags=[t for t in row["tags"].split(",") if t],
created_at=row["created_at"],
updated_at=row["updated_at"],
resolved_at=row["resolved_at"],
)
# ----- time_boxes -----------------------------------------------------
def create_timebox(self, box: TimeBox) -> TimeBox:
now = datetime.now(timezone.utc).isoformat()
box.created_at = now
box.updated_at = now
with sqlite3.connect(self.db_path) as con:
cur = con.execute(
"INSERT INTO time_boxes "
"(todo_id, duration_seconds, state, accumulated_seconds, "
"last_started_at, interruptions, created_at, updated_at, "
"completed_at, reason) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
box.todo_id,
box.duration_seconds,
box.state.value,
box.accumulated_seconds,
box.last_started_at,
json.dumps(box.interruptions),
box.created_at,
box.updated_at,
box.completed_at,
box.reason,
),
)
box.id = cur.lastrowid
con.commit()
return box
def get_timebox(self, box_id: int) -> Optional[TimeBox]:
with sqlite3.connect(self.db_path) as con:
con.row_factory = sqlite3.Row
row = con.execute(
"SELECT * FROM time_boxes WHERE id = ?", (box_id,)
).fetchone()
return self._row_to_timebox(row) if row else None
def list_timeboxes(self, active_only: bool = False) -> list[TimeBox]:
query = "SELECT * FROM time_boxes"
if active_only:
states = ", ".join(f"'{s.value}'" for s in BoxState if s not in {
BoxState.completed, BoxState.abandoned, BoxState.expired
})
query += f" WHERE state IN ({states})"
query += " ORDER BY updated_at DESC"
with sqlite3.connect(self.db_path) as con:
con.row_factory = sqlite3.Row
rows = con.execute(query).fetchall()
return [self._row_to_timebox(r) for r in rows]
def update_timebox(self, box_id: int, **kwargs) -> Optional[TimeBox]:
box = self.get_timebox(box_id)
if not box:
return None
allowed = {
"todo_id", "duration_seconds", "state", "accumulated_seconds",
"last_started_at", "interruptions", "completed_at", "reason",
}
fields = {k: v for k, v in kwargs.items() if k in allowed}
if not fields:
return box
now = datetime.now(timezone.utc).isoformat()
sets = ["updated_at = ?"]
params: list = [now]
for k, v in fields.items():
if k == "interruptions" and isinstance(v, list):
v = json.dumps(v)
if k == "state":
v = v.value if isinstance(v, BoxState) else v
sets.append(f"{k} = ?")
params.append(v)
params.append(box_id)
with sqlite3.connect(self.db_path) as con:
con.execute(
f"UPDATE time_boxes SET {', '.join(sets)} WHERE id = ?", params
)
con.commit()
return self.get_timebox(box_id)
def recover_timebox(self) -> Optional[TimeBox]:
"""Return the most-recent active box, or None if none exists.
Use this after an interruption (crash, reboot, agent restart) so the
user can resume exactly where they left off.
"""
states = ", ".join(f"'{s.value}'" for s in BoxState if s not in {
BoxState.completed, BoxState.abandoned, BoxState.expired
})
query = (
f"SELECT * FROM time_boxes WHERE state IN ({states}) "
"ORDER BY updated_at DESC LIMIT 1"
)
with sqlite3.connect(self.db_path) as con:
con.row_factory = sqlite3.Row
row = con.execute(query).fetchone()
return self._row_to_timebox(row) if row else None
def _row_to_timebox(self, row: sqlite3.Row) -> TimeBox:
return TimeBox(
id=row["id"],
todo_id=row["todo_id"],
duration_seconds=row["duration_seconds"],
state=BoxState(row["state"]),
accumulated_seconds=row["accumulated_seconds"] or 0.0,
last_started_at=row["last_started_at"],
interruptions=json.loads(row["interruptions"] or "[]"),
created_at=row["created_at"],
updated_at=row["updated_at"],
completed_at=row["completed_at"],
reason=row["reason"],
)