taskflow-agent 0.2.2__tar.gz → 0.3.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.
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/.gitignore +1 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/PKG-INFO +91 -35
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/README.md +90 -34
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/pyproject.toml +1 -1
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/server.py +36 -2
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/web.py +233 -10
- taskflow_agent-0.3.0/src/workflows.py +138 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/static/index.html +146 -5
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/LICENSE +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/Makefile +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/__init__.py +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/db.py +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/importer.py +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/models.py +0 -0
- {taskflow_agent-0.2.2 → taskflow_agent-0.3.0}/src/repos.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: taskflow-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -31,31 +31,92 @@ SQLite backend, 23+ MCP tools, web UI with embedded AI chat, and a FastAPI REST
|
|
|
31
31
|
|
|
32
32
|
## Features
|
|
33
33
|
|
|
34
|
-
- **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
|
|
34
|
+
- **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
|
|
35
35
|
- **Web UI** — dark-theme SPA with project boards, task details, inline editing
|
|
36
36
|
- **AI chat** — embedded Claude chat with workspace awareness, tool access, and persistent memory
|
|
37
37
|
- **Daily focus** — Today view with goals, focus list, and AI-assisted daily planning
|
|
38
38
|
- **Goals** — timeframe-scoped goals (day/week/month/quarter) that guide daily prioritization
|
|
39
|
-
- **Agent memory** — persistent context across chat sessions
|
|
39
|
+
- **Agent memory** — persistent context across chat sessions
|
|
40
40
|
- **Repo integration** — read-only git status, recent commits, and TODOs across connected repos
|
|
41
41
|
- **FTS search** — full-text search across task names and notes
|
|
42
|
-
- **Server-side chat storage** — chat history persisted in SQLite with compaction
|
|
43
42
|
- **Asana import** — bulk import from Asana CSV exports
|
|
44
|
-
- **Service management** — start/stop the web server via MCP tools or Makefile
|
|
45
43
|
|
|
46
44
|
## Quick Start
|
|
47
45
|
|
|
48
46
|
```bash
|
|
49
|
-
pip install -
|
|
47
|
+
pip install taskflow-agent[web]
|
|
50
48
|
|
|
51
49
|
# Start the MCP server (for Claude Code)
|
|
52
50
|
taskflow
|
|
53
51
|
|
|
54
|
-
# Start the web UI
|
|
55
|
-
|
|
56
|
-
taskflow-web # via CLI
|
|
52
|
+
# Start the web UI (port 8787)
|
|
53
|
+
taskflow-web
|
|
57
54
|
```
|
|
58
55
|
|
|
56
|
+
## Setup
|
|
57
|
+
|
|
58
|
+
### 1. Environment
|
|
59
|
+
|
|
60
|
+
Create a `.env` file in your working directory:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Auth — pick one mode
|
|
64
|
+
ANTHROPIC_AUTH_MODE=api_key # "oauth" or "api_key"
|
|
65
|
+
ANTHROPIC_API_KEY=sk-ant-... # if using api_key mode
|
|
66
|
+
ANTHROPIC_AUTH_TOKEN=... # if using oauth mode
|
|
67
|
+
|
|
68
|
+
# Optional
|
|
69
|
+
ANTHROPIC_MODEL=claude-sonnet-4-6 # default model for chat
|
|
70
|
+
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The MCP server (task management tools) works without auth. Auth is only needed for the web UI's embedded AI chat.
|
|
74
|
+
|
|
75
|
+
### 2. Register MCP Server
|
|
76
|
+
|
|
77
|
+
Add to `~/.claude.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"taskflow": {
|
|
83
|
+
"type": "stdio",
|
|
84
|
+
"command": "path/to/venv/bin/python",
|
|
85
|
+
"args": ["-m", "src.server"],
|
|
86
|
+
"cwd": "path/to/taskflow"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 3. Configure Repos (optional)
|
|
93
|
+
|
|
94
|
+
Create `data/repos.json` to connect git repos for status tracking:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"my-project": "/absolute/path/to/my-project",
|
|
99
|
+
"another-repo": "/absolute/path/to/another-repo"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`tf_repo_list` and `tf_repo_status` use this to show branch, state, recent commits, and TODOs. Read-only.
|
|
104
|
+
|
|
105
|
+
### 4. Run
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Web UI
|
|
109
|
+
taskflow-web # port 8787
|
|
110
|
+
# or with make (if developing from source):
|
|
111
|
+
make serve # foreground
|
|
112
|
+
make dev # with auto-reload
|
|
113
|
+
|
|
114
|
+
# MCP server only
|
|
115
|
+
taskflow
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Open `http://localhost:8787`.
|
|
119
|
+
|
|
59
120
|
## MCP Tools
|
|
60
121
|
|
|
61
122
|
### Projects
|
|
@@ -123,41 +184,36 @@ taskflow-web # via CLI
|
|
|
123
184
|
| `tf_serve_start` | Start web server in background |
|
|
124
185
|
| `tf_serve_stop` | Stop web server |
|
|
125
186
|
|
|
126
|
-
##
|
|
187
|
+
## Embedded Chat
|
|
127
188
|
|
|
128
|
-
|
|
189
|
+
The web UI includes an AI chat panel (toggle with `C`) powered by [ai-agent-gateway](https://pypi.org/project/ai-agent-gateway/).
|
|
129
190
|
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
"mcpServers": {
|
|
133
|
-
"taskflow": {
|
|
134
|
-
"type": "stdio",
|
|
135
|
-
"command": "path/to/venv/bin/python",
|
|
136
|
-
"args": ["-m", "src.server"],
|
|
137
|
-
"cwd": "path/to/taskflow"
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
## Web UI
|
|
191
|
+
### What the chat agent can do
|
|
144
192
|
|
|
145
|
-
|
|
193
|
+
- All `tf_*` tools — manage projects, tasks, goals, focus
|
|
194
|
+
- `read_file` / `list_dir` / `run_shell` — filesystem access
|
|
195
|
+
- `notes_search` / `notes_read` — Apple Notes integration
|
|
196
|
+
- `tf_memory_read` / `tf_memory_update` — persistent memory across sessions (stored in `data/agent_memory.md`, 12 KB max)
|
|
197
|
+
- `load_tools` — dynamically load any MCP server from `~/.claude.json` on demand
|
|
146
198
|
|
|
147
|
-
|
|
148
|
-
make serve # foreground, Ctrl-C to stop
|
|
149
|
-
make dev # with auto-reload
|
|
150
|
-
make status # check if running
|
|
151
|
-
make stop # stop the server
|
|
152
|
-
```
|
|
199
|
+
### Deferred MCP Servers
|
|
153
200
|
|
|
154
|
-
|
|
201
|
+
The chat agent can load any `stdio`-type MCP server registered in your `~/.claude.json` on demand. The agent calls `load_tools("server-name")` and gains access to that server's tools for the session.
|
|
155
202
|
|
|
156
203
|
## Database
|
|
157
204
|
|
|
158
|
-
SQLite with WAL mode.
|
|
205
|
+
SQLite with WAL mode. Created automatically on first run.
|
|
206
|
+
|
|
207
|
+
Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `daily_focus`, `chat_messages`.
|
|
208
|
+
|
|
209
|
+
## Asana Import
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
# Via MCP tool:
|
|
213
|
+
tf_import_asana directory=/path/to/Asana-Export/
|
|
214
|
+
```
|
|
159
215
|
|
|
160
|
-
|
|
216
|
+
Import is additive — re-importing creates duplicates. Delete `taskflow.db` first for a clean re-import.
|
|
161
217
|
|
|
162
218
|
## License
|
|
163
219
|
|
|
@@ -6,31 +6,92 @@ SQLite backend, 23+ MCP tools, web UI with embedded AI chat, and a FastAPI REST
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
|
|
9
|
+
- **23+ MCP tools** — projects, tasks, sections, goals, daily focus, search, views, repo integration
|
|
10
10
|
- **Web UI** — dark-theme SPA with project boards, task details, inline editing
|
|
11
11
|
- **AI chat** — embedded Claude chat with workspace awareness, tool access, and persistent memory
|
|
12
12
|
- **Daily focus** — Today view with goals, focus list, and AI-assisted daily planning
|
|
13
13
|
- **Goals** — timeframe-scoped goals (day/week/month/quarter) that guide daily prioritization
|
|
14
|
-
- **Agent memory** — persistent context across chat sessions
|
|
14
|
+
- **Agent memory** — persistent context across chat sessions
|
|
15
15
|
- **Repo integration** — read-only git status, recent commits, and TODOs across connected repos
|
|
16
16
|
- **FTS search** — full-text search across task names and notes
|
|
17
|
-
- **Server-side chat storage** — chat history persisted in SQLite with compaction
|
|
18
17
|
- **Asana import** — bulk import from Asana CSV exports
|
|
19
|
-
- **Service management** — start/stop the web server via MCP tools or Makefile
|
|
20
18
|
|
|
21
19
|
## Quick Start
|
|
22
20
|
|
|
23
21
|
```bash
|
|
24
|
-
pip install -
|
|
22
|
+
pip install taskflow-agent[web]
|
|
25
23
|
|
|
26
24
|
# Start the MCP server (for Claude Code)
|
|
27
25
|
taskflow
|
|
28
26
|
|
|
29
|
-
# Start the web UI
|
|
30
|
-
|
|
31
|
-
taskflow-web # via CLI
|
|
27
|
+
# Start the web UI (port 8787)
|
|
28
|
+
taskflow-web
|
|
32
29
|
```
|
|
33
30
|
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
### 1. Environment
|
|
34
|
+
|
|
35
|
+
Create a `.env` file in your working directory:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Auth — pick one mode
|
|
39
|
+
ANTHROPIC_AUTH_MODE=api_key # "oauth" or "api_key"
|
|
40
|
+
ANTHROPIC_API_KEY=sk-ant-... # if using api_key mode
|
|
41
|
+
ANTHROPIC_AUTH_TOKEN=... # if using oauth mode
|
|
42
|
+
|
|
43
|
+
# Optional
|
|
44
|
+
ANTHROPIC_MODEL=claude-sonnet-4-6 # default model for chat
|
|
45
|
+
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The MCP server (task management tools) works without auth. Auth is only needed for the web UI's embedded AI chat.
|
|
49
|
+
|
|
50
|
+
### 2. Register MCP Server
|
|
51
|
+
|
|
52
|
+
Add to `~/.claude.json`:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"mcpServers": {
|
|
57
|
+
"taskflow": {
|
|
58
|
+
"type": "stdio",
|
|
59
|
+
"command": "path/to/venv/bin/python",
|
|
60
|
+
"args": ["-m", "src.server"],
|
|
61
|
+
"cwd": "path/to/taskflow"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Configure Repos (optional)
|
|
68
|
+
|
|
69
|
+
Create `data/repos.json` to connect git repos for status tracking:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"my-project": "/absolute/path/to/my-project",
|
|
74
|
+
"another-repo": "/absolute/path/to/another-repo"
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`tf_repo_list` and `tf_repo_status` use this to show branch, state, recent commits, and TODOs. Read-only.
|
|
79
|
+
|
|
80
|
+
### 4. Run
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Web UI
|
|
84
|
+
taskflow-web # port 8787
|
|
85
|
+
# or with make (if developing from source):
|
|
86
|
+
make serve # foreground
|
|
87
|
+
make dev # with auto-reload
|
|
88
|
+
|
|
89
|
+
# MCP server only
|
|
90
|
+
taskflow
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Open `http://localhost:8787`.
|
|
94
|
+
|
|
34
95
|
## MCP Tools
|
|
35
96
|
|
|
36
97
|
### Projects
|
|
@@ -98,41 +159,36 @@ taskflow-web # via CLI
|
|
|
98
159
|
| `tf_serve_start` | Start web server in background |
|
|
99
160
|
| `tf_serve_stop` | Stop web server |
|
|
100
161
|
|
|
101
|
-
##
|
|
162
|
+
## Embedded Chat
|
|
102
163
|
|
|
103
|
-
|
|
164
|
+
The web UI includes an AI chat panel (toggle with `C`) powered by [ai-agent-gateway](https://pypi.org/project/ai-agent-gateway/).
|
|
104
165
|
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
"mcpServers": {
|
|
108
|
-
"taskflow": {
|
|
109
|
-
"type": "stdio",
|
|
110
|
-
"command": "path/to/venv/bin/python",
|
|
111
|
-
"args": ["-m", "src.server"],
|
|
112
|
-
"cwd": "path/to/taskflow"
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
## Web UI
|
|
166
|
+
### What the chat agent can do
|
|
119
167
|
|
|
120
|
-
|
|
168
|
+
- All `tf_*` tools — manage projects, tasks, goals, focus
|
|
169
|
+
- `read_file` / `list_dir` / `run_shell` — filesystem access
|
|
170
|
+
- `notes_search` / `notes_read` — Apple Notes integration
|
|
171
|
+
- `tf_memory_read` / `tf_memory_update` — persistent memory across sessions (stored in `data/agent_memory.md`, 12 KB max)
|
|
172
|
+
- `load_tools` — dynamically load any MCP server from `~/.claude.json` on demand
|
|
121
173
|
|
|
122
|
-
|
|
123
|
-
make serve # foreground, Ctrl-C to stop
|
|
124
|
-
make dev # with auto-reload
|
|
125
|
-
make status # check if running
|
|
126
|
-
make stop # stop the server
|
|
127
|
-
```
|
|
174
|
+
### Deferred MCP Servers
|
|
128
175
|
|
|
129
|
-
|
|
176
|
+
The chat agent can load any `stdio`-type MCP server registered in your `~/.claude.json` on demand. The agent calls `load_tools("server-name")` and gains access to that server's tools for the session.
|
|
130
177
|
|
|
131
178
|
## Database
|
|
132
179
|
|
|
133
|
-
SQLite with WAL mode.
|
|
180
|
+
SQLite with WAL mode. Created automatically on first run.
|
|
181
|
+
|
|
182
|
+
Tables: `projects`, `sections`, `tasks`, `tags`, `task_tags`, `tasks_fts` (FTS5), `goals`, `daily_focus`, `chat_messages`.
|
|
183
|
+
|
|
184
|
+
## Asana Import
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
# Via MCP tool:
|
|
188
|
+
tf_import_asana directory=/path/to/Asana-Export/
|
|
189
|
+
```
|
|
134
190
|
|
|
135
|
-
|
|
191
|
+
Import is additive — re-importing creates duplicates. Delete `taskflow.db` first for a clean re-import.
|
|
136
192
|
|
|
137
193
|
## License
|
|
138
194
|
|
|
@@ -14,7 +14,7 @@ from typing import Optional
|
|
|
14
14
|
|
|
15
15
|
from mcp.server.fastmcp import FastMCP
|
|
16
16
|
|
|
17
|
-
from . import db
|
|
17
|
+
from . import db, workflows
|
|
18
18
|
|
|
19
19
|
mcp = FastMCP(
|
|
20
20
|
"taskflow",
|
|
@@ -28,6 +28,7 @@ mcp = FastMCP(
|
|
|
28
28
|
db.init_db()
|
|
29
29
|
|
|
30
30
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
31
|
+
_VENV_PYTHON = _PROJECT_ROOT / "venv" / "bin" / "python"
|
|
31
32
|
_PID_FILE = _PROJECT_ROOT / "data" / "taskflow-web.pid"
|
|
32
33
|
_LOG_DIR = _PROJECT_ROOT / "logs"
|
|
33
34
|
_LOG_FILE = _LOG_DIR / "web.log"
|
|
@@ -659,7 +660,7 @@ def tf_serve_start() -> str:
|
|
|
659
660
|
log_fh = open(_LOG_FILE, "a")
|
|
660
661
|
try:
|
|
661
662
|
proc = subprocess.Popen(
|
|
662
|
-
[sys.executable, "-m", "src.web"],
|
|
663
|
+
[str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable, "-m", "src.web"],
|
|
663
664
|
cwd=str(_PROJECT_ROOT),
|
|
664
665
|
stdout=log_fh,
|
|
665
666
|
stderr=subprocess.STDOUT,
|
|
@@ -750,6 +751,39 @@ def tf_repo_status(repo: str = "all", commits: int = 10) -> str:
|
|
|
750
751
|
return _json(repos.repo_status(repo, commit_count=commits))
|
|
751
752
|
|
|
752
753
|
|
|
754
|
+
@mcp.tool()
|
|
755
|
+
def tf_workflow_list() -> str:
|
|
756
|
+
"""List available workflow templates."""
|
|
757
|
+
workflow_items = workflows.list_workflows()
|
|
758
|
+
return _json({"workflows": workflow_items, "count": len(workflow_items)})
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
@mcp.tool()
|
|
762
|
+
def tf_workflow_get(slug: str) -> str:
|
|
763
|
+
"""Read a workflow template by slug."""
|
|
764
|
+
try:
|
|
765
|
+
workflow = workflows.get_workflow(slug)
|
|
766
|
+
except ValueError as exc:
|
|
767
|
+
return _error(str(exc))
|
|
768
|
+
except OSError as exc:
|
|
769
|
+
return _error(f"Could not read workflow '{slug}': {exc}")
|
|
770
|
+
if workflow is None:
|
|
771
|
+
return _error(f"Workflow '{slug}' not found")
|
|
772
|
+
return _json(workflow)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@mcp.tool()
|
|
776
|
+
def tf_workflow_save(slug: str, content: str) -> str:
|
|
777
|
+
"""Create or update a workflow template."""
|
|
778
|
+
try:
|
|
779
|
+
result = workflows.save_workflow(slug, content)
|
|
780
|
+
except ValueError as exc:
|
|
781
|
+
return _error(str(exc))
|
|
782
|
+
except OSError as exc:
|
|
783
|
+
return _error(f"Could not write workflow '{slug}': {exc}")
|
|
784
|
+
return _json(result)
|
|
785
|
+
|
|
786
|
+
|
|
753
787
|
def main():
|
|
754
788
|
mcp.run()
|
|
755
789
|
|
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import sqlite3
|
|
11
11
|
import subprocess
|
|
12
12
|
import time
|
|
13
|
+
from datetime import date as dt_date
|
|
13
14
|
from logging.handlers import RotatingFileHandler
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
@@ -53,13 +54,14 @@ logging.getLogger("claude_gateway").addHandler(_file_handler)
|
|
|
53
54
|
from typing import Any, Literal, Optional
|
|
54
55
|
from uuid import uuid4
|
|
55
56
|
|
|
56
|
-
from claude_gateway import AgentRunner, EventLog, McpClientManager
|
|
57
|
+
from claude_gateway import AgentRunner, EventLog, McpClientManager
|
|
58
|
+
from claude_gateway.tool_dispatcher import ToolDispatcher
|
|
57
59
|
from fastapi import FastAPI, HTTPException, Request
|
|
58
60
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
59
61
|
from fastapi.staticfiles import StaticFiles
|
|
60
62
|
from pydantic import BaseModel, Field
|
|
61
63
|
|
|
62
|
-
from . import db
|
|
64
|
+
from . import db, workflows
|
|
63
65
|
|
|
64
66
|
app = FastAPI(title="Taskflow")
|
|
65
67
|
|
|
@@ -147,6 +149,7 @@ class TaskUpdate(BaseModel):
|
|
|
147
149
|
due_date: Optional[str] = None
|
|
148
150
|
tags: Optional[str] = None
|
|
149
151
|
section_id: Optional[int] = None
|
|
152
|
+
position: Optional[int] = None
|
|
150
153
|
|
|
151
154
|
|
|
152
155
|
class ProjectCreate(BaseModel):
|
|
@@ -701,15 +704,19 @@ that code projects get naturally: everything in one place, AI collaborator, clea
|
|
|
701
704
|
|
|
702
705
|
Structure follows three layers:
|
|
703
706
|
- Project plan = the orientation ("what are we doing and why") — freeform markdown
|
|
704
|
-
- Sections =
|
|
707
|
+
- Sections = PHASES IN A SEQUENCE, not categories. They answer "what order do I do this in."
|
|
708
|
+
Good: "Phase 1: Schema", "Phase 2: API". Bad: "API", "Frontend", "Database" (that's just filing).
|
|
709
|
+
Do NOT default to grouping by category — always think about sequencing and what comes next.
|
|
705
710
|
- Tasks = atomic action items extracted from plans
|
|
706
711
|
|
|
707
712
|
Key principles:
|
|
708
713
|
- Plans before tasks — think first, structure second, execute third. Don't jump to creating tasks.
|
|
709
714
|
- Goals set direction — concrete targets (not vague aspirations) that drive daily focus choices.
|
|
710
|
-
-
|
|
715
|
+
- Daily planning = prioritization + scoping against time. What's highest leverage? What unblocks things?
|
|
716
|
+
How much time is available? What fits, what gets cut? Rough-estimate in conversation, not schema fields.
|
|
711
717
|
- Collaborator, not task bot — read the plan, understand context, suggest and act, don't just CRUD.
|
|
712
718
|
- Connected — you can reach into Roam, Drive, Sheets, Gmail, etc. to do real work, not just manage tasks.
|
|
719
|
+
- Taskflow is for projects with forward motion. Admin tasks, routines, and errands stay in Roam.
|
|
713
720
|
|
|
714
721
|
MEMORY MANAGEMENT:
|
|
715
722
|
Use tf_memory_read / tf_memory_update to maintain persistent context across sessions.
|
|
@@ -746,6 +753,7 @@ Core (always loaded):
|
|
|
746
753
|
- `tf_*` tools — manage Taskflow projects, sections, tasks, search, and workspace views.
|
|
747
754
|
- `tf_today` / `tf_focus` / `tf_unfocus` / `tf_move_focus` — daily focus list management.
|
|
748
755
|
- `tf_create_goal` / `tf_update_goal` / `tf_goal_list` / `tf_goal_complete` / `tf_goal_reopen` / `tf_goal_remove` — goal management.
|
|
756
|
+
- `tf_workflow_list` / `tf_workflow_get` / `tf_workflow_save` — reusable project templates.
|
|
749
757
|
- `read_file` / `list_dir` / `run_shell` — read files, browse directories, run shell commands (git, grep, etc.).
|
|
750
758
|
- `notes_search` / `notes_read` — search and read Apple Notes (the user's phone-accessible capture tool).
|
|
751
759
|
- `tf_repo_list` / `tf_repo_status` — check git status, recent commits, and open TODOs across connected repositories.
|
|
@@ -758,6 +766,13 @@ Deferred MCP servers (call `load_tools` with server_name first):
|
|
|
758
766
|
- `notify` — Send notifications via Telegram or iMessage.
|
|
759
767
|
- Other servers: {', '.join(s for s in deferred_tools if s not in ('roam-research', 'drive-mcp', 'gsheets-mcp', 'gmail-mcp', 'notify'))}
|
|
760
768
|
|
|
769
|
+
Sub-agent delegation:
|
|
770
|
+
- You have a `run_agent` tool that spawns a focused sub-agent with its own context window.
|
|
771
|
+
- Use it for intensive work that would bloat this conversation: code exploration, audits, file analysis, searching through notes or Roam pages, or research tasks that require many tool calls.
|
|
772
|
+
- The sub-agent is read-only. It cannot create, update, or delete tasks or projects. You handle all mutations based on its findings.
|
|
773
|
+
- Write detailed, specific instructions in the `task` field. The sub-agent has access to `read_file`, `list_dir`, git repo tools, read-only task tools, Apple Notes, and any MCP servers you've already loaded via `load_tools`. It does NOT have `run_shell`.
|
|
774
|
+
- If you need the sub-agent to access an MCP server such as Roam, call `load_tools` first, then spawn the agent.
|
|
775
|
+
|
|
761
776
|
BEHAVIORAL GUIDELINES:
|
|
762
777
|
- When the user is looking at a project, treat that project as the default context.
|
|
763
778
|
- Reference project plans when suggesting next steps.
|
|
@@ -765,11 +780,15 @@ BEHAVIORAL GUIDELINES:
|
|
|
765
780
|
- For planning-heavy requests, improve the plan markdown first, then extract tasks.
|
|
766
781
|
- When the user mentions notes or ideas they captured, check Apple Notes or Roam.
|
|
767
782
|
- When discussing code projects or repo work, use `tf_repo_status` to check current state before making recommendations.
|
|
783
|
+
- When the user wants to start a repeatable project type (video, thesis, blog post), check `tf_workflow_list` first. If a matching workflow exists, read it with `tf_workflow_get` and propose using it to scaffold the project. Wait for user confirmation before creating. Customize the plan from context; workflows are starting points, not rigid scripts.
|
|
784
|
+
- When a project reveals a repeatable process, suggest saving it as a workflow for next time.
|
|
785
|
+
- Workflows live in `data/workflows/` as markdown files. The user can also edit them directly.
|
|
768
786
|
- Load deferred MCP servers proactively when the conversation clearly needs them.
|
|
769
787
|
- Daily planning:
|
|
770
|
-
When the user asks what to focus on today, or the Today view is empty
|
|
788
|
+
When the user asks what to focus on today, or the Today view is empty: read goals first, survey active projects (in phase sequence), consider carry-forward items, ask about time constraints ("how much time do you have today?"), then propose 3-5 high-leverage tasks with reasons. Focus on what moves the needle most and what unblocks other work. Rough-estimate task duration in conversation to help scope. Iterate with the user, then pin the agreed tasks with `tf_focus`.
|
|
771
789
|
- Prioritization heuristics:
|
|
772
|
-
|
|
790
|
+
Unblockers beat isolated work, goal-aligned tasks beat dormant projects, sequential dependencies matter (what's blocking the next phase?), quick wins are useful early, and focus lists should stay short. Be ruthless about cutting — if it doesn't fit the time available, defer it.
|
|
791
|
+
- When structuring projects, organize sections as phases/sequence (what to do first, second, third), NOT by category (API, Frontend, Database). Sequencing tells you what's next; categories are just filing.
|
|
773
792
|
""".strip()
|
|
774
793
|
return _trim_for_token_budget(prompt, PROMPT_TOKEN_BUDGET)
|
|
775
794
|
|
|
@@ -1071,6 +1090,24 @@ TF_TOOL_DEFINITIONS = [
|
|
|
1071
1090
|
"description": "Load deferred MCP tools for a configured server from ~/.claude.json.",
|
|
1072
1091
|
"input_schema": _schema({"server_name": {"type": "string"}}, ["server_name"]),
|
|
1073
1092
|
},
|
|
1093
|
+
{
|
|
1094
|
+
"name": "run_agent",
|
|
1095
|
+
"description": "Spawn a read-only sub-agent to perform a focused task. The sub-agent gets its own context window and returns a structured response. Use this for intensive work that would bloat the main conversation: code audits, file exploration, note triage, research. The sub-agent cannot create, update, or delete tasks or projects; you handle mutations based on its findings.",
|
|
1096
|
+
"input_schema": _schema(
|
|
1097
|
+
{
|
|
1098
|
+
"task": {
|
|
1099
|
+
"type": "string",
|
|
1100
|
+
"description": "Detailed instructions for the sub-agent.",
|
|
1101
|
+
},
|
|
1102
|
+
"model": {
|
|
1103
|
+
"type": "string",
|
|
1104
|
+
"description": "Model override. Defaults to claude-sonnet-4-6.",
|
|
1105
|
+
"enum": ["claude-sonnet-4-6", "claude-opus-4-6"],
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
required=["task"],
|
|
1109
|
+
),
|
|
1110
|
+
},
|
|
1074
1111
|
# --- Apple Notes tools ---
|
|
1075
1112
|
{
|
|
1076
1113
|
"name": "notes_search",
|
|
@@ -1104,6 +1141,24 @@ TF_TOOL_DEFINITIONS = [
|
|
|
1104
1141
|
["content"],
|
|
1105
1142
|
),
|
|
1106
1143
|
},
|
|
1144
|
+
{
|
|
1145
|
+
"name": "tf_workflow_list",
|
|
1146
|
+
"description": "List available project workflow templates.",
|
|
1147
|
+
"input_schema": _schema({}),
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
"name": "tf_workflow_get",
|
|
1151
|
+
"description": "Read a workflow template by slug.",
|
|
1152
|
+
"input_schema": _schema({"slug": {"type": "string"}}, ["slug"]),
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
"name": "tf_workflow_save",
|
|
1156
|
+
"description": "Create or update a workflow template. Content is markdown with simple frontmatter (--- delimited, plain 'key: value' lines, no quoting or nesting). Required field: name.",
|
|
1157
|
+
"input_schema": _schema(
|
|
1158
|
+
{"slug": {"type": "string"}, "content": {"type": "string"}},
|
|
1159
|
+
["slug", "content"],
|
|
1160
|
+
),
|
|
1161
|
+
},
|
|
1107
1162
|
# --- Filesystem tools ---
|
|
1108
1163
|
{
|
|
1109
1164
|
"name": "read_file",
|
|
@@ -1183,6 +1238,30 @@ MUTATING_TOOL_NAMES = {
|
|
|
1183
1238
|
"tf_goal_reopen",
|
|
1184
1239
|
"tf_goal_remove",
|
|
1185
1240
|
}
|
|
1241
|
+
_SUB_AGENT_EXCLUDED_TOOLS: set[str] = {
|
|
1242
|
+
"run_agent",
|
|
1243
|
+
"run_shell",
|
|
1244
|
+
"load_tools",
|
|
1245
|
+
"tf_memory_read",
|
|
1246
|
+
"tf_memory_update",
|
|
1247
|
+
} | MUTATING_TOOL_NAMES
|
|
1248
|
+
|
|
1249
|
+
_SUB_AGENT_SYSTEM_PROMPT = (
|
|
1250
|
+
"You are a focused research assistant working on behalf of a project manager. "
|
|
1251
|
+
"You have read-only access to files, git repos, task data, and notes. "
|
|
1252
|
+
"You do NOT have shell access or the ability to modify tasks/projects.\n"
|
|
1253
|
+
"Complete the assigned task thoroughly and return a clear, structured response.\n\n"
|
|
1254
|
+
"Be concise — your output will be read by the orchestrator agent, not a human. "
|
|
1255
|
+
"If any tool call fails or returns unexpected data, note the issue clearly in "
|
|
1256
|
+
"your response rather than silently proceeding.\n\n"
|
|
1257
|
+
"Today's date: {date}"
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
_SUB_AGENT_MAX_TURNS = 15
|
|
1261
|
+
_SUB_AGENT_TIMEOUT = int(os.getenv("SUB_AGENT_TIMEOUT", "300"))
|
|
1262
|
+
_SUB_AGENT_CLIENT_TIMEOUT = 90
|
|
1263
|
+
_SUB_AGENT_MAX_TOKENS = 32_000
|
|
1264
|
+
_SUB_AGENT_DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
1186
1265
|
|
|
1187
1266
|
mcp_manager = McpClientManager(
|
|
1188
1267
|
allowed_servers=None,
|
|
@@ -1701,7 +1780,12 @@ async def load_tools_handler(tool_input, *, call_index=0):
|
|
|
1701
1780
|
|
|
1702
1781
|
async with mcp_manager._lock:
|
|
1703
1782
|
if server_name in mcp_manager._servers:
|
|
1704
|
-
return {
|
|
1783
|
+
return {
|
|
1784
|
+
"status": "ok",
|
|
1785
|
+
"server_name": server_name,
|
|
1786
|
+
"already_loaded": True,
|
|
1787
|
+
"_load_servers": [server_name],
|
|
1788
|
+
}, None
|
|
1705
1789
|
|
|
1706
1790
|
config = _claude_config()
|
|
1707
1791
|
mcp_servers = config.get("mcpServers")
|
|
@@ -1731,6 +1815,63 @@ async def load_tools_handler(tool_input, *, call_index=0):
|
|
|
1731
1815
|
}, None
|
|
1732
1816
|
|
|
1733
1817
|
|
|
1818
|
+
def make_run_agent_handler(
|
|
1819
|
+
runner_ref: list[Any],
|
|
1820
|
+
local_tool_handlers: dict[str, Any],
|
|
1821
|
+
mcp_manager: McpClientManager,
|
|
1822
|
+
):
|
|
1823
|
+
async def _handle_run_agent(
|
|
1824
|
+
tool_input: dict,
|
|
1825
|
+
*,
|
|
1826
|
+
call_index: int = 0,
|
|
1827
|
+
) -> tuple[Any | None, dict[str, Any] | None]:
|
|
1828
|
+
runner = runner_ref[0]
|
|
1829
|
+
if runner is None:
|
|
1830
|
+
return None, {"code": "internal_error", "message": "Runner not initialized"}
|
|
1831
|
+
|
|
1832
|
+
task = tool_input.get("task", "")
|
|
1833
|
+
if not task or not isinstance(task, str):
|
|
1834
|
+
return None, {"code": "invalid_input", "message": "task is required"}
|
|
1835
|
+
|
|
1836
|
+
raw_model = tool_input.get("model")
|
|
1837
|
+
allowed = {"claude-sonnet-4-6", "claude-opus-4-6"}
|
|
1838
|
+
if raw_model is not None and raw_model not in allowed:
|
|
1839
|
+
return None, {"code": "invalid_input", "message": f"Invalid model: {raw_model}"}
|
|
1840
|
+
|
|
1841
|
+
system_prompt = _SUB_AGENT_SYSTEM_PROMPT.format(
|
|
1842
|
+
date=dt_date.today().isoformat()
|
|
1843
|
+
)
|
|
1844
|
+
effective_model = raw_model or _SUB_AGENT_DEFAULT_MODEL
|
|
1845
|
+
|
|
1846
|
+
sub_local = {
|
|
1847
|
+
name: handler
|
|
1848
|
+
for name, handler in local_tool_handlers.items()
|
|
1849
|
+
if name not in _SUB_AGENT_EXCLUDED_TOOLS
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
sub_dispatcher = ToolDispatcher(
|
|
1853
|
+
mcp_client=mcp_manager,
|
|
1854
|
+
local_tool_handlers=sub_local,
|
|
1855
|
+
needs_approval=lambda _: False,
|
|
1856
|
+
)
|
|
1857
|
+
|
|
1858
|
+
result, error = await runner.spawn_sub_agent(
|
|
1859
|
+
task,
|
|
1860
|
+
model=effective_model,
|
|
1861
|
+
system_prompt=system_prompt,
|
|
1862
|
+
dispatcher=sub_dispatcher,
|
|
1863
|
+
excluded_tools=_SUB_AGENT_EXCLUDED_TOOLS,
|
|
1864
|
+
max_turns=_SUB_AGENT_MAX_TURNS,
|
|
1865
|
+
timeout=_SUB_AGENT_TIMEOUT,
|
|
1866
|
+
client_timeout=_SUB_AGENT_CLIENT_TIMEOUT,
|
|
1867
|
+
max_tokens=_SUB_AGENT_MAX_TOKENS,
|
|
1868
|
+
call_index=call_index,
|
|
1869
|
+
)
|
|
1870
|
+
return result, error
|
|
1871
|
+
|
|
1872
|
+
return _handle_run_agent
|
|
1873
|
+
|
|
1874
|
+
|
|
1734
1875
|
def _run_osascript(script: str, timeout: float = 15.0) -> str:
|
|
1735
1876
|
proc = subprocess.run(
|
|
1736
1877
|
["osascript", "-e", script],
|
|
@@ -1845,6 +1986,50 @@ async def tf_memory_update_handler(tool_input, *, call_index=0):
|
|
|
1845
1986
|
return {"status": "ok", "path": str(MEMORY_FILE_PATH), "chars": len(content)}, None
|
|
1846
1987
|
|
|
1847
1988
|
|
|
1989
|
+
def _workflow_value_error(exc: ValueError):
|
|
1990
|
+
message = str(exc)
|
|
1991
|
+
if message.startswith("Invalid slug:") or message == "Frontmatter must include 'name' field":
|
|
1992
|
+
return _tool_error("invalid_input", message)
|
|
1993
|
+
if "exceeds size limit" in message or message == "Content exceeds limit (16 KB / 200 lines)":
|
|
1994
|
+
return _tool_error("too_large", message)
|
|
1995
|
+
return _tool_error("invalid_input", message)
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
async def tf_workflow_list_handler(tool_input, *, call_index=0):
|
|
1999
|
+
del tool_input, call_index
|
|
2000
|
+
try:
|
|
2001
|
+
workflow_items = workflows.list_workflows()
|
|
2002
|
+
except OSError as exc:
|
|
2003
|
+
return _tool_error("file_error", f"Could not list workflows: {exc}")
|
|
2004
|
+
return {"workflows": workflow_items, "count": len(workflow_items)}, None
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
async def tf_workflow_get_handler(tool_input, *, call_index=0):
|
|
2008
|
+
del call_index
|
|
2009
|
+
slug = str(tool_input.get("slug", ""))
|
|
2010
|
+
try:
|
|
2011
|
+
workflow_item = workflows.get_workflow(slug)
|
|
2012
|
+
except ValueError as exc:
|
|
2013
|
+
return _workflow_value_error(exc)
|
|
2014
|
+
except OSError as exc:
|
|
2015
|
+
return _tool_error("file_error", f"Could not read workflow '{slug}': {exc}")
|
|
2016
|
+
if workflow_item is None:
|
|
2017
|
+
return _tool_error("not_found", f"Workflow '{slug}' not found")
|
|
2018
|
+
return workflow_item, None
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
async def tf_workflow_save_handler(tool_input, *, call_index=0):
|
|
2022
|
+
del call_index
|
|
2023
|
+
slug = str(tool_input.get("slug", ""))
|
|
2024
|
+
content = str(tool_input.get("content", ""))
|
|
2025
|
+
try:
|
|
2026
|
+
return workflows.save_workflow(slug, content), None
|
|
2027
|
+
except ValueError as exc:
|
|
2028
|
+
return _workflow_value_error(exc)
|
|
2029
|
+
except OSError as exc:
|
|
2030
|
+
return _tool_error("file_error", f"Could not write workflow '{slug}': {exc}")
|
|
2031
|
+
|
|
2032
|
+
|
|
1848
2033
|
_READ_FILE_MAX_LINES = 2000
|
|
1849
2034
|
_SHELL_TIMEOUT = 30
|
|
1850
2035
|
_SHELL_MAX_OUTPUT = 50_000 # ~50 KB
|
|
@@ -1996,6 +2181,9 @@ LOCAL_TOOL_HANDLERS = {
|
|
|
1996
2181
|
"notes_read": notes_read_handler,
|
|
1997
2182
|
"tf_memory_read": tf_memory_read_handler,
|
|
1998
2183
|
"tf_memory_update": tf_memory_update_handler,
|
|
2184
|
+
"tf_workflow_list": tf_workflow_list_handler,
|
|
2185
|
+
"tf_workflow_get": tf_workflow_get_handler,
|
|
2186
|
+
"tf_workflow_save": tf_workflow_save_handler,
|
|
1999
2187
|
"read_file": read_file_handler,
|
|
2000
2188
|
"list_dir": list_dir_handler,
|
|
2001
2189
|
"run_shell": run_shell_handler,
|
|
@@ -2375,7 +2563,7 @@ def create_task(body: TaskCreate):
|
|
|
2375
2563
|
def update_task(task_id: int, body: TaskUpdate):
|
|
2376
2564
|
conn = _conn()
|
|
2377
2565
|
fields = {}
|
|
2378
|
-
for key in ("name", "notes", "assignee", "start_date", "due_date", "section_id"):
|
|
2566
|
+
for key in ("name", "notes", "assignee", "start_date", "due_date", "section_id", "position"):
|
|
2379
2567
|
val = getattr(body, key)
|
|
2380
2568
|
if val is not None:
|
|
2381
2569
|
fields[key] = val
|
|
@@ -2389,6 +2577,23 @@ def update_task(task_id: int, body: TaskUpdate):
|
|
|
2389
2577
|
return {"status": "ok"}
|
|
2390
2578
|
|
|
2391
2579
|
|
|
2580
|
+
@app.post("/api/tasks/reorder")
|
|
2581
|
+
def reorder_tasks(body: dict[str, Any]):
|
|
2582
|
+
"""Batch update task positions. Body: { task_ids: [id1, id2, ...] }
|
|
2583
|
+
Sets position = index for each task in the array."""
|
|
2584
|
+
task_ids = body.get("task_ids", [])
|
|
2585
|
+
if not task_ids or not isinstance(task_ids, list):
|
|
2586
|
+
raise HTTPException(400, "task_ids must be a non-empty list")
|
|
2587
|
+
conn = _conn()
|
|
2588
|
+
try:
|
|
2589
|
+
for i, tid in enumerate(task_ids):
|
|
2590
|
+
db.update_task(conn, int(tid), position=i)
|
|
2591
|
+
finally:
|
|
2592
|
+
conn.close()
|
|
2593
|
+
invalidate_workspace_summary_cache()
|
|
2594
|
+
return {"status": "ok", "count": len(task_ids)}
|
|
2595
|
+
|
|
2596
|
+
|
|
2392
2597
|
@app.post("/api/tasks/{task_id}/complete")
|
|
2393
2598
|
def complete_task(task_id: int):
|
|
2394
2599
|
conn = _conn()
|
|
@@ -2726,18 +2931,36 @@ async def chat(body: ChatRequest, request: Request):
|
|
|
2726
2931
|
session_id = f"tf-{uuid4().hex[:8]}"
|
|
2727
2932
|
log.info("chat_start | %s | view=%s messages=%d", session_id, body.context.view, len(messages))
|
|
2728
2933
|
event_log = EventLog(session_id=session_id)
|
|
2934
|
+
request_handlers = dict(LOCAL_TOOL_HANDLERS)
|
|
2935
|
+
loaded_mcp_servers: set[str] = set()
|
|
2936
|
+
runner_ref: list[AgentRunner | None] = [None]
|
|
2937
|
+
request_handlers["run_agent"] = make_run_agent_handler(
|
|
2938
|
+
runner_ref,
|
|
2939
|
+
request_handlers,
|
|
2940
|
+
mcp_manager,
|
|
2941
|
+
)
|
|
2729
2942
|
dispatcher = ToolDispatcher(
|
|
2730
2943
|
mcp_client=mcp_manager,
|
|
2731
|
-
local_tool_handlers=
|
|
2944
|
+
local_tool_handlers=request_handlers,
|
|
2945
|
+
needs_approval=lambda _: False,
|
|
2732
2946
|
)
|
|
2947
|
+
|
|
2948
|
+
def get_tool_definitions():
|
|
2949
|
+
mcp_defs = mcp_manager.get_server_tool_definitions(loaded_mcp_servers)
|
|
2950
|
+
return TF_TOOL_DEFINITIONS + mcp_defs
|
|
2951
|
+
|
|
2733
2952
|
runner = AgentRunner(
|
|
2734
2953
|
dispatcher=dispatcher,
|
|
2735
2954
|
event_log=event_log,
|
|
2736
2955
|
session_id=event_log._session_id,
|
|
2737
2956
|
auth_config=_anthropic_auth_config(),
|
|
2738
2957
|
mcp_client=mcp_manager,
|
|
2739
|
-
|
|
2958
|
+
loaded_mcp_servers=loaded_mcp_servers,
|
|
2959
|
+
get_tool_definitions=get_tool_definitions,
|
|
2960
|
+
client_timeout=90.0,
|
|
2961
|
+
per_turn_timeout=120.0,
|
|
2740
2962
|
)
|
|
2963
|
+
runner_ref[0] = runner
|
|
2741
2964
|
runner_task = asyncio.create_task(
|
|
2742
2965
|
runner.run(
|
|
2743
2966
|
messages=messages,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Shared workflow template storage helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
WORKFLOWS_DIR = Path(__file__).resolve().parent.parent / "data" / "workflows"
|
|
10
|
+
MAX_BYTES = 16_384
|
|
11
|
+
MAX_LINES = 200
|
|
12
|
+
SLUG_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
13
|
+
MAX_SLUG_LEN = 60
|
|
14
|
+
|
|
15
|
+
_FRONTMATTER_LINE_RE = re.compile(r"^([a-zA-Z0-9_]+):\s*(.*)$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_slug(slug: str) -> str | None:
|
|
19
|
+
"""Return an error message when a workflow slug is invalid."""
|
|
20
|
+
if not isinstance(slug, str):
|
|
21
|
+
return "must be kebab-case, 1-60 chars"
|
|
22
|
+
if not slug or len(slug) > MAX_SLUG_LEN or not SLUG_RE.fullmatch(slug):
|
|
23
|
+
return "must be kebab-case, 1-60 chars"
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_frontmatter(content: str) -> dict[str, str]:
|
|
28
|
+
"""Extract recognized frontmatter fields from markdown content."""
|
|
29
|
+
metadata = {"name": "", "description": ""}
|
|
30
|
+
lines = content.splitlines()
|
|
31
|
+
if not lines or lines[0] != "---":
|
|
32
|
+
return metadata
|
|
33
|
+
|
|
34
|
+
closing_index = None
|
|
35
|
+
for index, line in enumerate(lines[1:], start=1):
|
|
36
|
+
if line == "---":
|
|
37
|
+
closing_index = index
|
|
38
|
+
break
|
|
39
|
+
if closing_index is None:
|
|
40
|
+
return metadata
|
|
41
|
+
|
|
42
|
+
for line in lines[1:closing_index]:
|
|
43
|
+
match = _FRONTMATTER_LINE_RE.match(line)
|
|
44
|
+
if not match:
|
|
45
|
+
continue
|
|
46
|
+
key, value = match.groups()
|
|
47
|
+
if key in metadata:
|
|
48
|
+
metadata[key] = value.strip()
|
|
49
|
+
return metadata
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_workflows() -> list[dict[str, str]]:
|
|
53
|
+
"""Return workflow metadata for all valid workflow files."""
|
|
54
|
+
if not WORKFLOWS_DIR.is_dir():
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
workflow_items = []
|
|
58
|
+
for path in sorted(WORKFLOWS_DIR.glob("*.md")):
|
|
59
|
+
try:
|
|
60
|
+
if path.stat().st_size > MAX_BYTES:
|
|
61
|
+
continue
|
|
62
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
slug = path.stem
|
|
66
|
+
if validate_slug(slug):
|
|
67
|
+
continue
|
|
68
|
+
if _line_count(content) > MAX_LINES:
|
|
69
|
+
continue
|
|
70
|
+
workflow_items.append(_build_workflow_metadata(slug, content))
|
|
71
|
+
return workflow_items
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_workflow(slug: str) -> dict[str, str] | None:
|
|
75
|
+
"""Return workflow metadata plus raw markdown content for a slug."""
|
|
76
|
+
error = validate_slug(slug)
|
|
77
|
+
if error:
|
|
78
|
+
raise ValueError(f"Invalid slug: {error}")
|
|
79
|
+
|
|
80
|
+
path = WORKFLOWS_DIR / f"{slug}.md"
|
|
81
|
+
try:
|
|
82
|
+
size = path.stat().st_size
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return None
|
|
85
|
+
if size > MAX_BYTES:
|
|
86
|
+
raise ValueError(f"Workflow '{slug}' exceeds size limit")
|
|
87
|
+
|
|
88
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
89
|
+
if _line_count(content) > MAX_LINES:
|
|
90
|
+
raise ValueError(f"Workflow '{slug}' exceeds size limit")
|
|
91
|
+
return _build_workflow_metadata(slug, content) | {"content": content}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_workflow(slug: str, content: str) -> dict[str, str | int]:
|
|
95
|
+
"""Create or replace a workflow markdown file atomically."""
|
|
96
|
+
error = validate_slug(slug)
|
|
97
|
+
if error:
|
|
98
|
+
raise ValueError(f"Invalid slug: {error}")
|
|
99
|
+
|
|
100
|
+
if len(content.encode("utf-8")) > MAX_BYTES or _line_count(content) > MAX_LINES:
|
|
101
|
+
raise ValueError("Content exceeds limit (16 KB / 200 lines)")
|
|
102
|
+
|
|
103
|
+
metadata = parse_frontmatter(content)
|
|
104
|
+
if not metadata.get("name"):
|
|
105
|
+
raise ValueError("Frontmatter must include 'name' field")
|
|
106
|
+
|
|
107
|
+
WORKFLOWS_DIR.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
path = WORKFLOWS_DIR / f"{slug}.md"
|
|
109
|
+
tmp_path: Path | None = None
|
|
110
|
+
try:
|
|
111
|
+
with tempfile.NamedTemporaryFile(
|
|
112
|
+
"w",
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
dir=WORKFLOWS_DIR,
|
|
115
|
+
prefix=f".{slug}-",
|
|
116
|
+
suffix=".tmp",
|
|
117
|
+
delete=False,
|
|
118
|
+
) as tmp_file:
|
|
119
|
+
tmp_file.write(content)
|
|
120
|
+
tmp_path = Path(tmp_file.name)
|
|
121
|
+
tmp_path.replace(path)
|
|
122
|
+
except OSError:
|
|
123
|
+
if tmp_path is not None:
|
|
124
|
+
tmp_path.unlink(missing_ok=True)
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
return {"status": "ok", "slug": slug, "path": str(path), "chars": len(content)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _line_count(content: str) -> int:
|
|
131
|
+
return len(content.splitlines())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _build_workflow_metadata(slug: str, content: str) -> dict[str, str]:
|
|
135
|
+
metadata = parse_frontmatter(content)
|
|
136
|
+
name = metadata.get("name") or slug.replace("-", " ").title()
|
|
137
|
+
description = metadata.get("description") or ""
|
|
138
|
+
return {"slug": slug, "name": name, "description": description}
|
|
@@ -613,6 +613,17 @@ button { cursor: pointer; }
|
|
|
613
613
|
}
|
|
614
614
|
.task-row:hover { background: var(--bg-surface); }
|
|
615
615
|
.task-row.selected { background: var(--bg-surface-2); }
|
|
616
|
+
.task-row[draggable="true"] { cursor: grab; }
|
|
617
|
+
.task-row.dragging { opacity: 0.4; }
|
|
618
|
+
.task-row.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
|
|
619
|
+
.t-drag {
|
|
620
|
+
flex-shrink: 0; width: 14px; margin-top: 2px;
|
|
621
|
+
color: var(--text-muted); font-size: 10px; cursor: grab;
|
|
622
|
+
opacity: 0; transition: opacity var(--transition);
|
|
623
|
+
user-select: none;
|
|
624
|
+
}
|
|
625
|
+
.task-row:hover .t-drag { opacity: 0.6; }
|
|
626
|
+
.t-drag:hover { opacity: 1 !important; }
|
|
616
627
|
|
|
617
628
|
.t-check {
|
|
618
629
|
width: 16px; height: 16px; border: 1.5px solid var(--border);
|
|
@@ -1154,6 +1165,13 @@ button { cursor: pointer; }
|
|
|
1154
1165
|
font-size: 11px;
|
|
1155
1166
|
color: var(--accent);
|
|
1156
1167
|
}
|
|
1168
|
+
.chat-tool-hint {
|
|
1169
|
+
color: var(--text-muted);
|
|
1170
|
+
font-family: var(--font-mono);
|
|
1171
|
+
font-size: 10px;
|
|
1172
|
+
margin-left: 4px;
|
|
1173
|
+
opacity: 0.7;
|
|
1174
|
+
}
|
|
1157
1175
|
.chat-tool-state {
|
|
1158
1176
|
font-size: 10px;
|
|
1159
1177
|
text-transform: uppercase;
|
|
@@ -1162,6 +1180,28 @@ button { cursor: pointer; }
|
|
|
1162
1180
|
}
|
|
1163
1181
|
.chat-tool-card.complete .chat-tool-state { color: var(--green); }
|
|
1164
1182
|
.chat-tool-card.error .chat-tool-state { color: var(--red); }
|
|
1183
|
+
.chat-tool-group {
|
|
1184
|
+
width: 100%;
|
|
1185
|
+
background: var(--bg-surface-2);
|
|
1186
|
+
border: 1px solid var(--border-subtle);
|
|
1187
|
+
border-radius: 12px;
|
|
1188
|
+
overflow: hidden;
|
|
1189
|
+
}
|
|
1190
|
+
.chat-tool-group summary {
|
|
1191
|
+
list-style: none;
|
|
1192
|
+
display: flex;
|
|
1193
|
+
align-items: center;
|
|
1194
|
+
justify-content: space-between;
|
|
1195
|
+
gap: 8px;
|
|
1196
|
+
padding: 9px 12px;
|
|
1197
|
+
cursor: pointer;
|
|
1198
|
+
font-size: 12px;
|
|
1199
|
+
color: var(--text-secondary);
|
|
1200
|
+
}
|
|
1201
|
+
.chat-tool-group summary::-webkit-details-marker { display: none; }
|
|
1202
|
+
.chat-tool-group .chat-tool-state.error { color: var(--red); }
|
|
1203
|
+
.chat-tool-group-body { padding: 4px 8px 8px; }
|
|
1204
|
+
.chat-tool-group-body .tool { margin-bottom: 4px; }
|
|
1165
1205
|
.chat-tool-body {
|
|
1166
1206
|
padding: 0 12px 12px;
|
|
1167
1207
|
border-top: 1px solid var(--border-subtle);
|
|
@@ -1585,17 +1625,21 @@ function renderSidebar() {
|
|
|
1585
1625
|
}
|
|
1586
1626
|
html += '</div>'
|
|
1587
1627
|
} else {
|
|
1628
|
+
const collapsiblePhases = new Set(['done', 'reference'])
|
|
1588
1629
|
let first = true
|
|
1589
1630
|
for (const ph of phaseOrder) {
|
|
1590
1631
|
const projs = active.filter(p => p.phase === ph)
|
|
1591
1632
|
if (!projs.length) continue
|
|
1633
|
+
const collapsible = collapsiblePhases.has(ph)
|
|
1634
|
+
const collapseAttr = collapsible ? ' style="cursor:pointer;user-select:none" onclick="this.nextElementSibling.classList.toggle(\'collapsed\')"' : ''
|
|
1635
|
+
const countHint = collapsible ? ` <span style="opacity:.5">${projs.length}</span>` : ''
|
|
1592
1636
|
if (first) {
|
|
1593
|
-
html += `<div class="sb-group-label">${phaseLabels[ph] || ph} <span class="sb-group-toggle" onclick="event.stopPropagation();setSidebarSort('recent')" title="Sort by recent">↕</span></div>`
|
|
1637
|
+
html += `<div class="sb-group-label"${collapseAttr}>${phaseLabels[ph] || ph}${countHint} <span class="sb-group-toggle" onclick="event.stopPropagation();setSidebarSort('recent')" title="Sort by recent">↕</span></div>`
|
|
1594
1638
|
first = false
|
|
1595
1639
|
} else {
|
|
1596
|
-
html += `<div class="sb-group-label">${phaseLabels[ph] || ph}</div>`
|
|
1640
|
+
html += `<div class="sb-group-label"${collapseAttr}>${phaseLabels[ph] || ph}${countHint}</div>`
|
|
1597
1641
|
}
|
|
1598
|
-
html +=
|
|
1642
|
+
html += `<div class="sb-projects${collapsible ? ' collapsed' : ''}">`
|
|
1599
1643
|
for (const p of projs) {
|
|
1600
1644
|
html += `<div class="sb-proj ${p.id === S.projectId ? 'active' : ''}" onclick="goProject(${p.id})">
|
|
1601
1645
|
<span class="phase-dot ph-${p.phase}"></span>
|
|
@@ -1943,6 +1987,62 @@ function renderProject() {
|
|
|
1943
1987
|
}
|
|
1944
1988
|
|
|
1945
1989
|
c.innerHTML = html
|
|
1990
|
+
initDragReorder(c)
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/* ---- Drag-to-reorder tasks ---- */
|
|
1994
|
+
let _dragId = null
|
|
1995
|
+
function initDragReorder(container) {
|
|
1996
|
+
container.querySelectorAll('.task-row[draggable="true"]').forEach(row => {
|
|
1997
|
+
row.addEventListener('dragstart', e => {
|
|
1998
|
+
_dragId = +row.dataset.id
|
|
1999
|
+
row.classList.add('dragging')
|
|
2000
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
2001
|
+
e.dataTransfer.setData('text/plain', row.dataset.id)
|
|
2002
|
+
})
|
|
2003
|
+
row.addEventListener('dragend', () => {
|
|
2004
|
+
_dragId = null
|
|
2005
|
+
row.classList.remove('dragging')
|
|
2006
|
+
container.querySelectorAll('.drag-over').forEach(r => r.classList.remove('drag-over'))
|
|
2007
|
+
})
|
|
2008
|
+
row.addEventListener('dragover', e => {
|
|
2009
|
+
e.preventDefault()
|
|
2010
|
+
e.dataTransfer.dropEffect = 'move'
|
|
2011
|
+
if (+row.dataset.id === _dragId) return
|
|
2012
|
+
container.querySelectorAll('.drag-over').forEach(r => r.classList.remove('drag-over'))
|
|
2013
|
+
row.classList.add('drag-over')
|
|
2014
|
+
})
|
|
2015
|
+
row.addEventListener('dragleave', () => {
|
|
2016
|
+
row.classList.remove('drag-over')
|
|
2017
|
+
})
|
|
2018
|
+
row.addEventListener('drop', async e => {
|
|
2019
|
+
e.preventDefault()
|
|
2020
|
+
row.classList.remove('drag-over')
|
|
2021
|
+
const targetId = +row.dataset.id
|
|
2022
|
+
if (!_dragId || _dragId === targetId) return
|
|
2023
|
+
// Find the spec-body (container) for this row
|
|
2024
|
+
const body = row.closest('.spec-body')
|
|
2025
|
+
if (!body) return
|
|
2026
|
+
const rows = [...body.querySelectorAll('.task-row[draggable="true"]')]
|
|
2027
|
+
const ids = rows.map(r => +r.dataset.id)
|
|
2028
|
+
const fromIdx = ids.indexOf(_dragId)
|
|
2029
|
+
const toIdx = ids.indexOf(targetId)
|
|
2030
|
+
if (fromIdx < 0 || toIdx < 0) return
|
|
2031
|
+
// Reorder: remove from old position, insert before target
|
|
2032
|
+
ids.splice(fromIdx, 1)
|
|
2033
|
+
ids.splice(toIdx > fromIdx ? toIdx - 1 : toIdx, 0, _dragId)
|
|
2034
|
+
// Optimistic DOM reorder
|
|
2035
|
+
const draggedRow = body.querySelector(`.task-row[data-id="${_dragId}"]`)
|
|
2036
|
+
if (draggedRow) body.insertBefore(draggedRow, row)
|
|
2037
|
+
// Persist
|
|
2038
|
+
try {
|
|
2039
|
+
await api('/api/tasks/reorder', { method: 'POST', body: JSON.stringify({ task_ids: ids }) })
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
toast('Reorder failed')
|
|
2042
|
+
await refreshProject()
|
|
2043
|
+
}
|
|
2044
|
+
})
|
|
2045
|
+
})
|
|
1946
2046
|
}
|
|
1947
2047
|
|
|
1948
2048
|
function filteredTasks() {
|
|
@@ -1959,8 +2059,10 @@ function setFilter(f) {
|
|
|
1959
2059
|
function taskRow(t) {
|
|
1960
2060
|
const done = t.status === 'completed'
|
|
1961
2061
|
const dc = dueClass(t.due_date, t.status)
|
|
2062
|
+
const draggable = S.view === 'project' && !done
|
|
1962
2063
|
return `<div class="task-row ${done ? 'completed' : ''} ${t.id === S.selTaskId ? 'selected' : ''}"
|
|
1963
|
-
data-id="${t.id}" onclick="selTask(${t.id})">
|
|
2064
|
+
data-id="${t.id}" ${draggable ? 'draggable="true"' : ''} onclick="selTask(${t.id})">
|
|
2065
|
+
${draggable ? '<div class="t-drag" onmousedown="event.stopPropagation()">⠿</div>' : ''}
|
|
1964
2066
|
<div class="t-check ${done ? 'done' : ''}"
|
|
1965
2067
|
onclick="event.stopPropagation();toggleTask(${t.id},'${t.status}')"></div>
|
|
1966
2068
|
<div class="t-info">
|
|
@@ -2593,6 +2695,14 @@ function updateStreamingAssistant(text) {
|
|
|
2593
2695
|
scrollChatToBottom()
|
|
2594
2696
|
}
|
|
2595
2697
|
|
|
2698
|
+
function _toolHint(name, input) {
|
|
2699
|
+
if (!input || typeof input !== 'object') return ''
|
|
2700
|
+
const v = input.project_id || input.task_id || input.query || input.name || input.text || input.repo || input.path || input.command || input.server_name || ''
|
|
2701
|
+
if (!v) return ''
|
|
2702
|
+
const s = String(v)
|
|
2703
|
+
return s.length > 40 ? s.slice(0, 40) + '…' : s
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2596
2706
|
function appendToolCard(toolCallId, toolName, toolInput) {
|
|
2597
2707
|
clearChatEmptyState()
|
|
2598
2708
|
const row = document.createElement('div')
|
|
@@ -2600,9 +2710,11 @@ function appendToolCard(toolCallId, toolName, toolInput) {
|
|
|
2600
2710
|
const details = document.createElement('details')
|
|
2601
2711
|
details.className = 'chat-tool-card'
|
|
2602
2712
|
details.open = true
|
|
2713
|
+
const hint = _toolHint(toolName, toolInput)
|
|
2714
|
+
const hintHtml = hint ? ` <span class="chat-tool-hint">${esc(hint)}</span>` : ''
|
|
2603
2715
|
details.innerHTML = `
|
|
2604
2716
|
<summary>
|
|
2605
|
-
<span class="chat-tool-name">${esc(toolName)}</span>
|
|
2717
|
+
<span class="chat-tool-name">${esc(toolName)}${hintHtml}</span>
|
|
2606
2718
|
<span class="chat-tool-state">running</span>
|
|
2607
2719
|
</summary>
|
|
2608
2720
|
<div class="chat-tool-body">
|
|
@@ -2967,6 +3079,35 @@ function finishChatStream(assistantText) {
|
|
|
2967
3079
|
S.chat.currentAssistantEl = null
|
|
2968
3080
|
}
|
|
2969
3081
|
|
|
3082
|
+
// Collapse individual tool cards into a single summary row above the assistant message
|
|
3083
|
+
const chatMsgs = document.getElementById('chat-messages')
|
|
3084
|
+
const toolRows = Array.from(chatMsgs.querySelectorAll('.chat-row.tool'))
|
|
3085
|
+
if (toolRows.length > 0) {
|
|
3086
|
+
const uniqueTools = [...new Set(S.chat.turnTools.map(t => t.name.replace(/^tf_/, '')))]
|
|
3087
|
+
const label = uniqueTools.length <= 3 ? uniqueTools.join(', ') : `${toolRows.length} tools`
|
|
3088
|
+
const groupRow = document.createElement('div')
|
|
3089
|
+
groupRow.className = 'chat-row tool'
|
|
3090
|
+
const groupDetails = document.createElement('details')
|
|
3091
|
+
groupDetails.className = 'chat-tool-group'
|
|
3092
|
+
const hasError = S.chat.turnTools.some(t => t.error)
|
|
3093
|
+
groupDetails.innerHTML = `<summary><span class="chat-tool-name">${esc(label)}</span> <span class="chat-tool-state ${hasError ? 'error' : ''}">${hasError ? 'errors' : 'done'}</span></summary>`
|
|
3094
|
+
const groupBody = document.createElement('div')
|
|
3095
|
+
groupBody.className = 'chat-tool-group-body'
|
|
3096
|
+
toolRows.forEach(tr => {
|
|
3097
|
+
tr.classList.remove('chat-row')
|
|
3098
|
+
groupBody.appendChild(tr)
|
|
3099
|
+
})
|
|
3100
|
+
groupDetails.appendChild(groupBody)
|
|
3101
|
+
groupRow.appendChild(groupDetails)
|
|
3102
|
+
// Insert before the assistant bubble
|
|
3103
|
+
const assistantRow = S.chat.currentAssistantEl ? S.chat.currentAssistantEl.closest('.chat-row') : null
|
|
3104
|
+
if (assistantRow) {
|
|
3105
|
+
chatMsgs.insertBefore(groupRow, assistantRow)
|
|
3106
|
+
} else {
|
|
3107
|
+
chatMsgs.appendChild(groupRow)
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
|
|
2970
3111
|
S.chat.messages.push({ role: 'assistant', content: fullContent })
|
|
2971
3112
|
saveChatMessages()
|
|
2972
3113
|
resetChatTurnState()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|