@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 CHANGED
@@ -1,18 +1,23 @@
1
1
  # @hasna/todos
2
2
 
3
- Universal task management for AI coding agents. CLI + MCP server + web dashboard + library, all sharing a single SQLite database.
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
- # Launch interactive TUI
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 your AI coding agents:
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
- ## Sync (Optional)
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
- Claude supports a native task list. Other agents use JSON task lists under `~/.todos/agents/<agent>/<task_list_id>/`.
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
- Env overrides:
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
- Config file: `~/.todos/config.json`
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", "tasks_dir": "/Users/you/.todos/agents" }
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 add <title>` | Create a task |
97
- | `todos list` | List tasks (active by default) |
98
- | `todos show <id>` | Show full task details |
99
- | `todos update <id>` | Update task fields |
100
- | `todos start <id>` | Claim and start a task |
101
- | `todos done <id>` | Mark task completed |
102
- | `todos delete <id>` | Delete a task |
103
- | `todos plan <title>` | Create a plan with subtasks |
104
- | `todos comment <id> <text>` | Add a comment |
105
- | `todos search <query>` | Search tasks |
106
- | `todos deps <id>` | Manage dependencies |
107
- | `todos projects` | List/manage projects |
108
- | `todos export` | Export tasks (JSON or Markdown) |
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 { createTask, listTasks, completeTask } from "@hasna/todos";
117
-
118
- const task = createTask({ title: "My task", priority: "high" });
119
- const tasks = listTasks({ status: "pending" });
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 database with automatic location detection:
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
- Set `TODOS_DB_SCOPE=project` to force project-level DB at the git root (if found).
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 112 tests
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/todos",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",