@hasna/todos 0.9.5 → 0.9.7
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.
- package/README.md +250 -44
- package/dist/cli/index.js +187 -104
- package/dist/index.js +171 -96
- package/dist/mcp/index.js +177 -101
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
# @hasna/todos
|
|
2
2
|
|
|
3
|
-
Universal task management for AI coding agents. CLI + MCP server +
|
|
3
|
+
Universal task management for AI coding agents. CLI + MCP server + library, all sharing a single SQLite database.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **CLI** with interactive TUI (React/Ink) and JSON output
|
|
8
|
-
- **MCP server** for Claude, Codex, Gemini, and any MCP-compatible agent
|
|
7
|
+
- **CLI** with interactive TUI (React/Ink) and JSON output mode
|
|
8
|
+
- **MCP server** (29 tools) for Claude, Codex, Gemini, and any MCP-compatible agent
|
|
9
9
|
- **Library** for programmatic access from Node.js/Bun
|
|
10
|
+
- **Agent registration** with short UUID identity system
|
|
11
|
+
- **Task lists** for organizing tasks into named containers (backlog, sprint-1, bugs)
|
|
12
|
+
- **Task prefixes** with auto-incrementing short IDs per project (e.g., `APP-00001`)
|
|
13
|
+
- **Plans** as execution groups, separate from task list containers
|
|
10
14
|
- **SQLite** with WAL mode, optimistic locking, and automatic migrations
|
|
11
15
|
- Task dependencies with cycle detection
|
|
12
|
-
- Exclusive agent locking with auto-expiry
|
|
16
|
+
- Exclusive agent locking with 30-minute auto-expiry
|
|
13
17
|
- Full-text search across tasks
|
|
14
18
|
- Project auto-detection from git repositories
|
|
15
19
|
- Subtask hierarchies with cascade deletion
|
|
20
|
+
- Bidirectional sync with Claude Code, Codex, Gemini task lists
|
|
16
21
|
|
|
17
22
|
## Installation
|
|
18
23
|
|
|
@@ -20,31 +25,126 @@ Universal task management for AI coding agents. CLI + MCP server + web dashboard
|
|
|
20
25
|
bun add -g @hasna/todos
|
|
21
26
|
```
|
|
22
27
|
|
|
28
|
+
Or with npm:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @hasna/todos
|
|
32
|
+
```
|
|
33
|
+
|
|
23
34
|
## Quick Start
|
|
24
35
|
|
|
25
36
|
```bash
|
|
37
|
+
# Register your agent (get a short UUID for identity)
|
|
38
|
+
todos init my-agent
|
|
39
|
+
|
|
26
40
|
# Create a task
|
|
27
41
|
todos add "Fix login bug" --priority high --tags bug,auth
|
|
28
42
|
|
|
29
|
-
# List tasks
|
|
43
|
+
# List active tasks
|
|
30
44
|
todos list
|
|
31
45
|
|
|
32
|
-
# Start working on a task
|
|
46
|
+
# Start working on a task (claims + locks it)
|
|
33
47
|
todos start <id>
|
|
34
48
|
|
|
35
49
|
# Mark complete
|
|
36
50
|
todos done <id>
|
|
37
51
|
|
|
38
|
-
#
|
|
39
|
-
todos
|
|
52
|
+
# Create a task list
|
|
53
|
+
todos lists --add "sprint-1"
|
|
54
|
+
|
|
55
|
+
# Add task to a list
|
|
56
|
+
todos add "Build search" --list <list-id>
|
|
40
57
|
|
|
41
58
|
# Register MCP server with AI agents
|
|
42
59
|
todos mcp --register all
|
|
60
|
+
|
|
61
|
+
# Launch interactive TUI
|
|
62
|
+
todos
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Agent Registration
|
|
66
|
+
|
|
67
|
+
Agents register once to get a persistent 8-character UUID. This ID is used to track task ownership, locking, and activity.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Register (idempotent — same name returns same ID)
|
|
71
|
+
todos init claude
|
|
72
|
+
# Agent registered:
|
|
73
|
+
# ID: 56783129
|
|
74
|
+
# Name: claude
|
|
75
|
+
|
|
76
|
+
# Use the ID on future commands
|
|
77
|
+
todos add "Fix bug" --agent 56783129
|
|
78
|
+
|
|
79
|
+
# List all registered agents
|
|
80
|
+
todos agents
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Registration is idempotent: calling `init` with the same name returns the existing agent and updates `last_seen_at`.
|
|
84
|
+
|
|
85
|
+
## Task Lists
|
|
86
|
+
|
|
87
|
+
Task lists are named containers for organizing tasks (like folders). They're separate from plans.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Create a task list
|
|
91
|
+
todos lists --add "backlog"
|
|
92
|
+
todos lists --add "sprint-1" --slug sprint-1 -d "Current sprint"
|
|
93
|
+
|
|
94
|
+
# List all task lists
|
|
95
|
+
todos lists
|
|
96
|
+
|
|
97
|
+
# Add tasks to a list
|
|
98
|
+
todos add "Build feature" --list <list-id>
|
|
99
|
+
|
|
100
|
+
# Filter tasks by list
|
|
101
|
+
todos list --list <list-id>
|
|
102
|
+
|
|
103
|
+
# Move a task to a different list
|
|
104
|
+
todos update <task-id> --list <list-id>
|
|
105
|
+
|
|
106
|
+
# Delete a list (tasks keep their data, just lose the list association)
|
|
107
|
+
todos lists --delete <list-id>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Task lists can be project-scoped or standalone. Slugs must be unique within a project.
|
|
111
|
+
|
|
112
|
+
## Task Prefixes & Short IDs
|
|
113
|
+
|
|
114
|
+
Every project gets an auto-generated prefix (e.g., "APP" from "My App"). When tasks are created under a project, they get a short ID prepended to the title:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Project "My App" has prefix "MYA"
|
|
118
|
+
todos add "Fix login bug"
|
|
119
|
+
# Creates: "MYA-00001: Fix login bug"
|
|
120
|
+
|
|
121
|
+
todos add "Add dark mode"
|
|
122
|
+
# Creates: "MYA-00002: Add dark mode"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Custom prefixes can be set when creating a project. Counters auto-increment per project.
|
|
126
|
+
|
|
127
|
+
## Plans
|
|
128
|
+
|
|
129
|
+
Plans are execution groups for organizing work. A task can belong to both a task list AND a plan.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Create a plan
|
|
133
|
+
todos plans --add "v2.0 Release"
|
|
134
|
+
|
|
135
|
+
# Show plan details
|
|
136
|
+
todos plans --show <plan-id>
|
|
137
|
+
|
|
138
|
+
# Complete a plan
|
|
139
|
+
todos plans --complete <plan-id>
|
|
140
|
+
|
|
141
|
+
# Assign tasks to a plan
|
|
142
|
+
todos add "Build API" --plan <plan-id>
|
|
43
143
|
```
|
|
44
144
|
|
|
45
145
|
## MCP Server
|
|
46
146
|
|
|
47
|
-
Register with
|
|
147
|
+
Register the MCP server with AI coding agents:
|
|
48
148
|
|
|
49
149
|
```bash
|
|
50
150
|
todos mcp --register claude # Claude Code
|
|
@@ -59,23 +159,60 @@ Or start manually via stdio:
|
|
|
59
159
|
todos-mcp
|
|
60
160
|
```
|
|
61
161
|
|
|
62
|
-
|
|
162
|
+
### MCP Tools (29)
|
|
163
|
+
|
|
164
|
+
| Category | Tools |
|
|
165
|
+
|----------|-------|
|
|
166
|
+
| **Tasks** | `create_task`, `list_tasks`, `get_task`, `update_task`, `delete_task`, `start_task`, `complete_task` |
|
|
167
|
+
| **Locking** | `lock_task`, `unlock_task` |
|
|
168
|
+
| **Dependencies** | `add_dependency`, `remove_dependency` |
|
|
169
|
+
| **Comments** | `add_comment` |
|
|
170
|
+
| **Projects** | `create_project`, `list_projects` |
|
|
171
|
+
| **Plans** | `create_plan`, `list_plans`, `get_plan`, `update_plan`, `delete_plan` |
|
|
172
|
+
| **Agents** | `register_agent`, `list_agents`, `get_agent` |
|
|
173
|
+
| **Task Lists** | `create_task_list`, `list_task_lists`, `get_task_list`, `update_task_list`, `delete_task_list` |
|
|
174
|
+
| **Search** | `search_tasks` |
|
|
175
|
+
| **Sync** | `sync` |
|
|
176
|
+
|
|
177
|
+
### MCP Resources
|
|
178
|
+
|
|
179
|
+
| URI | Description |
|
|
180
|
+
|-----|-------------|
|
|
181
|
+
| `todos://tasks` | All active tasks (pending + in_progress) |
|
|
182
|
+
| `todos://projects` | All registered projects |
|
|
183
|
+
| `todos://agents` | All registered agents |
|
|
184
|
+
| `todos://task-lists` | All task lists |
|
|
185
|
+
|
|
186
|
+
## Sync
|
|
63
187
|
|
|
64
|
-
|
|
188
|
+
Bidirectional sync with agent-specific task lists.
|
|
65
189
|
|
|
66
190
|
```bash
|
|
67
191
|
todos sync --agent claude --task-list <id>
|
|
68
192
|
todos sync --agent codex --task-list default
|
|
69
193
|
todos sync --all --task-list <id>
|
|
70
|
-
todos sync --prefer local
|
|
194
|
+
todos sync --prefer local # Resolve conflicts favoring local
|
|
195
|
+
todos sync --push # One-way: local → agent
|
|
196
|
+
todos sync --pull # One-way: agent → local
|
|
71
197
|
```
|
|
72
198
|
|
|
73
|
-
|
|
74
|
-
- `TODOS_SYNC_AGENTS` (comma-separated list for `--all`)
|
|
75
|
-
- `TODOS_TASK_LIST_ID` or `TODOS_<AGENT>_TASK_LIST`
|
|
76
|
-
- `TODOS_AGENT_TASKS_DIR` or `TODOS_<AGENT>_TASKS_DIR`
|
|
199
|
+
Claude uses native Claude Code task lists. Other agents use JSON files under `~/.todos/agents/<agent>/<task_list_id>/`.
|
|
77
200
|
|
|
78
|
-
|
|
201
|
+
### Environment Variables
|
|
202
|
+
|
|
203
|
+
| Variable | Description |
|
|
204
|
+
|----------|-------------|
|
|
205
|
+
| `TODOS_DB_PATH` | Database file path (`:memory:` for testing) |
|
|
206
|
+
| `TODOS_DB_SCOPE` | Set to `project` to force project-level DB |
|
|
207
|
+
| `TODOS_AUTO_PROJECT` | Set to `false` to disable auto-project detection |
|
|
208
|
+
| `TODOS_SYNC_AGENTS` | Comma-separated agent list for `--all` |
|
|
209
|
+
| `TODOS_TASK_LIST_ID` | Default task list ID for sync |
|
|
210
|
+
| `TODOS_<AGENT>_TASK_LIST` | Agent-specific task list ID |
|
|
211
|
+
| `TODOS_AGENT_TASKS_DIR` | Base directory for agent task files |
|
|
212
|
+
|
|
213
|
+
### Config File
|
|
214
|
+
|
|
215
|
+
`~/.todos/config.json`:
|
|
79
216
|
|
|
80
217
|
```json
|
|
81
218
|
{
|
|
@@ -84,51 +221,119 @@ Config file: `~/.todos/config.json`
|
|
|
84
221
|
"agent_tasks_dir": "/Users/you/.todos/agents",
|
|
85
222
|
"agents": {
|
|
86
223
|
"claude": { "task_list_id": "session-or-project-id" },
|
|
87
|
-
"codex": { "task_list_id": "default"
|
|
224
|
+
"codex": { "task_list_id": "default" }
|
|
88
225
|
}
|
|
89
226
|
}
|
|
90
227
|
```
|
|
91
228
|
|
|
92
229
|
## CLI Commands
|
|
93
230
|
|
|
231
|
+
### Task Operations
|
|
232
|
+
|
|
233
|
+
| Command | Description |
|
|
234
|
+
|---------|-------------|
|
|
235
|
+
| `todos add <title>` | Create a task (`-p` priority, `--tags`, `--list`, `--plan`, `--assign`, `--parent`) |
|
|
236
|
+
| `todos list` | List tasks (`-s` status, `-p` priority, `--list`, `--tags`, `-a` all) |
|
|
237
|
+
| `todos show <id>` | Show full task details with relations |
|
|
238
|
+
| `todos update <id>` | Update fields (`--title`, `-s`, `-p`, `--tags`, `--list`, `--assign`) |
|
|
239
|
+
| `todos start <id>` | Claim task, lock it, set to in_progress |
|
|
240
|
+
| `todos done <id>` | Mark task completed, release lock |
|
|
241
|
+
| `todos delete <id>` | Delete permanently |
|
|
242
|
+
| `todos lock <id>` | Acquire exclusive lock |
|
|
243
|
+
| `todos unlock <id>` | Release lock |
|
|
244
|
+
|
|
245
|
+
### Organization
|
|
246
|
+
|
|
247
|
+
| Command | Description |
|
|
248
|
+
|---------|-------------|
|
|
249
|
+
| `todos lists` | List task lists (`--add`, `--delete`, `--slug`, `-d`) |
|
|
250
|
+
| `todos plans` | List plans (`--add`, `--show`, `--delete`, `--complete`) |
|
|
251
|
+
| `todos projects` | List projects (`--add`, `--name`, `--task-list-id`) |
|
|
252
|
+
| `todos deps <id>` | Manage dependencies (`--needs`, `--remove`) |
|
|
253
|
+
| `todos comment <id> <text>` | Add a comment to a task |
|
|
254
|
+
| `todos search <query>` | Full-text search across tasks |
|
|
255
|
+
|
|
256
|
+
### Agent & System
|
|
257
|
+
|
|
94
258
|
| Command | Description |
|
|
95
259
|
|---------|-------------|
|
|
96
|
-
| `todos
|
|
97
|
-
| `todos
|
|
98
|
-
| `todos
|
|
99
|
-
| `todos
|
|
100
|
-
| `todos
|
|
101
|
-
| `todos
|
|
102
|
-
| `todos
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
| `todos mcp` | Start MCP server |
|
|
110
|
-
|
|
111
|
-
Use `--json` for JSON output on any command. Use `--agent <name>` to identify the calling agent.
|
|
260
|
+
| `todos init <name>` | Register agent, get short UUID (`-d` description) |
|
|
261
|
+
| `todos agents` | List registered agents |
|
|
262
|
+
| `todos sync` | Sync with agent task lists |
|
|
263
|
+
| `todos mcp` | MCP server (`--register`, `--unregister`) |
|
|
264
|
+
| `todos hooks install` | Install Claude Code auto-sync hooks |
|
|
265
|
+
| `todos export` | Export tasks (`-f json\|md`) |
|
|
266
|
+
| `todos upgrade` | Self-update to latest version |
|
|
267
|
+
|
|
268
|
+
### Global Options
|
|
269
|
+
|
|
270
|
+
All commands support: `--project <path>`, `--json`, `--agent <name>`, `--session <id>`.
|
|
271
|
+
|
|
272
|
+
Partial IDs work everywhere — use the first 8+ characters of any UUID.
|
|
112
273
|
|
|
113
274
|
## Library Usage
|
|
114
275
|
|
|
115
276
|
```typescript
|
|
116
|
-
import {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
277
|
+
import {
|
|
278
|
+
createTask,
|
|
279
|
+
listTasks,
|
|
280
|
+
completeTask,
|
|
281
|
+
registerAgent,
|
|
282
|
+
createTaskList,
|
|
283
|
+
createProject,
|
|
284
|
+
searchTasks,
|
|
285
|
+
} from "@hasna/todos";
|
|
286
|
+
|
|
287
|
+
// Register an agent
|
|
288
|
+
const agent = registerAgent({ name: "my-bot" });
|
|
289
|
+
|
|
290
|
+
// Create a project
|
|
291
|
+
const project = createProject({ name: "My App", path: "/app" });
|
|
292
|
+
|
|
293
|
+
// Create a task list
|
|
294
|
+
const backlog = createTaskList({ name: "Backlog", project_id: project.id });
|
|
295
|
+
|
|
296
|
+
// Create a task (gets short_id like "MYA-00001" auto-prepended)
|
|
297
|
+
const task = createTask({
|
|
298
|
+
title: "Fix login bug",
|
|
299
|
+
priority: "high",
|
|
300
|
+
project_id: project.id,
|
|
301
|
+
task_list_id: backlog.id,
|
|
302
|
+
agent_id: agent.id,
|
|
303
|
+
tags: ["bug", "auth"],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// List and filter
|
|
307
|
+
const pending = listTasks({ status: "pending", task_list_id: backlog.id });
|
|
308
|
+
|
|
309
|
+
// Search
|
|
310
|
+
const results = searchTasks("login", project.id);
|
|
311
|
+
|
|
312
|
+
// Complete
|
|
120
313
|
completeTask(task.id);
|
|
121
314
|
```
|
|
122
315
|
|
|
123
316
|
## Database
|
|
124
317
|
|
|
125
|
-
SQLite
|
|
318
|
+
SQLite with automatic location detection:
|
|
126
319
|
|
|
127
320
|
1. `TODOS_DB_PATH` environment variable (`:memory:` for testing)
|
|
128
321
|
2. Nearest `.todos/todos.db` in current directory or any parent
|
|
129
322
|
3. `~/.todos/todos.db` global fallback
|
|
130
323
|
|
|
131
|
-
|
|
324
|
+
**Schema** (6 migrations, auto-applied):
|
|
325
|
+
|
|
326
|
+
| Table | Purpose |
|
|
327
|
+
|-------|---------|
|
|
328
|
+
| `projects` | Project registry with task prefix and counter |
|
|
329
|
+
| `tasks` | Main task table with short_id, versioning, locking |
|
|
330
|
+
| `task_lists` | Named containers for tasks |
|
|
331
|
+
| `agents` | Registered agent identities |
|
|
332
|
+
| `plans` | Execution groups |
|
|
333
|
+
| `task_dependencies` | DAG edges between tasks |
|
|
334
|
+
| `task_comments` | Notes on tasks |
|
|
335
|
+
| `task_tags` | Tag index for filtering |
|
|
336
|
+
| `sessions` | Agent session tracking |
|
|
132
337
|
|
|
133
338
|
## Development
|
|
134
339
|
|
|
@@ -136,9 +341,10 @@ Set `TODOS_DB_SCOPE=project` to force project-level DB at the git root (if found
|
|
|
136
341
|
git clone https://github.com/hasna/todos.git
|
|
137
342
|
cd todos
|
|
138
343
|
bun install
|
|
139
|
-
bun test # Run
|
|
344
|
+
bun test # Run 172 tests
|
|
140
345
|
bun run typecheck # TypeScript checking
|
|
141
346
|
bun run dev:cli # Run CLI in dev mode
|
|
347
|
+
bun run dev:mcp # Run MCP server in dev mode
|
|
142
348
|
```
|
|
143
349
|
|
|
144
350
|
## Architecture
|
|
@@ -146,10 +352,10 @@ bun run dev:cli # Run CLI in dev mode
|
|
|
146
352
|
```
|
|
147
353
|
src/
|
|
148
354
|
types/ TypeScript types, enums, custom errors
|
|
149
|
-
db/ SQLite data layer (tasks, projects, comments, sessions)
|
|
150
|
-
lib/ Business logic (search, sync)
|
|
355
|
+
db/ SQLite data layer (tasks, projects, agents, task-lists, plans, comments, sessions)
|
|
356
|
+
lib/ Business logic (search, sync, config)
|
|
151
357
|
cli/ Commander.js CLI + React/Ink TUI
|
|
152
|
-
mcp/ MCP server (stdio transport)
|
|
358
|
+
mcp/ MCP server (stdio transport, 29 tools)
|
|
153
359
|
index.ts Library re-exports
|
|
154
360
|
```
|
|
155
361
|
|
package/dist/cli/index.js
CHANGED
|
@@ -2379,7 +2379,7 @@ var init_database = __esm(() => {
|
|
|
2379
2379
|
});
|
|
2380
2380
|
|
|
2381
2381
|
// src/types/index.ts
|
|
2382
|
-
var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, TaskListNotFoundError, DependencyCycleError;
|
|
2382
|
+
var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError;
|
|
2383
2383
|
var init_types = __esm(() => {
|
|
2384
2384
|
VersionConflictError = class VersionConflictError extends Error {
|
|
2385
2385
|
taskId;
|
|
@@ -2445,6 +2445,16 @@ var init_types = __esm(() => {
|
|
|
2445
2445
|
this.name = "DependencyCycleError";
|
|
2446
2446
|
}
|
|
2447
2447
|
};
|
|
2448
|
+
CompletionGuardError = class CompletionGuardError extends Error {
|
|
2449
|
+
reason;
|
|
2450
|
+
retryAfterSeconds;
|
|
2451
|
+
constructor(reason, retryAfterSeconds) {
|
|
2452
|
+
super(reason);
|
|
2453
|
+
this.reason = reason;
|
|
2454
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
2455
|
+
this.name = "CompletionGuardError";
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2448
2458
|
});
|
|
2449
2459
|
|
|
2450
2460
|
// src/db/projects.ts
|
|
@@ -2546,6 +2556,173 @@ var init_projects = __esm(() => {
|
|
|
2546
2556
|
init_database();
|
|
2547
2557
|
});
|
|
2548
2558
|
|
|
2559
|
+
// src/lib/sync-utils.ts
|
|
2560
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
2561
|
+
import { join as join2 } from "path";
|
|
2562
|
+
function ensureDir2(dir) {
|
|
2563
|
+
if (!existsSync2(dir))
|
|
2564
|
+
mkdirSync2(dir, { recursive: true });
|
|
2565
|
+
}
|
|
2566
|
+
function listJsonFiles(dir) {
|
|
2567
|
+
if (!existsSync2(dir))
|
|
2568
|
+
return [];
|
|
2569
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
2570
|
+
}
|
|
2571
|
+
function readJsonFile(path) {
|
|
2572
|
+
try {
|
|
2573
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
2574
|
+
} catch {
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
function writeJsonFile(path, data) {
|
|
2579
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
2580
|
+
`);
|
|
2581
|
+
}
|
|
2582
|
+
function readHighWaterMark(dir) {
|
|
2583
|
+
const path = join2(dir, ".highwatermark");
|
|
2584
|
+
if (!existsSync2(path))
|
|
2585
|
+
return 1;
|
|
2586
|
+
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
2587
|
+
return isNaN(val) ? 1 : val;
|
|
2588
|
+
}
|
|
2589
|
+
function writeHighWaterMark(dir, value) {
|
|
2590
|
+
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
2591
|
+
}
|
|
2592
|
+
function getFileMtimeMs(path) {
|
|
2593
|
+
try {
|
|
2594
|
+
return statSync(path).mtimeMs;
|
|
2595
|
+
} catch {
|
|
2596
|
+
return null;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
function parseTimestamp(value) {
|
|
2600
|
+
if (typeof value !== "string")
|
|
2601
|
+
return null;
|
|
2602
|
+
const parsed = Date.parse(value);
|
|
2603
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
2604
|
+
}
|
|
2605
|
+
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
2606
|
+
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
2607
|
+
const next = [conflict, ...current].slice(0, limit);
|
|
2608
|
+
return { ...metadata, sync_conflicts: next };
|
|
2609
|
+
}
|
|
2610
|
+
var HOME;
|
|
2611
|
+
var init_sync_utils = __esm(() => {
|
|
2612
|
+
HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
// src/lib/config.ts
|
|
2616
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2617
|
+
import { join as join3 } from "path";
|
|
2618
|
+
function normalizeAgent(agent) {
|
|
2619
|
+
return agent.trim().toLowerCase();
|
|
2620
|
+
}
|
|
2621
|
+
function loadConfig() {
|
|
2622
|
+
if (cached)
|
|
2623
|
+
return cached;
|
|
2624
|
+
if (!existsSync3(CONFIG_PATH)) {
|
|
2625
|
+
cached = {};
|
|
2626
|
+
return cached;
|
|
2627
|
+
}
|
|
2628
|
+
const config = readJsonFile(CONFIG_PATH) || {};
|
|
2629
|
+
if (typeof config.sync_agents === "string") {
|
|
2630
|
+
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
2631
|
+
}
|
|
2632
|
+
cached = config;
|
|
2633
|
+
return cached;
|
|
2634
|
+
}
|
|
2635
|
+
function getSyncAgentsFromConfig() {
|
|
2636
|
+
const config = loadConfig();
|
|
2637
|
+
const agents = config.sync_agents;
|
|
2638
|
+
if (Array.isArray(agents) && agents.length > 0)
|
|
2639
|
+
return agents.map(normalizeAgent);
|
|
2640
|
+
return null;
|
|
2641
|
+
}
|
|
2642
|
+
function getAgentTaskListId(agent) {
|
|
2643
|
+
const config = loadConfig();
|
|
2644
|
+
const key = normalizeAgent(agent);
|
|
2645
|
+
return config.agents?.[key]?.task_list_id || config.task_list_id || null;
|
|
2646
|
+
}
|
|
2647
|
+
function getAgentTasksDir(agent) {
|
|
2648
|
+
const config = loadConfig();
|
|
2649
|
+
const key = normalizeAgent(agent);
|
|
2650
|
+
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
2651
|
+
}
|
|
2652
|
+
function getTaskPrefixConfig() {
|
|
2653
|
+
const config = loadConfig();
|
|
2654
|
+
return config.task_prefix || null;
|
|
2655
|
+
}
|
|
2656
|
+
function getCompletionGuardConfig(projectPath) {
|
|
2657
|
+
const config = loadConfig();
|
|
2658
|
+
const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
|
|
2659
|
+
if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
|
|
2660
|
+
return { ...global, ...config.project_overrides[projectPath].completion_guard };
|
|
2661
|
+
}
|
|
2662
|
+
return global;
|
|
2663
|
+
}
|
|
2664
|
+
var CONFIG_PATH, cached = null, GUARD_DEFAULTS;
|
|
2665
|
+
var init_config = __esm(() => {
|
|
2666
|
+
init_sync_utils();
|
|
2667
|
+
CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
2668
|
+
GUARD_DEFAULTS = {
|
|
2669
|
+
enabled: false,
|
|
2670
|
+
min_work_seconds: 30,
|
|
2671
|
+
max_completions_per_window: 5,
|
|
2672
|
+
window_minutes: 10,
|
|
2673
|
+
cooldown_seconds: 60
|
|
2674
|
+
};
|
|
2675
|
+
});
|
|
2676
|
+
|
|
2677
|
+
// src/lib/completion-guard.ts
|
|
2678
|
+
function checkCompletionGuard(task, agentId, db, configOverride) {
|
|
2679
|
+
let config;
|
|
2680
|
+
if (configOverride) {
|
|
2681
|
+
config = configOverride;
|
|
2682
|
+
} else {
|
|
2683
|
+
const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
|
|
2684
|
+
config = getCompletionGuardConfig(projectPath);
|
|
2685
|
+
}
|
|
2686
|
+
if (!config.enabled)
|
|
2687
|
+
return;
|
|
2688
|
+
if (task.status !== "in_progress") {
|
|
2689
|
+
throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
|
|
2690
|
+
}
|
|
2691
|
+
const agent = agentId || task.assigned_to || task.agent_id;
|
|
2692
|
+
if (config.min_work_seconds && task.locked_at) {
|
|
2693
|
+
const startedAt = new Date(task.locked_at).getTime();
|
|
2694
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
2695
|
+
if (elapsedSeconds < config.min_work_seconds) {
|
|
2696
|
+
const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
|
|
2697
|
+
throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
if (agent && config.max_completions_per_window && config.window_minutes) {
|
|
2701
|
+
const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
|
|
2702
|
+
const result = db.query(`SELECT COUNT(*) as count FROM tasks
|
|
2703
|
+
WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
|
|
2704
|
+
if (result.count >= config.max_completions_per_window) {
|
|
2705
|
+
throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
if (agent && config.cooldown_seconds) {
|
|
2709
|
+
const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
|
|
2710
|
+
WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
|
|
2711
|
+
if (result.last_completed) {
|
|
2712
|
+
const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
|
|
2713
|
+
if (elapsedSeconds < config.cooldown_seconds) {
|
|
2714
|
+
const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
|
|
2715
|
+
throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
var init_completion_guard = __esm(() => {
|
|
2721
|
+
init_types();
|
|
2722
|
+
init_config();
|
|
2723
|
+
init_projects();
|
|
2724
|
+
});
|
|
2725
|
+
|
|
2549
2726
|
// src/db/tasks.ts
|
|
2550
2727
|
function rowToTask(row) {
|
|
2551
2728
|
return {
|
|
@@ -2723,6 +2900,9 @@ function updateTask(id, input, db) {
|
|
|
2723
2900
|
params.push(input.description);
|
|
2724
2901
|
}
|
|
2725
2902
|
if (input.status !== undefined) {
|
|
2903
|
+
if (input.status === "completed") {
|
|
2904
|
+
checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
|
|
2905
|
+
}
|
|
2726
2906
|
sets.push("status = ?");
|
|
2727
2907
|
params.push(input.status);
|
|
2728
2908
|
if (input.status === "completed") {
|
|
@@ -2794,6 +2974,7 @@ function completeTask(id, agentId, db) {
|
|
|
2794
2974
|
if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
|
|
2795
2975
|
throw new LockError(id, task.locked_by);
|
|
2796
2976
|
}
|
|
2977
|
+
checkCompletionGuard(task, agentId || null, d);
|
|
2797
2978
|
const timestamp = now();
|
|
2798
2979
|
d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
|
|
2799
2980
|
WHERE id = ?`, [timestamp, timestamp, id]);
|
|
@@ -2876,6 +3057,7 @@ var init_tasks = __esm(() => {
|
|
|
2876
3057
|
init_types();
|
|
2877
3058
|
init_database();
|
|
2878
3059
|
init_projects();
|
|
3060
|
+
init_completion_guard();
|
|
2879
3061
|
});
|
|
2880
3062
|
|
|
2881
3063
|
// src/db/agents.ts
|
|
@@ -3111,109 +3293,6 @@ var init_search = __esm(() => {
|
|
|
3111
3293
|
init_database();
|
|
3112
3294
|
});
|
|
3113
3295
|
|
|
3114
|
-
// src/lib/sync-utils.ts
|
|
3115
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
3116
|
-
import { join as join2 } from "path";
|
|
3117
|
-
function ensureDir2(dir) {
|
|
3118
|
-
if (!existsSync2(dir))
|
|
3119
|
-
mkdirSync2(dir, { recursive: true });
|
|
3120
|
-
}
|
|
3121
|
-
function listJsonFiles(dir) {
|
|
3122
|
-
if (!existsSync2(dir))
|
|
3123
|
-
return [];
|
|
3124
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
3125
|
-
}
|
|
3126
|
-
function readJsonFile(path) {
|
|
3127
|
-
try {
|
|
3128
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
3129
|
-
} catch {
|
|
3130
|
-
return null;
|
|
3131
|
-
}
|
|
3132
|
-
}
|
|
3133
|
-
function writeJsonFile(path, data) {
|
|
3134
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
3135
|
-
`);
|
|
3136
|
-
}
|
|
3137
|
-
function readHighWaterMark(dir) {
|
|
3138
|
-
const path = join2(dir, ".highwatermark");
|
|
3139
|
-
if (!existsSync2(path))
|
|
3140
|
-
return 1;
|
|
3141
|
-
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
3142
|
-
return isNaN(val) ? 1 : val;
|
|
3143
|
-
}
|
|
3144
|
-
function writeHighWaterMark(dir, value) {
|
|
3145
|
-
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
3146
|
-
}
|
|
3147
|
-
function getFileMtimeMs(path) {
|
|
3148
|
-
try {
|
|
3149
|
-
return statSync(path).mtimeMs;
|
|
3150
|
-
} catch {
|
|
3151
|
-
return null;
|
|
3152
|
-
}
|
|
3153
|
-
}
|
|
3154
|
-
function parseTimestamp(value) {
|
|
3155
|
-
if (typeof value !== "string")
|
|
3156
|
-
return null;
|
|
3157
|
-
const parsed = Date.parse(value);
|
|
3158
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
3159
|
-
}
|
|
3160
|
-
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
3161
|
-
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
3162
|
-
const next = [conflict, ...current].slice(0, limit);
|
|
3163
|
-
return { ...metadata, sync_conflicts: next };
|
|
3164
|
-
}
|
|
3165
|
-
var HOME;
|
|
3166
|
-
var init_sync_utils = __esm(() => {
|
|
3167
|
-
HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
3168
|
-
});
|
|
3169
|
-
|
|
3170
|
-
// src/lib/config.ts
|
|
3171
|
-
import { existsSync as existsSync3 } from "fs";
|
|
3172
|
-
import { join as join3 } from "path";
|
|
3173
|
-
function normalizeAgent(agent) {
|
|
3174
|
-
return agent.trim().toLowerCase();
|
|
3175
|
-
}
|
|
3176
|
-
function loadConfig() {
|
|
3177
|
-
if (cached)
|
|
3178
|
-
return cached;
|
|
3179
|
-
if (!existsSync3(CONFIG_PATH)) {
|
|
3180
|
-
cached = {};
|
|
3181
|
-
return cached;
|
|
3182
|
-
}
|
|
3183
|
-
const config = readJsonFile(CONFIG_PATH) || {};
|
|
3184
|
-
if (typeof config.sync_agents === "string") {
|
|
3185
|
-
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
3186
|
-
}
|
|
3187
|
-
cached = config;
|
|
3188
|
-
return cached;
|
|
3189
|
-
}
|
|
3190
|
-
function getSyncAgentsFromConfig() {
|
|
3191
|
-
const config = loadConfig();
|
|
3192
|
-
const agents = config.sync_agents;
|
|
3193
|
-
if (Array.isArray(agents) && agents.length > 0)
|
|
3194
|
-
return agents.map(normalizeAgent);
|
|
3195
|
-
return null;
|
|
3196
|
-
}
|
|
3197
|
-
function getAgentTaskListId(agent) {
|
|
3198
|
-
const config = loadConfig();
|
|
3199
|
-
const key = normalizeAgent(agent);
|
|
3200
|
-
return config.agents?.[key]?.task_list_id || config.task_list_id || null;
|
|
3201
|
-
}
|
|
3202
|
-
function getAgentTasksDir(agent) {
|
|
3203
|
-
const config = loadConfig();
|
|
3204
|
-
const key = normalizeAgent(agent);
|
|
3205
|
-
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
3206
|
-
}
|
|
3207
|
-
function getTaskPrefixConfig() {
|
|
3208
|
-
const config = loadConfig();
|
|
3209
|
-
return config.task_prefix || null;
|
|
3210
|
-
}
|
|
3211
|
-
var CONFIG_PATH, cached = null;
|
|
3212
|
-
var init_config = __esm(() => {
|
|
3213
|
-
init_sync_utils();
|
|
3214
|
-
CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
3215
|
-
});
|
|
3216
|
-
|
|
3217
3296
|
// src/lib/claude-tasks.ts
|
|
3218
3297
|
import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
3219
3298
|
import { join as join4 } from "path";
|
|
@@ -7729,6 +7808,10 @@ function formatError(error) {
|
|
|
7729
7808
|
return `Lock error: ${error.message}`;
|
|
7730
7809
|
if (error instanceof DependencyCycleError)
|
|
7731
7810
|
return `Dependency cycle: ${error.message}`;
|
|
7811
|
+
if (error instanceof CompletionGuardError) {
|
|
7812
|
+
const retry = error.retryAfterSeconds ? ` (retry after ${error.retryAfterSeconds}s)` : "";
|
|
7813
|
+
return `Completion blocked: ${error.reason}${retry}`;
|
|
7814
|
+
}
|
|
7732
7815
|
if (error instanceof Error)
|
|
7733
7816
|
return error.message;
|
|
7734
7817
|
return String(error);
|
package/dist/index.js
CHANGED
|
@@ -417,6 +417,17 @@ class DependencyCycleError extends Error {
|
|
|
417
417
|
}
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
class CompletionGuardError extends Error {
|
|
421
|
+
reason;
|
|
422
|
+
retryAfterSeconds;
|
|
423
|
+
constructor(reason, retryAfterSeconds) {
|
|
424
|
+
super(reason);
|
|
425
|
+
this.reason = reason;
|
|
426
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
427
|
+
this.name = "CompletionGuardError";
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
420
431
|
// src/db/projects.ts
|
|
421
432
|
function slugify(name) {
|
|
422
433
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
@@ -517,6 +528,159 @@ function ensureProject(name, path, db) {
|
|
|
517
528
|
return createProject({ name, path }, d);
|
|
518
529
|
}
|
|
519
530
|
|
|
531
|
+
// src/lib/config.ts
|
|
532
|
+
import { existsSync as existsSync3 } from "fs";
|
|
533
|
+
import { join as join3 } from "path";
|
|
534
|
+
|
|
535
|
+
// src/lib/sync-utils.ts
|
|
536
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
537
|
+
import { join as join2 } from "path";
|
|
538
|
+
var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
539
|
+
function ensureDir2(dir) {
|
|
540
|
+
if (!existsSync2(dir))
|
|
541
|
+
mkdirSync2(dir, { recursive: true });
|
|
542
|
+
}
|
|
543
|
+
function listJsonFiles(dir) {
|
|
544
|
+
if (!existsSync2(dir))
|
|
545
|
+
return [];
|
|
546
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
547
|
+
}
|
|
548
|
+
function readJsonFile(path) {
|
|
549
|
+
try {
|
|
550
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
551
|
+
} catch {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function writeJsonFile(path, data) {
|
|
556
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
557
|
+
`);
|
|
558
|
+
}
|
|
559
|
+
function readHighWaterMark(dir) {
|
|
560
|
+
const path = join2(dir, ".highwatermark");
|
|
561
|
+
if (!existsSync2(path))
|
|
562
|
+
return 1;
|
|
563
|
+
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
564
|
+
return isNaN(val) ? 1 : val;
|
|
565
|
+
}
|
|
566
|
+
function writeHighWaterMark(dir, value) {
|
|
567
|
+
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
568
|
+
}
|
|
569
|
+
function getFileMtimeMs(path) {
|
|
570
|
+
try {
|
|
571
|
+
return statSync(path).mtimeMs;
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function parseTimestamp(value) {
|
|
577
|
+
if (typeof value !== "string")
|
|
578
|
+
return null;
|
|
579
|
+
const parsed = Date.parse(value);
|
|
580
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
581
|
+
}
|
|
582
|
+
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
583
|
+
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
584
|
+
const next = [conflict, ...current].slice(0, limit);
|
|
585
|
+
return { ...metadata, sync_conflicts: next };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// src/lib/config.ts
|
|
589
|
+
var CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
590
|
+
var cached = null;
|
|
591
|
+
function normalizeAgent(agent) {
|
|
592
|
+
return agent.trim().toLowerCase();
|
|
593
|
+
}
|
|
594
|
+
function loadConfig() {
|
|
595
|
+
if (cached)
|
|
596
|
+
return cached;
|
|
597
|
+
if (!existsSync3(CONFIG_PATH)) {
|
|
598
|
+
cached = {};
|
|
599
|
+
return cached;
|
|
600
|
+
}
|
|
601
|
+
const config = readJsonFile(CONFIG_PATH) || {};
|
|
602
|
+
if (typeof config.sync_agents === "string") {
|
|
603
|
+
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
604
|
+
}
|
|
605
|
+
cached = config;
|
|
606
|
+
return cached;
|
|
607
|
+
}
|
|
608
|
+
function getSyncAgentsFromConfig() {
|
|
609
|
+
const config = loadConfig();
|
|
610
|
+
const agents = config.sync_agents;
|
|
611
|
+
if (Array.isArray(agents) && agents.length > 0)
|
|
612
|
+
return agents.map(normalizeAgent);
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
function getAgentTasksDir(agent) {
|
|
616
|
+
const config = loadConfig();
|
|
617
|
+
const key = normalizeAgent(agent);
|
|
618
|
+
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
619
|
+
}
|
|
620
|
+
function getTaskPrefixConfig() {
|
|
621
|
+
const config = loadConfig();
|
|
622
|
+
return config.task_prefix || null;
|
|
623
|
+
}
|
|
624
|
+
var GUARD_DEFAULTS = {
|
|
625
|
+
enabled: false,
|
|
626
|
+
min_work_seconds: 30,
|
|
627
|
+
max_completions_per_window: 5,
|
|
628
|
+
window_minutes: 10,
|
|
629
|
+
cooldown_seconds: 60
|
|
630
|
+
};
|
|
631
|
+
function getCompletionGuardConfig(projectPath) {
|
|
632
|
+
const config = loadConfig();
|
|
633
|
+
const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
|
|
634
|
+
if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
|
|
635
|
+
return { ...global, ...config.project_overrides[projectPath].completion_guard };
|
|
636
|
+
}
|
|
637
|
+
return global;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// src/lib/completion-guard.ts
|
|
641
|
+
function checkCompletionGuard(task, agentId, db, configOverride) {
|
|
642
|
+
let config;
|
|
643
|
+
if (configOverride) {
|
|
644
|
+
config = configOverride;
|
|
645
|
+
} else {
|
|
646
|
+
const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
|
|
647
|
+
config = getCompletionGuardConfig(projectPath);
|
|
648
|
+
}
|
|
649
|
+
if (!config.enabled)
|
|
650
|
+
return;
|
|
651
|
+
if (task.status !== "in_progress") {
|
|
652
|
+
throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
|
|
653
|
+
}
|
|
654
|
+
const agent = agentId || task.assigned_to || task.agent_id;
|
|
655
|
+
if (config.min_work_seconds && task.locked_at) {
|
|
656
|
+
const startedAt = new Date(task.locked_at).getTime();
|
|
657
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
658
|
+
if (elapsedSeconds < config.min_work_seconds) {
|
|
659
|
+
const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
|
|
660
|
+
throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (agent && config.max_completions_per_window && config.window_minutes) {
|
|
664
|
+
const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
|
|
665
|
+
const result = db.query(`SELECT COUNT(*) as count FROM tasks
|
|
666
|
+
WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
|
|
667
|
+
if (result.count >= config.max_completions_per_window) {
|
|
668
|
+
throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (agent && config.cooldown_seconds) {
|
|
672
|
+
const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
|
|
673
|
+
WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
|
|
674
|
+
if (result.last_completed) {
|
|
675
|
+
const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
|
|
676
|
+
if (elapsedSeconds < config.cooldown_seconds) {
|
|
677
|
+
const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
|
|
678
|
+
throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
520
684
|
// src/db/tasks.ts
|
|
521
685
|
function rowToTask(row) {
|
|
522
686
|
return {
|
|
@@ -694,6 +858,9 @@ function updateTask(id, input, db) {
|
|
|
694
858
|
params.push(input.description);
|
|
695
859
|
}
|
|
696
860
|
if (input.status !== undefined) {
|
|
861
|
+
if (input.status === "completed") {
|
|
862
|
+
checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
|
|
863
|
+
}
|
|
697
864
|
sets.push("status = ?");
|
|
698
865
|
params.push(input.status);
|
|
699
866
|
if (input.status === "completed") {
|
|
@@ -765,6 +932,7 @@ function completeTask(id, agentId, db) {
|
|
|
765
932
|
if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
|
|
766
933
|
throw new LockError(id, task.locked_by);
|
|
767
934
|
}
|
|
935
|
+
checkCompletionGuard(task, agentId || null, d);
|
|
768
936
|
const timestamp = now();
|
|
769
937
|
d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
|
|
770
938
|
WHERE id = ?`, [timestamp, timestamp, id]);
|
|
@@ -1140,102 +1308,6 @@ function searchTasks(query, projectId, taskListId, db) {
|
|
|
1140
1308
|
// src/lib/claude-tasks.ts
|
|
1141
1309
|
import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1142
1310
|
import { join as join4 } from "path";
|
|
1143
|
-
|
|
1144
|
-
// src/lib/config.ts
|
|
1145
|
-
import { existsSync as existsSync3 } from "fs";
|
|
1146
|
-
import { join as join3 } from "path";
|
|
1147
|
-
|
|
1148
|
-
// src/lib/sync-utils.ts
|
|
1149
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
1150
|
-
import { join as join2 } from "path";
|
|
1151
|
-
var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
1152
|
-
function ensureDir2(dir) {
|
|
1153
|
-
if (!existsSync2(dir))
|
|
1154
|
-
mkdirSync2(dir, { recursive: true });
|
|
1155
|
-
}
|
|
1156
|
-
function listJsonFiles(dir) {
|
|
1157
|
-
if (!existsSync2(dir))
|
|
1158
|
-
return [];
|
|
1159
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
1160
|
-
}
|
|
1161
|
-
function readJsonFile(path) {
|
|
1162
|
-
try {
|
|
1163
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
1164
|
-
} catch {
|
|
1165
|
-
return null;
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
function writeJsonFile(path, data) {
|
|
1169
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
1170
|
-
`);
|
|
1171
|
-
}
|
|
1172
|
-
function readHighWaterMark(dir) {
|
|
1173
|
-
const path = join2(dir, ".highwatermark");
|
|
1174
|
-
if (!existsSync2(path))
|
|
1175
|
-
return 1;
|
|
1176
|
-
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
1177
|
-
return isNaN(val) ? 1 : val;
|
|
1178
|
-
}
|
|
1179
|
-
function writeHighWaterMark(dir, value) {
|
|
1180
|
-
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
1181
|
-
}
|
|
1182
|
-
function getFileMtimeMs(path) {
|
|
1183
|
-
try {
|
|
1184
|
-
return statSync(path).mtimeMs;
|
|
1185
|
-
} catch {
|
|
1186
|
-
return null;
|
|
1187
|
-
}
|
|
1188
|
-
}
|
|
1189
|
-
function parseTimestamp(value) {
|
|
1190
|
-
if (typeof value !== "string")
|
|
1191
|
-
return null;
|
|
1192
|
-
const parsed = Date.parse(value);
|
|
1193
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
1194
|
-
}
|
|
1195
|
-
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
1196
|
-
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
1197
|
-
const next = [conflict, ...current].slice(0, limit);
|
|
1198
|
-
return { ...metadata, sync_conflicts: next };
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// src/lib/config.ts
|
|
1202
|
-
var CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
1203
|
-
var cached = null;
|
|
1204
|
-
function normalizeAgent(agent) {
|
|
1205
|
-
return agent.trim().toLowerCase();
|
|
1206
|
-
}
|
|
1207
|
-
function loadConfig() {
|
|
1208
|
-
if (cached)
|
|
1209
|
-
return cached;
|
|
1210
|
-
if (!existsSync3(CONFIG_PATH)) {
|
|
1211
|
-
cached = {};
|
|
1212
|
-
return cached;
|
|
1213
|
-
}
|
|
1214
|
-
const config = readJsonFile(CONFIG_PATH) || {};
|
|
1215
|
-
if (typeof config.sync_agents === "string") {
|
|
1216
|
-
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
1217
|
-
}
|
|
1218
|
-
cached = config;
|
|
1219
|
-
return cached;
|
|
1220
|
-
}
|
|
1221
|
-
function getSyncAgentsFromConfig() {
|
|
1222
|
-
const config = loadConfig();
|
|
1223
|
-
const agents = config.sync_agents;
|
|
1224
|
-
if (Array.isArray(agents) && agents.length > 0)
|
|
1225
|
-
return agents.map(normalizeAgent);
|
|
1226
|
-
return null;
|
|
1227
|
-
}
|
|
1228
|
-
function getAgentTasksDir(agent) {
|
|
1229
|
-
const config = loadConfig();
|
|
1230
|
-
const key = normalizeAgent(agent);
|
|
1231
|
-
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
1232
|
-
}
|
|
1233
|
-
function getTaskPrefixConfig() {
|
|
1234
|
-
const config = loadConfig();
|
|
1235
|
-
return config.task_prefix || null;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
// src/lib/claude-tasks.ts
|
|
1239
1311
|
function getTaskListDir(taskListId) {
|
|
1240
1312
|
return join4(HOME, ".claude", "tasks", taskListId);
|
|
1241
1313
|
}
|
|
@@ -1789,6 +1861,7 @@ export {
|
|
|
1789
1861
|
getProject,
|
|
1790
1862
|
getPlan,
|
|
1791
1863
|
getDatabase,
|
|
1864
|
+
getCompletionGuardConfig,
|
|
1792
1865
|
getComment,
|
|
1793
1866
|
getAgentByName,
|
|
1794
1867
|
getAgent,
|
|
@@ -1809,6 +1882,7 @@ export {
|
|
|
1809
1882
|
createPlan,
|
|
1810
1883
|
completeTask,
|
|
1811
1884
|
closeDatabase,
|
|
1885
|
+
checkCompletionGuard,
|
|
1812
1886
|
addDependency,
|
|
1813
1887
|
addComment,
|
|
1814
1888
|
VersionConflictError,
|
|
@@ -1821,5 +1895,6 @@ export {
|
|
|
1821
1895
|
PLAN_STATUSES,
|
|
1822
1896
|
LockError,
|
|
1823
1897
|
DependencyCycleError,
|
|
1898
|
+
CompletionGuardError,
|
|
1824
1899
|
AgentNotFoundError
|
|
1825
1900
|
};
|
package/dist/mcp/index.js
CHANGED
|
@@ -4049,6 +4049,17 @@ class DependencyCycleError extends Error {
|
|
|
4049
4049
|
}
|
|
4050
4050
|
}
|
|
4051
4051
|
|
|
4052
|
+
class CompletionGuardError extends Error {
|
|
4053
|
+
reason;
|
|
4054
|
+
retryAfterSeconds;
|
|
4055
|
+
constructor(reason, retryAfterSeconds) {
|
|
4056
|
+
super(reason);
|
|
4057
|
+
this.reason = reason;
|
|
4058
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
4059
|
+
this.name = "CompletionGuardError";
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4052
4063
|
// src/db/database.ts
|
|
4053
4064
|
import { Database } from "bun:sqlite";
|
|
4054
4065
|
import { existsSync, mkdirSync } from "fs";
|
|
@@ -4417,6 +4428,164 @@ function nextTaskShortId(projectId, db) {
|
|
|
4417
4428
|
return `${updated.task_prefix}-${padded}`;
|
|
4418
4429
|
}
|
|
4419
4430
|
|
|
4431
|
+
// src/lib/config.ts
|
|
4432
|
+
import { existsSync as existsSync3 } from "fs";
|
|
4433
|
+
import { join as join3 } from "path";
|
|
4434
|
+
|
|
4435
|
+
// src/lib/sync-utils.ts
|
|
4436
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
4437
|
+
import { join as join2 } from "path";
|
|
4438
|
+
var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
4439
|
+
function ensureDir2(dir) {
|
|
4440
|
+
if (!existsSync2(dir))
|
|
4441
|
+
mkdirSync2(dir, { recursive: true });
|
|
4442
|
+
}
|
|
4443
|
+
function listJsonFiles(dir) {
|
|
4444
|
+
if (!existsSync2(dir))
|
|
4445
|
+
return [];
|
|
4446
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
4447
|
+
}
|
|
4448
|
+
function readJsonFile(path) {
|
|
4449
|
+
try {
|
|
4450
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
4451
|
+
} catch {
|
|
4452
|
+
return null;
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
function writeJsonFile(path, data) {
|
|
4456
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
4457
|
+
`);
|
|
4458
|
+
}
|
|
4459
|
+
function readHighWaterMark(dir) {
|
|
4460
|
+
const path = join2(dir, ".highwatermark");
|
|
4461
|
+
if (!existsSync2(path))
|
|
4462
|
+
return 1;
|
|
4463
|
+
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
4464
|
+
return isNaN(val) ? 1 : val;
|
|
4465
|
+
}
|
|
4466
|
+
function writeHighWaterMark(dir, value) {
|
|
4467
|
+
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
4468
|
+
}
|
|
4469
|
+
function getFileMtimeMs(path) {
|
|
4470
|
+
try {
|
|
4471
|
+
return statSync(path).mtimeMs;
|
|
4472
|
+
} catch {
|
|
4473
|
+
return null;
|
|
4474
|
+
}
|
|
4475
|
+
}
|
|
4476
|
+
function parseTimestamp(value) {
|
|
4477
|
+
if (typeof value !== "string")
|
|
4478
|
+
return null;
|
|
4479
|
+
const parsed = Date.parse(value);
|
|
4480
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
4481
|
+
}
|
|
4482
|
+
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
4483
|
+
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
4484
|
+
const next = [conflict, ...current].slice(0, limit);
|
|
4485
|
+
return { ...metadata, sync_conflicts: next };
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
// src/lib/config.ts
|
|
4489
|
+
var CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
4490
|
+
var cached = null;
|
|
4491
|
+
function normalizeAgent(agent) {
|
|
4492
|
+
return agent.trim().toLowerCase();
|
|
4493
|
+
}
|
|
4494
|
+
function loadConfig() {
|
|
4495
|
+
if (cached)
|
|
4496
|
+
return cached;
|
|
4497
|
+
if (!existsSync3(CONFIG_PATH)) {
|
|
4498
|
+
cached = {};
|
|
4499
|
+
return cached;
|
|
4500
|
+
}
|
|
4501
|
+
const config = readJsonFile(CONFIG_PATH) || {};
|
|
4502
|
+
if (typeof config.sync_agents === "string") {
|
|
4503
|
+
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
4504
|
+
}
|
|
4505
|
+
cached = config;
|
|
4506
|
+
return cached;
|
|
4507
|
+
}
|
|
4508
|
+
function getSyncAgentsFromConfig() {
|
|
4509
|
+
const config = loadConfig();
|
|
4510
|
+
const agents = config.sync_agents;
|
|
4511
|
+
if (Array.isArray(agents) && agents.length > 0)
|
|
4512
|
+
return agents.map(normalizeAgent);
|
|
4513
|
+
return null;
|
|
4514
|
+
}
|
|
4515
|
+
function getAgentTaskListId(agent) {
|
|
4516
|
+
const config = loadConfig();
|
|
4517
|
+
const key = normalizeAgent(agent);
|
|
4518
|
+
return config.agents?.[key]?.task_list_id || config.task_list_id || null;
|
|
4519
|
+
}
|
|
4520
|
+
function getAgentTasksDir(agent) {
|
|
4521
|
+
const config = loadConfig();
|
|
4522
|
+
const key = normalizeAgent(agent);
|
|
4523
|
+
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
4524
|
+
}
|
|
4525
|
+
function getTaskPrefixConfig() {
|
|
4526
|
+
const config = loadConfig();
|
|
4527
|
+
return config.task_prefix || null;
|
|
4528
|
+
}
|
|
4529
|
+
var GUARD_DEFAULTS = {
|
|
4530
|
+
enabled: false,
|
|
4531
|
+
min_work_seconds: 30,
|
|
4532
|
+
max_completions_per_window: 5,
|
|
4533
|
+
window_minutes: 10,
|
|
4534
|
+
cooldown_seconds: 60
|
|
4535
|
+
};
|
|
4536
|
+
function getCompletionGuardConfig(projectPath) {
|
|
4537
|
+
const config = loadConfig();
|
|
4538
|
+
const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
|
|
4539
|
+
if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
|
|
4540
|
+
return { ...global, ...config.project_overrides[projectPath].completion_guard };
|
|
4541
|
+
}
|
|
4542
|
+
return global;
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
// src/lib/completion-guard.ts
|
|
4546
|
+
function checkCompletionGuard(task, agentId, db, configOverride) {
|
|
4547
|
+
let config;
|
|
4548
|
+
if (configOverride) {
|
|
4549
|
+
config = configOverride;
|
|
4550
|
+
} else {
|
|
4551
|
+
const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
|
|
4552
|
+
config = getCompletionGuardConfig(projectPath);
|
|
4553
|
+
}
|
|
4554
|
+
if (!config.enabled)
|
|
4555
|
+
return;
|
|
4556
|
+
if (task.status !== "in_progress") {
|
|
4557
|
+
throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
|
|
4558
|
+
}
|
|
4559
|
+
const agent = agentId || task.assigned_to || task.agent_id;
|
|
4560
|
+
if (config.min_work_seconds && task.locked_at) {
|
|
4561
|
+
const startedAt = new Date(task.locked_at).getTime();
|
|
4562
|
+
const elapsedSeconds = (Date.now() - startedAt) / 1000;
|
|
4563
|
+
if (elapsedSeconds < config.min_work_seconds) {
|
|
4564
|
+
const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
|
|
4565
|
+
throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
if (agent && config.max_completions_per_window && config.window_minutes) {
|
|
4569
|
+
const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
|
|
4570
|
+
const result = db.query(`SELECT COUNT(*) as count FROM tasks
|
|
4571
|
+
WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
|
|
4572
|
+
if (result.count >= config.max_completions_per_window) {
|
|
4573
|
+
throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
|
|
4574
|
+
}
|
|
4575
|
+
}
|
|
4576
|
+
if (agent && config.cooldown_seconds) {
|
|
4577
|
+
const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
|
|
4578
|
+
WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
|
|
4579
|
+
if (result.last_completed) {
|
|
4580
|
+
const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
|
|
4581
|
+
if (elapsedSeconds < config.cooldown_seconds) {
|
|
4582
|
+
const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
|
|
4583
|
+
throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4420
4589
|
// src/db/tasks.ts
|
|
4421
4590
|
function rowToTask(row) {
|
|
4422
4591
|
return {
|
|
@@ -4594,6 +4763,9 @@ function updateTask(id, input, db) {
|
|
|
4594
4763
|
params.push(input.description);
|
|
4595
4764
|
}
|
|
4596
4765
|
if (input.status !== undefined) {
|
|
4766
|
+
if (input.status === "completed") {
|
|
4767
|
+
checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
|
|
4768
|
+
}
|
|
4597
4769
|
sets.push("status = ?");
|
|
4598
4770
|
params.push(input.status);
|
|
4599
4771
|
if (input.status === "completed") {
|
|
@@ -4665,6 +4837,7 @@ function completeTask(id, agentId, db) {
|
|
|
4665
4837
|
if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
|
|
4666
4838
|
throw new LockError(id, task.locked_by);
|
|
4667
4839
|
}
|
|
4840
|
+
checkCompletionGuard(task, agentId || null, d);
|
|
4668
4841
|
const timestamp = now();
|
|
4669
4842
|
d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
|
|
4670
4843
|
WHERE id = ?`, [timestamp, timestamp, id]);
|
|
@@ -4960,107 +5133,6 @@ function searchTasks(query, projectId, taskListId, db) {
|
|
|
4960
5133
|
// src/lib/claude-tasks.ts
|
|
4961
5134
|
import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4962
5135
|
import { join as join4 } from "path";
|
|
4963
|
-
|
|
4964
|
-
// src/lib/config.ts
|
|
4965
|
-
import { existsSync as existsSync3 } from "fs";
|
|
4966
|
-
import { join as join3 } from "path";
|
|
4967
|
-
|
|
4968
|
-
// src/lib/sync-utils.ts
|
|
4969
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
4970
|
-
import { join as join2 } from "path";
|
|
4971
|
-
var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
4972
|
-
function ensureDir2(dir) {
|
|
4973
|
-
if (!existsSync2(dir))
|
|
4974
|
-
mkdirSync2(dir, { recursive: true });
|
|
4975
|
-
}
|
|
4976
|
-
function listJsonFiles(dir) {
|
|
4977
|
-
if (!existsSync2(dir))
|
|
4978
|
-
return [];
|
|
4979
|
-
return readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
4980
|
-
}
|
|
4981
|
-
function readJsonFile(path) {
|
|
4982
|
-
try {
|
|
4983
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
4984
|
-
} catch {
|
|
4985
|
-
return null;
|
|
4986
|
-
}
|
|
4987
|
-
}
|
|
4988
|
-
function writeJsonFile(path, data) {
|
|
4989
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + `
|
|
4990
|
-
`);
|
|
4991
|
-
}
|
|
4992
|
-
function readHighWaterMark(dir) {
|
|
4993
|
-
const path = join2(dir, ".highwatermark");
|
|
4994
|
-
if (!existsSync2(path))
|
|
4995
|
-
return 1;
|
|
4996
|
-
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
4997
|
-
return isNaN(val) ? 1 : val;
|
|
4998
|
-
}
|
|
4999
|
-
function writeHighWaterMark(dir, value) {
|
|
5000
|
-
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
5001
|
-
}
|
|
5002
|
-
function getFileMtimeMs(path) {
|
|
5003
|
-
try {
|
|
5004
|
-
return statSync(path).mtimeMs;
|
|
5005
|
-
} catch {
|
|
5006
|
-
return null;
|
|
5007
|
-
}
|
|
5008
|
-
}
|
|
5009
|
-
function parseTimestamp(value) {
|
|
5010
|
-
if (typeof value !== "string")
|
|
5011
|
-
return null;
|
|
5012
|
-
const parsed = Date.parse(value);
|
|
5013
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
5014
|
-
}
|
|
5015
|
-
function appendSyncConflict(metadata, conflict, limit = 5) {
|
|
5016
|
-
const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
|
|
5017
|
-
const next = [conflict, ...current].slice(0, limit);
|
|
5018
|
-
return { ...metadata, sync_conflicts: next };
|
|
5019
|
-
}
|
|
5020
|
-
|
|
5021
|
-
// src/lib/config.ts
|
|
5022
|
-
var CONFIG_PATH = join3(HOME, ".todos", "config.json");
|
|
5023
|
-
var cached = null;
|
|
5024
|
-
function normalizeAgent(agent) {
|
|
5025
|
-
return agent.trim().toLowerCase();
|
|
5026
|
-
}
|
|
5027
|
-
function loadConfig() {
|
|
5028
|
-
if (cached)
|
|
5029
|
-
return cached;
|
|
5030
|
-
if (!existsSync3(CONFIG_PATH)) {
|
|
5031
|
-
cached = {};
|
|
5032
|
-
return cached;
|
|
5033
|
-
}
|
|
5034
|
-
const config = readJsonFile(CONFIG_PATH) || {};
|
|
5035
|
-
if (typeof config.sync_agents === "string") {
|
|
5036
|
-
config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
|
|
5037
|
-
}
|
|
5038
|
-
cached = config;
|
|
5039
|
-
return cached;
|
|
5040
|
-
}
|
|
5041
|
-
function getSyncAgentsFromConfig() {
|
|
5042
|
-
const config = loadConfig();
|
|
5043
|
-
const agents = config.sync_agents;
|
|
5044
|
-
if (Array.isArray(agents) && agents.length > 0)
|
|
5045
|
-
return agents.map(normalizeAgent);
|
|
5046
|
-
return null;
|
|
5047
|
-
}
|
|
5048
|
-
function getAgentTaskListId(agent) {
|
|
5049
|
-
const config = loadConfig();
|
|
5050
|
-
const key = normalizeAgent(agent);
|
|
5051
|
-
return config.agents?.[key]?.task_list_id || config.task_list_id || null;
|
|
5052
|
-
}
|
|
5053
|
-
function getAgentTasksDir(agent) {
|
|
5054
|
-
const config = loadConfig();
|
|
5055
|
-
const key = normalizeAgent(agent);
|
|
5056
|
-
return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
|
|
5057
|
-
}
|
|
5058
|
-
function getTaskPrefixConfig() {
|
|
5059
|
-
const config = loadConfig();
|
|
5060
|
-
return config.task_prefix || null;
|
|
5061
|
-
}
|
|
5062
|
-
|
|
5063
|
-
// src/lib/claude-tasks.ts
|
|
5064
5136
|
function getTaskListDir(taskListId) {
|
|
5065
5137
|
return join4(HOME, ".claude", "tasks", taskListId);
|
|
5066
5138
|
}
|
|
@@ -5593,6 +5665,10 @@ function formatError(error) {
|
|
|
5593
5665
|
return `Lock error: ${error.message}`;
|
|
5594
5666
|
if (error instanceof DependencyCycleError)
|
|
5595
5667
|
return `Dependency cycle: ${error.message}`;
|
|
5668
|
+
if (error instanceof CompletionGuardError) {
|
|
5669
|
+
const retry = error.retryAfterSeconds ? ` (retry after ${error.retryAfterSeconds}s)` : "";
|
|
5670
|
+
return `Completion blocked: ${error.reason}${retry}`;
|
|
5671
|
+
}
|
|
5596
5672
|
if (error instanceof Error)
|
|
5597
5673
|
return error.message;
|
|
5598
5674
|
return String(error);
|