taskflow-agent 0.6.0__tar.gz → 0.6.2__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.
Files changed (32) hide show
  1. taskflow_agent-0.6.2/.gitignore +22 -0
  2. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/PKG-INFO +3 -3
  3. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/pyproject.toml +2 -2
  4. taskflow_agent-0.6.2/src/approval_policy.py +42 -0
  5. taskflow_agent-0.6.2/src/calendar_context.py +39 -0
  6. taskflow_agent-0.6.2/src/contexts.py +279 -0
  7. taskflow_agent-0.6.2/src/db.py +3900 -0
  8. taskflow_agent-0.6.2/src/development.py +115 -0
  9. taskflow_agent-0.6.2/src/development_queue.py +203 -0
  10. taskflow_agent-0.6.2/src/development_sources.py +406 -0
  11. taskflow_agent-0.6.2/src/dotenv_loader.py +52 -0
  12. taskflow_agent-0.6.2/src/ideas.py +203 -0
  13. taskflow_agent-0.6.2/src/repos.py +816 -0
  14. taskflow_agent-0.6.2/src/server.py +1857 -0
  15. taskflow_agent-0.6.2/src/sub_agent_handler.py +86 -0
  16. taskflow_agent-0.6.2/src/web.py +6486 -0
  17. taskflow_agent-0.6.2/static/development.css +69 -0
  18. taskflow_agent-0.6.2/static/development.js +407 -0
  19. taskflow_agent-0.6.2/static/index.html +8734 -0
  20. taskflow_agent-0.6.0/.gitignore +0 -13
  21. taskflow_agent-0.6.0/src/db.py +0 -1471
  22. taskflow_agent-0.6.0/src/repos.py +0 -181
  23. taskflow_agent-0.6.0/src/server.py +0 -843
  24. taskflow_agent-0.6.0/src/web.py +0 -3353
  25. taskflow_agent-0.6.0/static/index.html +0 -3528
  26. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/LICENSE +0 -0
  27. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/Makefile +0 -0
  28. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/README.md +0 -0
  29. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/__init__.py +0 -0
  30. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/importer.py +0 -0
  31. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/models.py +0 -0
  32. {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/workflows.py +0 -0
@@ -0,0 +1,22 @@
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.db
5
+ .claude/
6
+ .excel_mcp_server.pid
7
+ exports/
8
+ logs/
9
+ .env
10
+ data/taskflow-web.pid
11
+ data/agent_memory.md
12
+ data/repos.json
13
+ data/repos.json.lock
14
+ # data/workflows/ — now tracked in git
15
+ data/ideas/
16
+ .gstack/
17
+ .DS_Store
18
+ .context/
19
+ dist/
20
+ *.db-wal
21
+ *.db-shm
22
+ data/contexts/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: taskflow-agent
3
- Version: 0.6.0
3
+ Version: 0.6.2
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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Topic :: Office/Business :: Scheduling
18
18
  Requires-Python: >=3.10
19
- Requires-Dist: mcp>=1.0.0
19
+ Requires-Dist: mcp<2,>=1.30.0
20
20
  Provides-Extra: web
21
21
  Requires-Dist: fastapi; extra == 'web'
22
22
  Requires-Dist: python-dotenv; extra == 'web'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "taskflow-agent"
7
- version = "0.6.0"
7
+ version = "0.6.2"
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"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Topic :: Office/Business :: Scheduling",
25
25
  ]
26
26
  dependencies = [
27
- "mcp>=1.0.0",
27
+ "mcp>=1.30.0,<2",
28
28
  ]
29
29
 
30
30
  [project.optional-dependencies]
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Collection, Mapping
4
+ from typing import Any
5
+
6
+
7
+ def make_needs_approval(
8
+ *,
9
+ local_tool_names: Collection[str],
10
+ mutating_tool_names: Collection[str],
11
+ is_mcp_tool: Callable[[str], bool],
12
+ ):
13
+ """Build Taskflow's fail-closed tool approval boundary.
14
+
15
+ Declared local reads run without prompts. Declared local mutations and all
16
+ dynamically loaded MCP tools require approval. Anything not declared by
17
+ either boundary also requires approval so newly added tools cannot silently
18
+ inherit write authority.
19
+ """
20
+ local_names = frozenset(local_tool_names)
21
+ mutating_names = frozenset(mutating_tool_names)
22
+
23
+ def needs_approval(
24
+ tool_name: str,
25
+ tool_input: Mapping[str, Any] | None = None,
26
+ qualifier: str = "",
27
+ ) -> bool:
28
+ del tool_input, qualifier
29
+ name = str(tool_name or "").strip()
30
+ if name in local_names:
31
+ return name in mutating_names
32
+ try:
33
+ if is_mcp_tool(name):
34
+ return True
35
+ except Exception:
36
+ return True
37
+ return True
38
+
39
+ return needs_approval
40
+
41
+
42
+ __all__ = ["make_needs_approval"]
@@ -0,0 +1,39 @@
1
+ """Authoritative calendar context for Taskflow agent prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import os
7
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
8
+
9
+
10
+ def current_date_context(
11
+ *,
12
+ now: datetime | None = None,
13
+ timezone_name: str | None = None,
14
+ ) -> str:
15
+ """Return timezone-aware date guidance for relative-date reasoning."""
16
+
17
+ configured_name = str(
18
+ timezone_name or os.getenv("TASKFLOW_TIMEZONE", "America/New_York")
19
+ ).strip()
20
+ if not configured_name:
21
+ configured_name = "America/New_York"
22
+ try:
23
+ local_timezone = ZoneInfo(configured_name)
24
+ except ZoneInfoNotFoundError as exc:
25
+ raise RuntimeError(f"invalid TASKFLOW_TIMEZONE: {configured_name!r}") from exc
26
+ instant = now or datetime.now(timezone.utc)
27
+ if instant.tzinfo is None:
28
+ instant = instant.replace(tzinfo=timezone.utc)
29
+ local_now = instant.astimezone(local_timezone)
30
+ return (
31
+ "CURRENT DATE CONTEXT:\n"
32
+ f"Today is {local_now.strftime('%A, %B')} {local_now.day}, {local_now.year} "
33
+ f"({local_now.date().isoformat()}).\n"
34
+ f"Time zone: {configured_name}. Use this date and time zone for relative dates, "
35
+ "deadlines, day-of-week calculations, and phrases such as today, tomorrow, or this week."
36
+ )
37
+
38
+
39
+ __all__ = ["current_date_context"]
@@ -0,0 +1,279 @@
1
+ """Per-project resource discovery context helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import tempfile
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from . import db
13
+
14
+ CONTEXTS_DIR = Path(__file__).resolve().parent.parent / "data" / "contexts"
15
+ MAX_BYTES = 8_192
16
+ MAX_ALIASES = 20
17
+ MAX_SEARCH_TERMS = 30
18
+ MAX_LOCATION_ITEMS = 10
19
+ MAX_REPOS = 10
20
+ MAX_ALIAS_CHARS = 100
21
+ MAX_SEARCH_TERM_CHARS = 100
22
+ MAX_LOCATION_CHARS = 200
23
+ MAX_NOTES_CHARS = 500
24
+ MAX_REPO_NAME_CHARS = 60
25
+ _REPO_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
26
+ _TOP_LEVEL_KEYS = {"aliases", "search_terms", "locations", "repos", "last_scan_at", "notes"}
27
+ _LOCATION_KEYS = {"gdrive_folders", "roam_tags", "notes_queries", "onedrive_folders", "local_paths"}
28
+
29
+
30
+ def get_context(project_id: int) -> dict[str, Any] | None:
31
+ """Return stored context with injected read-only project metadata."""
32
+ project_id = _normalize_project_id(project_id)
33
+ path = CONTEXTS_DIR / f"{project_id}.json"
34
+ try:
35
+ if path.stat().st_size > MAX_BYTES:
36
+ raise ValueError(f"Context for project {project_id} exceeds size limit")
37
+ except FileNotFoundError:
38
+ return None
39
+
40
+ try:
41
+ raw = json.loads(path.read_text(encoding="utf-8"))
42
+ except json.JSONDecodeError as exc:
43
+ raise ValueError(f"Context for project {project_id} is not valid JSON") from exc
44
+ if not isinstance(raw, dict):
45
+ raise ValueError(f"Context for project {project_id} must be a JSON object")
46
+
47
+ errors = validate_context(raw)
48
+ if errors:
49
+ raise ValueError("; ".join(errors))
50
+
51
+ stored = _normalize_context_data(raw)
52
+ project_name = _project_name(project_id)
53
+ stored["_project_id"] = project_id
54
+ stored["_project_name"] = project_name
55
+ return stored
56
+
57
+
58
+ def save_context(project_id: int, data: dict[str, Any]) -> dict[str, Any]:
59
+ """Replace a project's stored context atomically after validation."""
60
+ project_id = _normalize_project_id(project_id)
61
+ if not isinstance(data, dict):
62
+ raise ValueError("context must be an object")
63
+
64
+ conn = db.get_conn()
65
+ try:
66
+ project = db.get_project(conn, project_id)
67
+ finally:
68
+ conn.close()
69
+ if not project:
70
+ raise ValueError(f"Project {project_id} not found")
71
+ if project.get("archived"):
72
+ raise ValueError(f"Project {project_id} is archived")
73
+
74
+ errors = validate_context(data)
75
+ if errors:
76
+ raise ValueError("; ".join(errors))
77
+ stored = _normalize_context_data(data)
78
+
79
+ encoded = json.dumps(stored, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
80
+ if len(encoded.encode("utf-8")) > MAX_BYTES:
81
+ raise ValueError("Context exceeds size limit (8 KB)")
82
+
83
+ CONTEXTS_DIR.mkdir(parents=True, exist_ok=True)
84
+ path = CONTEXTS_DIR / f"{project_id}.json"
85
+ tmp_path: Path | None = None
86
+ try:
87
+ with tempfile.NamedTemporaryFile(
88
+ "w",
89
+ encoding="utf-8",
90
+ dir=CONTEXTS_DIR,
91
+ prefix=f".{project_id}-",
92
+ suffix=".tmp",
93
+ delete=False,
94
+ ) as tmp_file:
95
+ tmp_file.write(encoded)
96
+ tmp_path = Path(tmp_file.name)
97
+ tmp_path.replace(path)
98
+ except OSError:
99
+ if tmp_path is not None:
100
+ tmp_path.unlink(missing_ok=True)
101
+ raise
102
+
103
+ return {"status": "ok", "project_id": project_id, "path": str(path), "bytes": len(encoded.encode("utf-8"))}
104
+
105
+
106
+ def validate_context(data: dict[str, Any]) -> list[str]:
107
+ """Return validation errors for stored context data."""
108
+ if not isinstance(data, dict):
109
+ return ["context must be an object"]
110
+
111
+ cleaned = _strip_readonly_fields(data)
112
+ errors: list[str] = []
113
+ unknown_keys = sorted(set(cleaned) - _TOP_LEVEL_KEYS)
114
+ if unknown_keys:
115
+ errors.append(f"Unknown context keys: {', '.join(unknown_keys)}")
116
+
117
+ errors.extend(_validate_string_list(cleaned.get("aliases", []), "aliases", MAX_ALIASES, MAX_ALIAS_CHARS))
118
+ errors.extend(
119
+ _validate_string_list(
120
+ cleaned.get("search_terms", []),
121
+ "search_terms",
122
+ MAX_SEARCH_TERMS,
123
+ MAX_SEARCH_TERM_CHARS,
124
+ )
125
+ )
126
+ errors.extend(_validate_locations(cleaned.get("locations", {})))
127
+ errors.extend(_validate_repos(cleaned.get("repos", [])))
128
+
129
+ last_scan_at = cleaned.get("last_scan_at", "")
130
+ if not isinstance(last_scan_at, str):
131
+ errors.append("last_scan_at must be a string")
132
+ elif last_scan_at.strip():
133
+ try:
134
+ datetime.fromisoformat(last_scan_at.strip().replace("Z", "+00:00"))
135
+ except ValueError:
136
+ errors.append("last_scan_at must be an ISO 8601 datetime string")
137
+
138
+ notes = cleaned.get("notes", "")
139
+ if not isinstance(notes, str):
140
+ errors.append("notes must be a string")
141
+ elif len(notes.strip()) > MAX_NOTES_CHARS:
142
+ errors.append(f"notes must be at most {MAX_NOTES_CHARS} characters")
143
+
144
+ return errors
145
+
146
+
147
+ def list_contexts() -> list[dict[str, Any]]:
148
+ """Return summaries for valid context files and remove orphans."""
149
+ if not CONTEXTS_DIR.is_dir():
150
+ return []
151
+
152
+ conn = db.get_conn()
153
+ try:
154
+ items: list[dict[str, Any]] = []
155
+ for path in sorted(CONTEXTS_DIR.glob("*.json")):
156
+ try:
157
+ project_id = int(path.stem)
158
+ except ValueError:
159
+ continue
160
+ project = db.get_project(conn, project_id)
161
+ if not project:
162
+ path.unlink(missing_ok=True)
163
+ continue
164
+ try:
165
+ if path.stat().st_size > MAX_BYTES:
166
+ continue
167
+ raw = json.loads(path.read_text(encoding="utf-8"))
168
+ except (OSError, ValueError, json.JSONDecodeError):
169
+ continue
170
+ if not isinstance(raw, dict):
171
+ continue
172
+ items.append(
173
+ {
174
+ "project_id": project_id,
175
+ "project_name": str(project.get("name", "")),
176
+ "alias_count": len(raw.get("aliases", [])) if isinstance(raw.get("aliases"), list) else 0,
177
+ "search_term_count": len(raw.get("search_terms", [])) if isinstance(raw.get("search_terms"), list) else 0,
178
+ "repo_count": len(raw.get("repos", [])) if isinstance(raw.get("repos"), list) else 0,
179
+ "last_scan_at": str(raw.get("last_scan_at", "")),
180
+ }
181
+ )
182
+ return items
183
+ finally:
184
+ conn.close()
185
+
186
+
187
+ def delete_context(project_id: int) -> bool:
188
+ """Delete a project's context file if present."""
189
+ project_id = _normalize_project_id(project_id)
190
+ path = CONTEXTS_DIR / f"{project_id}.json"
191
+ existed = path.exists()
192
+ path.unlink(missing_ok=True)
193
+ return existed
194
+
195
+
196
+ def _strip_readonly_fields(data: dict[str, Any]) -> dict[str, Any]:
197
+ return {str(key): value for key, value in data.items() if not str(key).startswith("_")}
198
+
199
+
200
+ def _normalize_context_data(data: dict[str, Any]) -> dict[str, Any]:
201
+ cleaned = _strip_readonly_fields(data)
202
+ locations = cleaned.get("locations", {})
203
+ normalized_locations = {
204
+ key: [str(item).strip() for item in locations.get(key, []) if str(item).strip()]
205
+ for key in sorted(_LOCATION_KEYS)
206
+ if isinstance(locations, dict) and key in locations
207
+ }
208
+ return {
209
+ "aliases": [str(item).strip() for item in cleaned.get("aliases", []) if str(item).strip()],
210
+ "search_terms": [str(item).strip() for item in cleaned.get("search_terms", []) if str(item).strip()],
211
+ "locations": normalized_locations,
212
+ "repos": [str(item).strip() for item in cleaned.get("repos", []) if str(item).strip()],
213
+ "last_scan_at": str(cleaned.get("last_scan_at", "")).strip(),
214
+ "notes": str(cleaned.get("notes", "")).strip(),
215
+ }
216
+
217
+
218
+ def _validate_string_list(value: Any, field: str, max_items: int, max_chars: int) -> list[str]:
219
+ if value is None:
220
+ return []
221
+ if not isinstance(value, list):
222
+ return [f"{field} must be a list"]
223
+ errors: list[str] = []
224
+ if len(value) > max_items:
225
+ errors.append(f"{field} must have at most {max_items} items")
226
+ for index, item in enumerate(value, start=1):
227
+ if not isinstance(item, str):
228
+ errors.append(f"{field}[{index}] must be a string")
229
+ continue
230
+ if len(item.strip()) > max_chars:
231
+ errors.append(f"{field}[{index}] must be at most {max_chars} characters")
232
+ return errors
233
+
234
+
235
+ def _validate_locations(value: Any) -> list[str]:
236
+ if value is None:
237
+ return []
238
+ if not isinstance(value, dict):
239
+ return ["locations must be an object"]
240
+ errors: list[str] = []
241
+ unknown_keys = sorted(set(value) - _LOCATION_KEYS)
242
+ if unknown_keys:
243
+ errors.append(f"Unknown location keys: {', '.join(unknown_keys)}")
244
+ for key in sorted(_LOCATION_KEYS):
245
+ errors.extend(_validate_string_list(value.get(key, []), f"locations.{key}", MAX_LOCATION_ITEMS, MAX_LOCATION_CHARS))
246
+ return errors
247
+
248
+
249
+ def _validate_repos(value: Any) -> list[str]:
250
+ if value is None:
251
+ return []
252
+ errors = _validate_string_list(value, "repos", MAX_REPOS, MAX_REPO_NAME_CHARS)
253
+ if not isinstance(value, list):
254
+ return errors
255
+ for index, item in enumerate(value, start=1):
256
+ if isinstance(item, str) and item.strip() and not _REPO_NAME_RE.match(item.strip()):
257
+ errors.append(f"repos[{index}] has invalid repo name")
258
+ return errors
259
+
260
+
261
+ def _normalize_project_id(project_id: int) -> int:
262
+ if isinstance(project_id, bool):
263
+ raise ValueError("project_id must be an integer")
264
+ try:
265
+ value = int(project_id)
266
+ except (TypeError, ValueError) as exc:
267
+ raise ValueError("project_id must be an integer") from exc
268
+ if value <= 0:
269
+ raise ValueError("project_id must be a positive integer")
270
+ return value
271
+
272
+
273
+ def _project_name(project_id: int) -> str:
274
+ conn = db.get_conn()
275
+ try:
276
+ project = db.get_project(conn, project_id)
277
+ finally:
278
+ conn.close()
279
+ return str(project.get("name", "")) if project else ""