taskflow-agent 0.3.0__tar.gz → 0.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskflow-agent
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Lightweight project and task manager with MCP tools for Claude Code
5
5
  Project-URL: Repository, https://github.com/henrysouchien/taskflow-agent
6
6
  Author: Henry Chien
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskflow-agent"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Lightweight project and task manager with MCP tools for Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import re
6
7
  import sqlite3
7
8
  from pathlib import Path
@@ -138,6 +139,17 @@ CREATE TABLE IF NOT EXISTS daily_focus (
138
139
  );
139
140
  CREATE INDEX IF NOT EXISTS idx_daily_focus_date ON daily_focus(focus_date);
140
141
  CREATE INDEX IF NOT EXISTS idx_daily_focus_task ON daily_focus(task_id);
142
+
143
+ CREATE TABLE IF NOT EXISTS deleted_items (
144
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
145
+ entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'section', 'goal')),
146
+ entity_id INTEGER NOT NULL,
147
+ entity_name TEXT NOT NULL DEFAULT '',
148
+ snapshot TEXT NOT NULL,
149
+ deleted_at TEXT NOT NULL DEFAULT (datetime('now'))
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_deleted_items_deleted_at
152
+ ON deleted_items(deleted_at DESC);
141
153
  """
142
154
 
143
155
 
@@ -576,12 +588,21 @@ def move_section(conn: sqlite3.Connection, section_id: int, new_position: int) -
576
588
 
577
589
  def delete_section(conn: sqlite3.Connection, section_id: int) -> bool:
578
590
  """Delete a section. Reassigns its tasks to section_id=NULL (Ungrouped)."""
579
- conn.execute(
580
- "UPDATE tasks SET section_id = NULL, last_modified = datetime('now') WHERE section_id = ?",
581
- (section_id,),
582
- )
583
- cur = conn.execute("DELETE FROM sections WHERE id = ?", (section_id,))
584
- conn.commit()
591
+ conn.execute("BEGIN IMMEDIATE")
592
+ try:
593
+ snap = _snapshot_section(conn, section_id)
594
+ if snap:
595
+ _save_deleted_snapshot(conn, "section", section_id, snap["section"]["name"], snap)
596
+ conn.execute(
597
+ "UPDATE tasks SET section_id = NULL, last_modified = datetime('now') WHERE section_id = ?",
598
+ (section_id,),
599
+ )
600
+ cur = conn.execute("DELETE FROM sections WHERE id = ?", (section_id,))
601
+ conn.commit()
602
+ except Exception:
603
+ conn.rollback()
604
+ raise
605
+ _purge_deleted_items(conn)
585
606
  return cur.rowcount > 0
586
607
 
587
608
 
@@ -760,12 +781,20 @@ def move_task(conn: sqlite3.Connection, task_id: int, project_id: int | None = N
760
781
 
761
782
 
762
783
  def delete_task(conn: sqlite3.Connection, task_id: int) -> bool:
763
- # Delete subtasks first
764
- conn.execute("DELETE FROM task_tags WHERE task_id IN (SELECT id FROM tasks WHERE parent_task_id = ?)", (task_id,))
765
- conn.execute("DELETE FROM tasks WHERE parent_task_id = ?", (task_id,))
766
- conn.execute("DELETE FROM task_tags WHERE task_id = ?", (task_id,))
767
- cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
768
- conn.commit()
784
+ conn.execute("BEGIN IMMEDIATE")
785
+ try:
786
+ snap = _snapshot_task(conn, task_id)
787
+ if snap:
788
+ _save_deleted_snapshot(conn, "task", task_id, snap["task"]["name"], snap)
789
+ conn.execute("DELETE FROM task_tags WHERE task_id IN (SELECT id FROM tasks WHERE parent_task_id = ?)", (task_id,))
790
+ conn.execute("DELETE FROM tasks WHERE parent_task_id = ?", (task_id,))
791
+ conn.execute("DELETE FROM task_tags WHERE task_id = ?", (task_id,))
792
+ cur = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
793
+ conn.commit()
794
+ except Exception:
795
+ conn.rollback()
796
+ raise
797
+ _purge_deleted_items(conn)
769
798
  return cur.rowcount > 0
770
799
 
771
800
 
@@ -950,11 +979,350 @@ def reopen_goal(conn: sqlite3.Connection, goal_id: int) -> bool:
950
979
 
951
980
 
952
981
  def delete_goal(conn: sqlite3.Connection, goal_id: int) -> bool:
953
- cur = conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
954
- conn.commit()
982
+ conn.execute("BEGIN IMMEDIATE")
983
+ try:
984
+ snap = _snapshot_goal(conn, goal_id)
985
+ if snap:
986
+ _save_deleted_snapshot(conn, "goal", goal_id, snap["goal"]["text"], snap)
987
+ cur = conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,))
988
+ conn.commit()
989
+ except Exception:
990
+ conn.rollback()
991
+ raise
992
+ _purge_deleted_items(conn)
955
993
  return cur.rowcount > 0
956
994
 
957
995
 
996
+ # ---------------------------------------------------------------------------
997
+ # Deleted Items (Undo Support)
998
+ # ---------------------------------------------------------------------------
999
+
1000
+ def _snapshot_task(conn: sqlite3.Connection, task_id: int) -> dict[str, Any] | None:
1001
+ """Build a restorable snapshot for a task + subtasks + tags + focus entries."""
1002
+ row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
1003
+ if not row:
1004
+ return None
1005
+ task = dict(row)
1006
+ tags = [
1007
+ r["name"]
1008
+ for r in conn.execute(
1009
+ """
1010
+ SELECT tg.name
1011
+ FROM tags tg
1012
+ JOIN task_tags tt ON tt.tag_id = tg.id
1013
+ WHERE tt.task_id = ?
1014
+ """,
1015
+ (task_id,),
1016
+ ).fetchall()
1017
+ ]
1018
+ focus_entries = [
1019
+ {"focus_date": r["focus_date"], "position": r["position"], "added_at": r["added_at"]}
1020
+ for r in conn.execute(
1021
+ "SELECT focus_date, position, added_at FROM daily_focus WHERE task_id = ?",
1022
+ (task_id,),
1023
+ ).fetchall()
1024
+ ]
1025
+ subtasks = []
1026
+ for sub in conn.execute("SELECT * FROM tasks WHERE parent_task_id = ?", (task_id,)).fetchall():
1027
+ sub_tags = [
1028
+ r["name"]
1029
+ for r in conn.execute(
1030
+ """
1031
+ SELECT tg.name
1032
+ FROM tags tg
1033
+ JOIN task_tags tt ON tt.tag_id = tg.id
1034
+ WHERE tt.task_id = ?
1035
+ """,
1036
+ (sub["id"],),
1037
+ ).fetchall()
1038
+ ]
1039
+ sub_focus = [
1040
+ {"focus_date": r["focus_date"], "position": r["position"], "added_at": r["added_at"]}
1041
+ for r in conn.execute(
1042
+ "SELECT focus_date, position, added_at FROM daily_focus WHERE task_id = ?",
1043
+ (sub["id"],),
1044
+ ).fetchall()
1045
+ ]
1046
+ subtasks.append({"task": dict(sub), "tags": sub_tags, "focus_entries": sub_focus})
1047
+ return {"task": task, "tags": tags, "focus_entries": focus_entries, "subtasks": subtasks}
1048
+
1049
+
1050
+ def _snapshot_section(conn: sqlite3.Connection, section_id: int) -> dict[str, Any] | None:
1051
+ """Build a restorable snapshot for a section + affected tasks with their last_modified."""
1052
+ row = conn.execute("SELECT * FROM sections WHERE id = ?", (section_id,)).fetchone()
1053
+ if not row:
1054
+ return None
1055
+ tasks = [
1056
+ {"id": r["id"], "last_modified": r["last_modified"]}
1057
+ for r in conn.execute(
1058
+ "SELECT id, last_modified FROM tasks WHERE section_id = ?",
1059
+ (section_id,),
1060
+ ).fetchall()
1061
+ ]
1062
+ return {"section": dict(row), "tasks": tasks}
1063
+
1064
+
1065
+ def _snapshot_goal(conn: sqlite3.Connection, goal_id: int) -> dict[str, Any] | None:
1066
+ """Build a restorable snapshot for a goal."""
1067
+ row = conn.execute("SELECT * FROM goals WHERE id = ?", (goal_id,)).fetchone()
1068
+ if not row:
1069
+ return None
1070
+ return {"goal": dict(row)}
1071
+
1072
+
1073
+ def _save_deleted_snapshot(
1074
+ conn: sqlite3.Connection,
1075
+ entity_type: str,
1076
+ entity_id: int,
1077
+ entity_name: str,
1078
+ snapshot: dict[str, Any],
1079
+ ) -> None:
1080
+ """Save a snapshot before deletion. Caller commits as part of the delete transaction."""
1081
+ conn.execute(
1082
+ "INSERT INTO deleted_items (entity_type, entity_id, entity_name, snapshot) VALUES (?, ?, ?, ?)",
1083
+ (entity_type, entity_id, entity_name, json.dumps(snapshot)),
1084
+ )
1085
+
1086
+
1087
+ def list_deleted_items(
1088
+ conn: sqlite3.Connection,
1089
+ entity_type: str | None = None,
1090
+ limit: int = 20,
1091
+ ) -> list[dict[str, Any]]:
1092
+ """List recent deleted items, optionally filtered by type."""
1093
+ if entity_type:
1094
+ rows = conn.execute(
1095
+ """
1096
+ SELECT id, entity_type, entity_id, entity_name, deleted_at
1097
+ FROM deleted_items
1098
+ WHERE entity_type = ?
1099
+ ORDER BY deleted_at DESC
1100
+ LIMIT ?
1101
+ """,
1102
+ (entity_type, limit),
1103
+ ).fetchall()
1104
+ else:
1105
+ rows = conn.execute(
1106
+ """
1107
+ SELECT id, entity_type, entity_id, entity_name, deleted_at
1108
+ FROM deleted_items
1109
+ ORDER BY deleted_at DESC
1110
+ LIMIT ?
1111
+ """,
1112
+ (limit,),
1113
+ ).fetchall()
1114
+ return [dict(r) for r in rows]
1115
+
1116
+
1117
+ def restore_deleted_item(conn: sqlite3.Connection, deleted_item_id: int) -> tuple[str, int] | None:
1118
+ """Restore a deleted item from its snapshot."""
1119
+ conn.execute("BEGIN IMMEDIATE")
1120
+ try:
1121
+ row = conn.execute(
1122
+ "SELECT * FROM deleted_items WHERE id = ?",
1123
+ (deleted_item_id,),
1124
+ ).fetchone()
1125
+ if not row:
1126
+ conn.rollback()
1127
+ return None
1128
+ snap = json.loads(row["snapshot"])
1129
+ entity_type = row["entity_type"]
1130
+
1131
+ if entity_type == "task":
1132
+ _restore_task(conn, snap)
1133
+ elif entity_type == "section":
1134
+ _restore_section(conn, snap)
1135
+ elif entity_type == "goal":
1136
+ _restore_goal(conn, snap)
1137
+
1138
+ conn.execute("DELETE FROM deleted_items WHERE id = ?", (deleted_item_id,))
1139
+ conn.commit()
1140
+ except sqlite3.IntegrityError as exc:
1141
+ conn.rollback()
1142
+ raise ValueError(f"Cannot restore: {exc}") from exc
1143
+ except Exception:
1144
+ conn.rollback()
1145
+ raise
1146
+
1147
+ return entity_type, row["entity_id"]
1148
+
1149
+
1150
+ def _restore_task(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
1151
+ """Re-insert a task + subtasks + tags + focus entries from snapshot."""
1152
+ t = snap["task"]
1153
+ proj = conn.execute("SELECT id FROM projects WHERE id = ?", (t["project_id"],)).fetchone()
1154
+ if not proj:
1155
+ raise sqlite3.IntegrityError(f"Parent project {t['project_id']} no longer exists")
1156
+
1157
+ section_id = t["section_id"]
1158
+ if section_id is not None:
1159
+ sec_row = conn.execute(
1160
+ "SELECT id, project_id FROM sections WHERE id = ?",
1161
+ (section_id,),
1162
+ ).fetchone()
1163
+ if not sec_row or sec_row["project_id"] != t["project_id"]:
1164
+ section_id = None
1165
+
1166
+ parent_task_id = t["parent_task_id"]
1167
+ if parent_task_id is not None:
1168
+ parent_row = conn.execute(
1169
+ "SELECT id, project_id FROM tasks WHERE id = ?",
1170
+ (parent_task_id,),
1171
+ ).fetchone()
1172
+ if not parent_row:
1173
+ raise sqlite3.IntegrityError(
1174
+ f"Parent task {parent_task_id} no longer exists - restore the parent task first"
1175
+ )
1176
+ if parent_row["project_id"] != t["project_id"]:
1177
+ raise sqlite3.IntegrityError(
1178
+ f"Parent task {parent_task_id} is now in project {parent_row['project_id']}, "
1179
+ f"but subtask belongs to project {t['project_id']} - move parent back first"
1180
+ )
1181
+
1182
+ conn.execute(
1183
+ """
1184
+ INSERT INTO tasks (
1185
+ id,
1186
+ project_id,
1187
+ section_id,
1188
+ parent_task_id,
1189
+ name,
1190
+ notes,
1191
+ assignee,
1192
+ status,
1193
+ start_date,
1194
+ due_date,
1195
+ created_at,
1196
+ completed_at,
1197
+ last_modified,
1198
+ position
1199
+ )
1200
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1201
+ """,
1202
+ (
1203
+ t["id"],
1204
+ t["project_id"],
1205
+ section_id,
1206
+ parent_task_id,
1207
+ t["name"],
1208
+ t["notes"],
1209
+ t["assignee"],
1210
+ t["status"],
1211
+ t["start_date"],
1212
+ t["due_date"],
1213
+ t["created_at"],
1214
+ t["completed_at"],
1215
+ t["last_modified"],
1216
+ t["position"],
1217
+ ),
1218
+ )
1219
+ if snap.get("tags"):
1220
+ _set_tags(conn, t["id"], snap["tags"])
1221
+ for fe in snap.get("focus_entries", []):
1222
+ conn.execute(
1223
+ "INSERT OR IGNORE INTO daily_focus (task_id, focus_date, position, added_at) VALUES (?, ?, ?, ?)",
1224
+ (t["id"], fe["focus_date"], fe["position"], fe["added_at"]),
1225
+ )
1226
+
1227
+ for sub_snap in snap.get("subtasks", []):
1228
+ st = sub_snap["task"]
1229
+ sub_project_id = t["project_id"]
1230
+ sub_section_id = st["section_id"]
1231
+ if sub_section_id is not None:
1232
+ sec_row = conn.execute(
1233
+ "SELECT id, project_id FROM sections WHERE id = ?",
1234
+ (sub_section_id,),
1235
+ ).fetchone()
1236
+ if not sec_row or sec_row["project_id"] != sub_project_id:
1237
+ sub_section_id = None
1238
+ conn.execute(
1239
+ """
1240
+ INSERT INTO tasks (
1241
+ id,
1242
+ project_id,
1243
+ section_id,
1244
+ parent_task_id,
1245
+ name,
1246
+ notes,
1247
+ assignee,
1248
+ status,
1249
+ start_date,
1250
+ due_date,
1251
+ created_at,
1252
+ completed_at,
1253
+ last_modified,
1254
+ position
1255
+ )
1256
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1257
+ """,
1258
+ (
1259
+ st["id"],
1260
+ sub_project_id,
1261
+ sub_section_id,
1262
+ st["parent_task_id"],
1263
+ st["name"],
1264
+ st["notes"],
1265
+ st["assignee"],
1266
+ st["status"],
1267
+ st["start_date"],
1268
+ st["due_date"],
1269
+ st["created_at"],
1270
+ st["completed_at"],
1271
+ st["last_modified"],
1272
+ st["position"],
1273
+ ),
1274
+ )
1275
+ if sub_snap.get("tags"):
1276
+ _set_tags(conn, st["id"], sub_snap["tags"])
1277
+ for fe in sub_snap.get("focus_entries", []):
1278
+ conn.execute(
1279
+ "INSERT OR IGNORE INTO daily_focus (task_id, focus_date, position, added_at) VALUES (?, ?, ?, ?)",
1280
+ (st["id"], fe["focus_date"], fe["position"], fe["added_at"]),
1281
+ )
1282
+
1283
+
1284
+ def _restore_section(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
1285
+ """Re-insert a section and reassign tasks still left ungrouped in the same project."""
1286
+ s = snap["section"]
1287
+ proj = conn.execute("SELECT id FROM projects WHERE id = ?", (s["project_id"],)).fetchone()
1288
+ if not proj:
1289
+ raise sqlite3.IntegrityError(f"Parent project {s['project_id']} no longer exists")
1290
+ conn.execute(
1291
+ "INSERT INTO sections (id, project_id, name, position, plan) VALUES (?, ?, ?, ?, ?)",
1292
+ (s["id"], s["project_id"], s["name"], s["position"], s.get("plan", "")),
1293
+ )
1294
+ for task_info in snap.get("tasks", []):
1295
+ conn.execute(
1296
+ """
1297
+ UPDATE tasks
1298
+ SET section_id = ?, last_modified = datetime('now')
1299
+ WHERE id = ? AND section_id IS NULL AND project_id = ?
1300
+ """,
1301
+ (s["id"], task_info["id"], s["project_id"]),
1302
+ )
1303
+
1304
+
1305
+ def _restore_goal(conn: sqlite3.Connection, snap: dict[str, Any]) -> None:
1306
+ """Re-insert a goal from snapshot."""
1307
+ g = snap["goal"]
1308
+ conn.execute(
1309
+ "INSERT INTO goals (id, text, timeframe, active, created_at, completed_at) VALUES (?, ?, ?, ?, ?, ?)",
1310
+ (g["id"], g["text"], g["timeframe"], g["active"], g["created_at"], g["completed_at"]),
1311
+ )
1312
+
1313
+
1314
+ def _purge_deleted_items(conn: sqlite3.Connection, older_than_hours: int = 24) -> None:
1315
+ """Best-effort cleanup for expired deleted-item snapshots."""
1316
+ try:
1317
+ conn.execute(
1318
+ "DELETE FROM deleted_items WHERE deleted_at < datetime('now', ?)",
1319
+ (f"-{older_than_hours} hours",),
1320
+ )
1321
+ conn.commit()
1322
+ except Exception:
1323
+ pass
1324
+
1325
+
958
1326
  # ---------------------------------------------------------------------------
959
1327
  # Daily Focus
960
1328
  # ---------------------------------------------------------------------------
@@ -43,6 +43,31 @@ def _json(data) -> str:
43
43
  return json.dumps(data, indent=2, default=str)
44
44
 
45
45
 
46
+ _TASK_LIST_FIELDS = {"id", "name", "status", "due_date", "project_id", "section_name", "project_name"}
47
+
48
+
49
+ def _slim_task(task: dict) -> dict:
50
+ return {k: v for k, v in task.items() if k in _TASK_LIST_FIELDS}
51
+
52
+
53
+ def _slim_search_result(task: dict) -> dict:
54
+ result = _slim_task(task)
55
+ notes = task.get("notes") or ""
56
+ if notes:
57
+ result["notes"] = notes[:200] + ("..." if len(notes) > 200 else "")
58
+ return result
59
+
60
+
61
+ _PROJECT_LIST_FIELDS = {"id", "name", "icon", "phase", "open_count", "task_count", "last_activity"}
62
+
63
+
64
+ def _slim_project(project: dict) -> dict:
65
+ base = {k: v for k, v in project.items() if k in _PROJECT_LIST_FIELDS}
66
+ if "tasks" in project:
67
+ base["tasks"] = [_slim_task(task) for task in project["tasks"]]
68
+ return base
69
+
70
+
46
71
  def _error(msg: str) -> str:
47
72
  return json.dumps({"status": "error", "error": msg})
48
73
 
@@ -192,16 +217,16 @@ def _get_serve_status() -> dict:
192
217
 
193
218
  @mcp.tool()
194
219
  def tf_list_projects(phase: Optional[str] = None) -> str:
195
- """List active projects with task counts (backlog excluded unless phase is passed)."""
220
+ """List projects (summary: id, name, phase, counts). Use tf_get_project for full plan."""
196
221
  conn = _conn()
197
222
  projects = db.list_projects(conn, phase=phase)
198
223
  conn.close()
199
- return _json({"projects": projects, "count": len(projects)})
224
+ return _json({"projects": [_slim_project(project) for project in projects], "count": len(projects)})
200
225
 
201
226
 
202
227
  @mcp.tool()
203
228
  def tf_get_project(project_id: int) -> str:
204
- """Get project details with sections and top-level tasks."""
229
+ """Get full project details with sections and slim top-level tasks. Use tf_get_task for task detail."""
205
230
  conn = _conn()
206
231
  project = db.get_project(conn, project_id)
207
232
  if not project:
@@ -210,7 +235,7 @@ def tf_get_project(project_id: int) -> str:
210
235
  sections = db.list_sections(conn, project_id)
211
236
  tasks = db.list_tasks(conn, project_id=project_id)
212
237
  conn.close()
213
- return _json({"project": project, "sections": sections, "tasks": tasks})
238
+ return _json({"project": project, "sections": sections, "tasks": [_slim_task(task) for task in tasks]})
214
239
 
215
240
 
216
241
  @mcp.tool()
@@ -335,11 +360,11 @@ def tf_list_tasks(
335
360
  status: Optional[str] = None,
336
361
  assignee: Optional[str] = None,
337
362
  ) -> str:
338
- """List tasks with optional filters. Only returns top-level tasks (not subtasks)."""
363
+ """List top-level tasks (summary: id, name, status, due_date, project context). Use tf_get_task for notes and subtasks."""
339
364
  conn = _conn()
340
365
  tasks = db.list_tasks(conn, project_id=project_id, section_id=section_id, status=status, assignee=assignee)
341
366
  conn.close()
342
- return _json({"tasks": tasks, "count": len(tasks)})
367
+ return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
343
368
 
344
369
 
345
370
  @mcp.tool()
@@ -452,47 +477,48 @@ def tf_delete_task(task_id: int) -> str:
452
477
 
453
478
  @mcp.tool()
454
479
  def tf_search(query: str, limit: int = 50) -> str:
455
- """Full-text search across task names and notes."""
480
+ """Search tasks by name/notes (summary + notes excerpt). Use tf_get_task for full detail."""
456
481
  conn = _conn()
457
482
  results = db.search_tasks(conn, query, limit)
458
483
  conn.close()
459
- return _json({"results": results, "count": len(results)})
484
+ return _json({"results": [_slim_search_result(result) for result in results], "count": len(results)})
460
485
 
461
486
 
462
487
  @mcp.tool()
463
488
  def tf_backlog() -> str:
464
- """List open top-level tasks in the backlog project."""
489
+ """List backlog tasks (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
465
490
  conn = _conn()
466
491
  tasks = db.backlog(conn)
467
492
  conn.close()
468
- return _json({"tasks": tasks, "count": len(tasks)})
493
+ return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
469
494
 
470
495
 
471
496
  @mcp.tool()
472
497
  def tf_active() -> str:
473
- """List active projects with their next open tasks and backlog count."""
498
+ """List active projects (summary fields) with slim next tasks and backlog count. Use tf_get_project/tf_get_task for full detail."""
474
499
  conn = _conn()
475
500
  data = db.active_view(conn)
476
501
  conn.close()
502
+ data["projects"] = [_slim_project(project) for project in data["projects"]]
477
503
  return _json(data)
478
504
 
479
505
 
480
506
  @mcp.tool()
481
507
  def tf_due_soon(days: int = 7) -> str:
482
- """List open tasks due within N days from today."""
508
+ """List tasks due within N days (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
483
509
  conn = _conn()
484
510
  tasks = db.due_soon(conn, days)
485
511
  conn.close()
486
- return _json({"tasks": tasks, "count": len(tasks)})
512
+ return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
487
513
 
488
514
 
489
515
  @mcp.tool()
490
516
  def tf_overdue() -> str:
491
- """List all overdue open tasks."""
517
+ """List overdue tasks (summary: id, name, status, due_date, project context). Use tf_get_task for full detail."""
492
518
  conn = _conn()
493
519
  tasks = db.overdue(conn)
494
520
  conn.close()
495
- return _json({"tasks": tasks, "count": len(tasks)})
521
+ return _json({"tasks": [_slim_task(task) for task in tasks], "count": len(tasks)})
496
522
 
497
523
 
498
524
  # ---------------------------------------------------------------------------
@@ -626,6 +652,31 @@ def tf_goal_remove(goal_id: int) -> str:
626
652
  return _json({"status": "ok" if ok else "not_found"})
627
653
 
628
654
 
655
+ @mcp.tool()
656
+ def tf_list_deleted(entity_type: str = "") -> str:
657
+ """List recently deleted items available for undo. Optional filter: 'task', 'section', or 'goal'."""
658
+ conn = _conn()
659
+ items = db.list_deleted_items(conn, entity_type or None)
660
+ conn.close()
661
+ return _json({"deleted_items": items, "count": len(items)})
662
+
663
+
664
+ @mcp.tool()
665
+ def tf_undo_delete(deleted_item_id: int) -> str:
666
+ """Restore a previously deleted item by its deleted_items ID."""
667
+ conn = _conn()
668
+ try:
669
+ result = db.restore_deleted_item(conn, deleted_item_id)
670
+ except ValueError as exc:
671
+ conn.close()
672
+ return _json({"status": "error", "error": str(exc)})
673
+ conn.close()
674
+ if result:
675
+ entity_type, entity_id = result
676
+ return _json({"status": "ok", "restored": entity_type, "entity_id": entity_id})
677
+ return _json({"status": "not_found"})
678
+
679
+
629
680
  # ---------------------------------------------------------------------------
630
681
  # Import
631
682
  # ---------------------------------------------------------------------------