@hasna/todos 0.3.5 → 0.4.0

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/dist/cli/index.js CHANGED
@@ -2067,19 +2067,54 @@ var require_commander = __commonJS((exports) => {
2067
2067
  import { Database } from "bun:sqlite";
2068
2068
  import { existsSync, mkdirSync } from "fs";
2069
2069
  import { dirname, join, resolve } from "path";
2070
+ function isInMemoryDb(path) {
2071
+ return path === ":memory:" || path.startsWith("file::memory:");
2072
+ }
2073
+ function findNearestTodosDb(startDir) {
2074
+ let dir = resolve(startDir);
2075
+ while (true) {
2076
+ const candidate = join(dir, ".todos", "todos.db");
2077
+ if (existsSync(candidate))
2078
+ return candidate;
2079
+ const parent = dirname(dir);
2080
+ if (parent === dir)
2081
+ break;
2082
+ dir = parent;
2083
+ }
2084
+ return null;
2085
+ }
2086
+ function findGitRoot(startDir) {
2087
+ let dir = resolve(startDir);
2088
+ while (true) {
2089
+ if (existsSync(join(dir, ".git")))
2090
+ return dir;
2091
+ const parent = dirname(dir);
2092
+ if (parent === dir)
2093
+ break;
2094
+ dir = parent;
2095
+ }
2096
+ return null;
2097
+ }
2070
2098
  function getDbPath() {
2071
2099
  if (process.env["TODOS_DB_PATH"]) {
2072
2100
  return process.env["TODOS_DB_PATH"];
2073
2101
  }
2074
2102
  const cwd = process.cwd();
2075
- const localDb = join(cwd, ".todos", "todos.db");
2076
- if (existsSync(localDb)) {
2077
- return localDb;
2103
+ const nearest = findNearestTodosDb(cwd);
2104
+ if (nearest)
2105
+ return nearest;
2106
+ if (process.env["TODOS_DB_SCOPE"] === "project") {
2107
+ const gitRoot = findGitRoot(cwd);
2108
+ if (gitRoot) {
2109
+ return join(gitRoot, ".todos", "todos.db");
2110
+ }
2078
2111
  }
2079
2112
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
2080
2113
  return join(home, ".todos", "todos.db");
2081
2114
  }
2082
2115
  function ensureDir(filePath) {
2116
+ if (isInMemoryDb(filePath))
2117
+ return;
2083
2118
  const dir = dirname(resolve(filePath));
2084
2119
  if (!existsSync(dir)) {
2085
2120
  mkdirSync(dir, { recursive: true });
@@ -2095,6 +2130,7 @@ function getDatabase(dbPath) {
2095
2130
  _db.run("PRAGMA busy_timeout = 5000");
2096
2131
  _db.run("PRAGMA foreign_keys = ON");
2097
2132
  runMigrations(_db);
2133
+ backfillTaskTags(_db);
2098
2134
  return _db;
2099
2135
  }
2100
2136
  function runMigrations(db) {
@@ -2110,6 +2146,35 @@ function runMigrations(db) {
2110
2146
  }
2111
2147
  }
2112
2148
  }
2149
+ function backfillTaskTags(db) {
2150
+ try {
2151
+ const count = db.query("SELECT COUNT(*) as count FROM task_tags").get();
2152
+ if (count && count.count > 0)
2153
+ return;
2154
+ } catch {
2155
+ return;
2156
+ }
2157
+ try {
2158
+ const rows = db.query("SELECT id, tags FROM tasks WHERE tags IS NOT NULL AND tags != '[]'").all();
2159
+ if (rows.length === 0)
2160
+ return;
2161
+ const insert = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
2162
+ for (const row of rows) {
2163
+ if (!row.tags)
2164
+ continue;
2165
+ let tags = [];
2166
+ try {
2167
+ tags = JSON.parse(row.tags);
2168
+ } catch {
2169
+ continue;
2170
+ }
2171
+ for (const tag of tags) {
2172
+ if (tag)
2173
+ insert.run(row.id, tag);
2174
+ }
2175
+ }
2176
+ } catch {}
2177
+ }
2113
2178
  function now() {
2114
2179
  return new Date().toISOString();
2115
2180
  }
@@ -2123,6 +2188,14 @@ function isLockExpired(lockedAt) {
2123
2188
  const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
2124
2189
  return Date.now() - lockTime > expiryMs;
2125
2190
  }
2191
+ function lockExpiryCutoff(nowMs = Date.now()) {
2192
+ const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
2193
+ return new Date(nowMs - expiryMs).toISOString();
2194
+ }
2195
+ function clearExpiredLocks(db) {
2196
+ const cutoff = lockExpiryCutoff();
2197
+ db.run("UPDATE tasks SET locked_by = NULL, locked_at = NULL WHERE locked_at IS NOT NULL AND locked_at < ?", [cutoff]);
2198
+ }
2126
2199
  function resolvePartialId(db, table, partialId) {
2127
2200
  if (partialId.length >= 36) {
2128
2201
  const row = db.query(`SELECT id FROM ${table} WHERE id = ?`).get(partialId);
@@ -2215,12 +2288,43 @@ var init_database = __esm(() => {
2215
2288
  );
2216
2289
 
2217
2290
  INSERT OR IGNORE INTO _migrations (id) VALUES (1);
2291
+ `,
2292
+ `
2293
+ ALTER TABLE projects ADD COLUMN task_list_id TEXT;
2294
+ INSERT OR IGNORE INTO _migrations (id) VALUES (2);
2295
+ `,
2296
+ `
2297
+ CREATE TABLE IF NOT EXISTS task_tags (
2298
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
2299
+ tag TEXT NOT NULL,
2300
+ PRIMARY KEY (task_id, tag)
2301
+ );
2302
+ CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags(tag);
2303
+ CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id);
2304
+
2305
+ INSERT OR IGNORE INTO _migrations (id) VALUES (3);
2306
+ `,
2307
+ `
2308
+ CREATE TABLE IF NOT EXISTS plans (
2309
+ id TEXT PRIMARY KEY,
2310
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
2311
+ name TEXT NOT NULL,
2312
+ description TEXT,
2313
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
2314
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2315
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2316
+ );
2317
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans(project_id);
2318
+ CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
2319
+ ALTER TABLE tasks ADD COLUMN plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL;
2320
+ CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id);
2321
+ INSERT OR IGNORE INTO _migrations (id) VALUES (4);
2218
2322
  `
2219
2323
  ];
2220
2324
  });
2221
2325
 
2222
2326
  // src/types/index.ts
2223
- var VersionConflictError, TaskNotFoundError, LockError, DependencyCycleError;
2327
+ var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, DependencyCycleError;
2224
2328
  var init_types = __esm(() => {
2225
2329
  VersionConflictError = class VersionConflictError extends Error {
2226
2330
  taskId;
@@ -2242,6 +2346,22 @@ var init_types = __esm(() => {
2242
2346
  this.name = "TaskNotFoundError";
2243
2347
  }
2244
2348
  };
2349
+ ProjectNotFoundError = class ProjectNotFoundError extends Error {
2350
+ projectId;
2351
+ constructor(projectId) {
2352
+ super(`Project not found: ${projectId}`);
2353
+ this.projectId = projectId;
2354
+ this.name = "ProjectNotFoundError";
2355
+ }
2356
+ };
2357
+ PlanNotFoundError = class PlanNotFoundError extends Error {
2358
+ planId;
2359
+ constructor(planId) {
2360
+ super(`Plan not found: ${planId}`);
2361
+ this.planId = planId;
2362
+ this.name = "PlanNotFoundError";
2363
+ }
2364
+ };
2245
2365
  LockError = class LockError extends Error {
2246
2366
  taskId;
2247
2367
  lockedBy;
@@ -2274,15 +2394,30 @@ function rowToTask(row) {
2274
2394
  priority: row.priority
2275
2395
  };
2276
2396
  }
2397
+ function insertTaskTags(taskId, tags, db) {
2398
+ if (tags.length === 0)
2399
+ return;
2400
+ const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
2401
+ for (const tag of tags) {
2402
+ if (tag)
2403
+ stmt.run(taskId, tag);
2404
+ }
2405
+ }
2406
+ function replaceTaskTags(taskId, tags, db) {
2407
+ db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
2408
+ insertTaskTags(taskId, tags, db);
2409
+ }
2277
2410
  function createTask(input, db) {
2278
2411
  const d = db || getDatabase();
2279
2412
  const id = uuid();
2280
2413
  const timestamp = now();
2281
- d.run(`INSERT INTO tasks (id, project_id, parent_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at)
2282
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
2414
+ const tags = input.tags || [];
2415
+ d.run(`INSERT INTO tasks (id, project_id, parent_id, plan_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at)
2416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
2283
2417
  id,
2284
2418
  input.project_id || null,
2285
2419
  input.parent_id || null,
2420
+ input.plan_id || null,
2286
2421
  input.title,
2287
2422
  input.description || null,
2288
2423
  input.status || "pending",
@@ -2291,11 +2426,14 @@ function createTask(input, db) {
2291
2426
  input.assigned_to || null,
2292
2427
  input.session_id || null,
2293
2428
  input.working_dir || null,
2294
- JSON.stringify(input.tags || []),
2429
+ JSON.stringify(tags),
2295
2430
  JSON.stringify(input.metadata || {}),
2296
2431
  timestamp,
2297
2432
  timestamp
2298
2433
  ]);
2434
+ if (tags.length > 0) {
2435
+ insertTaskTags(id, tags, d);
2436
+ }
2299
2437
  return getTask(id, d);
2300
2438
  }
2301
2439
  function getTask(id, db) {
@@ -2333,6 +2471,7 @@ function getTaskWithRelations(id, db) {
2333
2471
  }
2334
2472
  function listTasks(filter = {}, db) {
2335
2473
  const d = db || getDatabase();
2474
+ clearExpiredLocks(d);
2336
2475
  const conditions = [];
2337
2476
  const params = [];
2338
2477
  if (filter.project_id) {
@@ -2378,9 +2517,13 @@ function listTasks(filter = {}, db) {
2378
2517
  params.push(filter.session_id);
2379
2518
  }
2380
2519
  if (filter.tags && filter.tags.length > 0) {
2381
- const tagConditions = filter.tags.map(() => "tags LIKE ?");
2382
- conditions.push(`(${tagConditions.join(" OR ")})`);
2383
- params.push(...filter.tags.map((t) => `%"${t}"%`));
2520
+ const placeholders = filter.tags.map(() => "?").join(",");
2521
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
2522
+ params.push(...filter.tags);
2523
+ }
2524
+ if (filter.plan_id) {
2525
+ conditions.push("plan_id = ?");
2526
+ params.push(filter.plan_id);
2384
2527
  }
2385
2528
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2386
2529
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
@@ -2430,12 +2573,19 @@ function updateTask(id, input, db) {
2430
2573
  sets.push("metadata = ?");
2431
2574
  params.push(JSON.stringify(input.metadata));
2432
2575
  }
2576
+ if (input.plan_id !== undefined) {
2577
+ sets.push("plan_id = ?");
2578
+ params.push(input.plan_id);
2579
+ }
2433
2580
  params.push(id, input.version);
2434
2581
  const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
2435
2582
  if (result.changes === 0) {
2436
2583
  const current = getTask(id, d);
2437
2584
  throw new VersionConflictError(id, input.version, current?.version ?? -1);
2438
2585
  }
2586
+ if (input.tags !== undefined) {
2587
+ replaceTaskTags(id, input.tags, d);
2588
+ }
2439
2589
  return getTask(id, d);
2440
2590
  }
2441
2591
  function deleteTask(id, db) {
@@ -2445,15 +2595,18 @@ function deleteTask(id, db) {
2445
2595
  }
2446
2596
  function startTask(id, agentId, db) {
2447
2597
  const d = db || getDatabase();
2448
- const task = getTask(id, d);
2449
- if (!task)
2450
- throw new TaskNotFoundError(id);
2451
- if (task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
2452
- throw new LockError(id, task.locked_by);
2453
- }
2598
+ const cutoff = lockExpiryCutoff();
2454
2599
  const timestamp = now();
2455
- d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2456
- WHERE id = ?`, [agentId, agentId, timestamp, timestamp, id]);
2600
+ const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2601
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, id, agentId, cutoff]);
2602
+ if (result.changes === 0) {
2603
+ const current = getTask(id, d);
2604
+ if (!current)
2605
+ throw new TaskNotFoundError(id);
2606
+ if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
2607
+ throw new LockError(id, current.locked_by);
2608
+ }
2609
+ }
2457
2610
  return getTask(id, d);
2458
2611
  }
2459
2612
  function completeTask(id, agentId, db) {
@@ -2474,20 +2627,26 @@ function lockTask(id, agentId, db) {
2474
2627
  const task = getTask(id, d);
2475
2628
  if (!task)
2476
2629
  throw new TaskNotFoundError(id);
2477
- if (task.locked_by === agentId) {
2630
+ if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
2478
2631
  return { success: true, locked_by: agentId, locked_at: task.locked_at };
2479
2632
  }
2480
- if (task.locked_by && !isLockExpired(task.locked_at)) {
2481
- return {
2482
- success: false,
2483
- locked_by: task.locked_by,
2484
- locked_at: task.locked_at,
2485
- error: `Task is locked by ${task.locked_by}`
2486
- };
2487
- }
2633
+ const cutoff = lockExpiryCutoff();
2488
2634
  const timestamp = now();
2489
- d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2490
- WHERE id = ?`, [agentId, timestamp, timestamp, id]);
2635
+ const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2636
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
2637
+ if (result.changes === 0) {
2638
+ const current = getTask(id, d);
2639
+ if (!current)
2640
+ throw new TaskNotFoundError(id);
2641
+ if (current.locked_by && !isLockExpired(current.locked_at)) {
2642
+ return {
2643
+ success: false,
2644
+ locked_by: current.locked_by,
2645
+ locked_at: current.locked_at,
2646
+ error: `Task is locked by ${current.locked_by}`
2647
+ };
2648
+ }
2649
+ }
2491
2650
  return { success: true, locked_by: agentId, locked_at: timestamp };
2492
2651
  }
2493
2652
  function unlockTask(id, agentId, db) {
@@ -2542,12 +2701,16 @@ var init_tasks = __esm(() => {
2542
2701
  });
2543
2702
 
2544
2703
  // src/db/projects.ts
2704
+ function slugify(name) {
2705
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2706
+ }
2545
2707
  function createProject(input, db) {
2546
2708
  const d = db || getDatabase();
2547
2709
  const id = uuid();
2548
2710
  const timestamp = now();
2549
- d.run(`INSERT INTO projects (id, name, path, description, created_at, updated_at)
2550
- VALUES (?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, timestamp, timestamp]);
2711
+ const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
2712
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, created_at, updated_at)
2713
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, timestamp, timestamp]);
2551
2714
  return getProject(id, d);
2552
2715
  }
2553
2716
  function getProject(id, db) {
@@ -2564,6 +2727,34 @@ function listProjects(db) {
2564
2727
  const d = db || getDatabase();
2565
2728
  return d.query("SELECT * FROM projects ORDER BY name").all();
2566
2729
  }
2730
+ function updateProject(id, input, db) {
2731
+ const d = db || getDatabase();
2732
+ const project = getProject(id, d);
2733
+ if (!project)
2734
+ throw new ProjectNotFoundError(id);
2735
+ const sets = ["updated_at = ?"];
2736
+ const params = [now()];
2737
+ if (input.name !== undefined) {
2738
+ sets.push("name = ?");
2739
+ params.push(input.name);
2740
+ }
2741
+ if (input.description !== undefined) {
2742
+ sets.push("description = ?");
2743
+ params.push(input.description);
2744
+ }
2745
+ if (input.task_list_id !== undefined) {
2746
+ sets.push("task_list_id = ?");
2747
+ params.push(input.task_list_id);
2748
+ }
2749
+ params.push(id);
2750
+ d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
2751
+ return getProject(id, d);
2752
+ }
2753
+ function deleteProject(id, db) {
2754
+ const d = db || getDatabase();
2755
+ const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
2756
+ return result.changes > 0;
2757
+ }
2567
2758
  function ensureProject(name, path, db) {
2568
2759
  const d = db || getDatabase();
2569
2760
  const existing = getProjectByPath(path, d);
@@ -2576,6 +2767,68 @@ var init_projects = __esm(() => {
2576
2767
  init_database();
2577
2768
  });
2578
2769
 
2770
+ // src/db/plans.ts
2771
+ function createPlan(input, db) {
2772
+ const d = db || getDatabase();
2773
+ const id = uuid();
2774
+ const timestamp = now();
2775
+ d.run(`INSERT INTO plans (id, project_id, name, description, status, created_at, updated_at)
2776
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
2777
+ id,
2778
+ input.project_id || null,
2779
+ input.name,
2780
+ input.description || null,
2781
+ input.status || "active",
2782
+ timestamp,
2783
+ timestamp
2784
+ ]);
2785
+ return getPlan(id, d);
2786
+ }
2787
+ function getPlan(id, db) {
2788
+ const d = db || getDatabase();
2789
+ const row = d.query("SELECT * FROM plans WHERE id = ?").get(id);
2790
+ return row;
2791
+ }
2792
+ function listPlans(projectId, db) {
2793
+ const d = db || getDatabase();
2794
+ if (projectId) {
2795
+ return d.query("SELECT * FROM plans WHERE project_id = ? ORDER BY created_at DESC").all(projectId);
2796
+ }
2797
+ return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
2798
+ }
2799
+ function updatePlan(id, input, db) {
2800
+ const d = db || getDatabase();
2801
+ const plan = getPlan(id, d);
2802
+ if (!plan)
2803
+ throw new PlanNotFoundError(id);
2804
+ const sets = ["updated_at = ?"];
2805
+ const params = [now()];
2806
+ if (input.name !== undefined) {
2807
+ sets.push("name = ?");
2808
+ params.push(input.name);
2809
+ }
2810
+ if (input.description !== undefined) {
2811
+ sets.push("description = ?");
2812
+ params.push(input.description);
2813
+ }
2814
+ if (input.status !== undefined) {
2815
+ sets.push("status = ?");
2816
+ params.push(input.status);
2817
+ }
2818
+ params.push(id);
2819
+ d.run(`UPDATE plans SET ${sets.join(", ")} WHERE id = ?`, params);
2820
+ return getPlan(id, d);
2821
+ }
2822
+ function deletePlan(id, db) {
2823
+ const d = db || getDatabase();
2824
+ const result = d.run("DELETE FROM plans WHERE id = ?", [id]);
2825
+ return result.changes > 0;
2826
+ }
2827
+ var init_plans = __esm(() => {
2828
+ init_types();
2829
+ init_database();
2830
+ });
2831
+
2579
2832
  // src/db/comments.ts
2580
2833
  function addComment(input, db) {
2581
2834
  const d = db || getDatabase();
@@ -2621,8 +2874,9 @@ function rowToTask2(row) {
2621
2874
  }
2622
2875
  function searchTasks(query, projectId, db) {
2623
2876
  const d = db || getDatabase();
2877
+ clearExpiredLocks(d);
2624
2878
  const pattern = `%${query}%`;
2625
- let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)`;
2879
+ let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = tasks.id AND tag LIKE ?))`;
2626
2880
  const params = [pattern, pattern, pattern];
2627
2881
  if (projectId) {
2628
2882
  sql += " AND project_id = ?";
@@ -2638,11 +2892,28 @@ var init_search = __esm(() => {
2638
2892
  init_database();
2639
2893
  });
2640
2894
 
2641
- // src/lib/claude-tasks.ts
2642
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, writeFileSync } from "fs";
2895
+ // src/lib/sync-utils.ts
2896
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync as readdirSync2, statSync, writeFileSync } from "fs";
2643
2897
  import { join as join2 } from "path";
2644
- function getTaskListDir(taskListId) {
2645
- return join2(HOME, ".claude", "tasks", taskListId);
2898
+ function ensureDir2(dir) {
2899
+ if (!existsSync2(dir))
2900
+ mkdirSync2(dir, { recursive: true });
2901
+ }
2902
+ function listJsonFiles(dir) {
2903
+ if (!existsSync2(dir))
2904
+ return [];
2905
+ return readdirSync2(dir).filter((f) => f.endsWith(".json"));
2906
+ }
2907
+ function readJsonFile(path) {
2908
+ try {
2909
+ return JSON.parse(readFileSync(path, "utf-8"));
2910
+ } catch {
2911
+ return null;
2912
+ }
2913
+ }
2914
+ function writeJsonFile(path, data) {
2915
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
2916
+ `);
2646
2917
  }
2647
2918
  function readHighWaterMark(dir) {
2648
2919
  const path = join2(dir, ".highwatermark");
@@ -2654,17 +2925,40 @@ function readHighWaterMark(dir) {
2654
2925
  function writeHighWaterMark(dir, value) {
2655
2926
  writeFileSync(join2(dir, ".highwatermark"), String(value));
2656
2927
  }
2657
- function readClaudeTask(dir, filename) {
2928
+ function getFileMtimeMs(path) {
2658
2929
  try {
2659
- const content = readFileSync(join2(dir, filename), "utf-8");
2660
- return JSON.parse(content);
2930
+ return statSync(path).mtimeMs;
2661
2931
  } catch {
2662
2932
  return null;
2663
2933
  }
2664
2934
  }
2935
+ function parseTimestamp(value) {
2936
+ if (typeof value !== "string")
2937
+ return null;
2938
+ const parsed = Date.parse(value);
2939
+ return Number.isNaN(parsed) ? null : parsed;
2940
+ }
2941
+ function appendSyncConflict(metadata, conflict, limit = 5) {
2942
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
2943
+ const next = [conflict, ...current].slice(0, limit);
2944
+ return { ...metadata, sync_conflicts: next };
2945
+ }
2946
+ var HOME;
2947
+ var init_sync_utils = __esm(() => {
2948
+ HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
2949
+ });
2950
+
2951
+ // src/lib/claude-tasks.ts
2952
+ import { existsSync as existsSync3 } from "fs";
2953
+ import { join as join3 } from "path";
2954
+ function getTaskListDir(taskListId) {
2955
+ return join3(HOME, ".claude", "tasks", taskListId);
2956
+ }
2957
+ function readClaudeTask(dir, filename) {
2958
+ return readJsonFile(join3(dir, filename));
2959
+ }
2665
2960
  function writeClaudeTask(dir, task) {
2666
- writeFileSync(join2(dir, `${task.id}.json`), JSON.stringify(task, null, 2) + `
2667
- `);
2961
+ writeJsonFile(join3(dir, `${task.id}.json`), task);
2668
2962
  }
2669
2963
  function toClaudeStatus(status) {
2670
2964
  if (status === "pending" || status === "in_progress" || status === "completed") {
@@ -2675,7 +2969,7 @@ function toClaudeStatus(status) {
2675
2969
  function toSqliteStatus(status) {
2676
2970
  return status;
2677
2971
  }
2678
- function taskToClaudeTask(task, claudeTaskId) {
2972
+ function taskToClaudeTask(task, claudeTaskId, existingMeta) {
2679
2973
  return {
2680
2974
  id: claudeTaskId,
2681
2975
  subject: task.title,
@@ -2686,39 +2980,80 @@ function taskToClaudeTask(task, claudeTaskId) {
2686
2980
  blocks: [],
2687
2981
  blockedBy: [],
2688
2982
  metadata: {
2983
+ ...existingMeta || {},
2689
2984
  todos_id: task.id,
2690
- priority: task.priority
2985
+ priority: task.priority,
2986
+ todos_updated_at: task.updated_at,
2987
+ todos_version: task.version
2691
2988
  }
2692
2989
  };
2693
2990
  }
2694
- function pushToClaudeTaskList(taskListId, projectId) {
2991
+ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
2695
2992
  const dir = getTaskListDir(taskListId);
2696
- if (!existsSync2(dir))
2697
- mkdirSync2(dir, { recursive: true });
2993
+ if (!existsSync3(dir))
2994
+ ensureDir2(dir);
2698
2995
  const filter = {};
2699
2996
  if (projectId)
2700
2997
  filter["project_id"] = projectId;
2701
2998
  const tasks = listTasks(filter);
2702
2999
  const existingByTodosId = new Map;
2703
- const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
3000
+ const files = listJsonFiles(dir);
2704
3001
  for (const f of files) {
3002
+ const path = join3(dir, f);
2705
3003
  const ct = readClaudeTask(dir, f);
2706
3004
  if (ct?.metadata?.["todos_id"]) {
2707
- existingByTodosId.set(ct.metadata["todos_id"], ct);
3005
+ existingByTodosId.set(ct.metadata["todos_id"], { task: ct, mtimeMs: getFileMtimeMs(path) });
2708
3006
  }
2709
3007
  }
2710
3008
  let hwm = readHighWaterMark(dir);
2711
3009
  let pushed = 0;
2712
3010
  const errors = [];
3011
+ const prefer = options.prefer || "remote";
2713
3012
  for (const task of tasks) {
2714
3013
  try {
2715
3014
  const existing = existingByTodosId.get(task.id);
2716
3015
  if (existing) {
2717
- const updated = taskToClaudeTask(task, existing.id);
2718
- updated.blocks = existing.blocks;
2719
- updated.blockedBy = existing.blockedBy;
2720
- updated.activeForm = existing.activeForm;
3016
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
3017
+ const localUpdatedAt = parseTimestamp(task.updated_at);
3018
+ const remoteUpdatedAt = existing.mtimeMs;
3019
+ let recordConflict = false;
3020
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3021
+ if (prefer === "remote") {
3022
+ const conflict = {
3023
+ agent: "claude",
3024
+ direction: "push",
3025
+ prefer,
3026
+ local_updated_at: task.updated_at,
3027
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3028
+ detected_at: new Date().toISOString()
3029
+ };
3030
+ const newMeta = appendSyncConflict(task.metadata, conflict);
3031
+ updateTask(task.id, { version: task.version, metadata: newMeta });
3032
+ errors.push(`conflict push ${task.id}: remote newer`);
3033
+ continue;
3034
+ }
3035
+ recordConflict = true;
3036
+ }
3037
+ const updated = taskToClaudeTask(task, existing.task.id, existing.task.metadata);
3038
+ updated.blocks = existing.task.blocks;
3039
+ updated.blockedBy = existing.task.blockedBy;
3040
+ updated.activeForm = existing.task.activeForm;
2721
3041
  writeClaudeTask(dir, updated);
3042
+ if (recordConflict) {
3043
+ const latest = getTask(task.id);
3044
+ if (latest) {
3045
+ const conflict = {
3046
+ agent: "claude",
3047
+ direction: "push",
3048
+ prefer,
3049
+ local_updated_at: latest.updated_at,
3050
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
3051
+ detected_at: new Date().toISOString()
3052
+ };
3053
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
3054
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
3055
+ }
3056
+ }
2722
3057
  } else {
2723
3058
  const claudeId = String(hwm);
2724
3059
  hwm++;
@@ -2738,14 +3073,15 @@ function pushToClaudeTaskList(taskListId, projectId) {
2738
3073
  writeHighWaterMark(dir, hwm);
2739
3074
  return { pushed, pulled: 0, errors };
2740
3075
  }
2741
- function pullFromClaudeTaskList(taskListId, projectId) {
3076
+ function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
2742
3077
  const dir = getTaskListDir(taskListId);
2743
- if (!existsSync2(dir)) {
3078
+ if (!existsSync3(dir)) {
2744
3079
  return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
2745
3080
  }
2746
3081
  const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
2747
3082
  let pulled = 0;
2748
3083
  const errors = [];
3084
+ const prefer = options.prefer || "remote";
2749
3085
  const allTasks = listTasks({});
2750
3086
  const byClaudeId = new Map;
2751
3087
  for (const t of allTasks) {
@@ -2759,6 +3095,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2759
3095
  }
2760
3096
  for (const f of files) {
2761
3097
  try {
3098
+ const filePath = join3(dir, f);
2762
3099
  const ct = readClaudeTask(dir, f);
2763
3100
  if (!ct)
2764
3101
  continue;
@@ -2769,13 +3106,33 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2769
3106
  const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
2770
3107
  const existing = existingByMapping || existingByTodos;
2771
3108
  if (existing) {
3109
+ const lastSyncedAt = parseTimestamp(ct.metadata?.["todos_updated_at"]);
3110
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
3111
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
3112
+ let conflictMeta = null;
3113
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3114
+ const conflict = {
3115
+ agent: "claude",
3116
+ direction: "pull",
3117
+ prefer,
3118
+ local_updated_at: existing.updated_at,
3119
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3120
+ detected_at: new Date().toISOString()
3121
+ };
3122
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
3123
+ if (prefer === "local") {
3124
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
3125
+ errors.push(`conflict pull ${existing.id}: local newer`);
3126
+ continue;
3127
+ }
3128
+ }
2772
3129
  updateTask(existing.id, {
2773
3130
  version: existing.version,
2774
3131
  title: ct.subject,
2775
3132
  description: ct.description || undefined,
2776
3133
  status: toSqliteStatus(ct.status),
2777
3134
  assigned_to: ct.owner || undefined,
2778
- metadata: { ...existing.metadata, claude_task_id: ct.id }
3135
+ metadata: { ...conflictMeta || existing.metadata, claude_task_id: ct.id, ...ct.metadata }
2779
3136
  });
2780
3137
  } else {
2781
3138
  createTask({
@@ -2784,7 +3141,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2784
3141
  status: toSqliteStatus(ct.status),
2785
3142
  assigned_to: ct.owner || undefined,
2786
3143
  project_id: projectId,
2787
- metadata: { claude_task_id: ct.id },
3144
+ metadata: { ...ct.metadata, claude_task_id: ct.id },
2788
3145
  priority: ct.metadata?.["priority"] || "medium"
2789
3146
  });
2790
3147
  }
@@ -2795,19 +3152,349 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2795
3152
  }
2796
3153
  return { pushed: 0, pulled, errors };
2797
3154
  }
2798
- function syncClaudeTaskList(taskListId, projectId) {
2799
- const pullResult = pullFromClaudeTaskList(taskListId, projectId);
2800
- const pushResult = pushToClaudeTaskList(taskListId, projectId);
3155
+ function syncClaudeTaskList(taskListId, projectId, options = {}) {
3156
+ const pullResult = pullFromClaudeTaskList(taskListId, projectId, options);
3157
+ const pushResult = pushToClaudeTaskList(taskListId, projectId, options);
2801
3158
  return {
2802
3159
  pushed: pushResult.pushed,
2803
3160
  pulled: pullResult.pulled,
2804
3161
  errors: [...pullResult.errors, ...pushResult.errors]
2805
3162
  };
2806
3163
  }
2807
- var HOME;
2808
3164
  var init_claude_tasks = __esm(() => {
2809
3165
  init_tasks();
2810
- HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
3166
+ init_sync_utils();
3167
+ });
3168
+
3169
+ // src/lib/config.ts
3170
+ import { existsSync as existsSync4 } from "fs";
3171
+ import { join as join4 } from "path";
3172
+ function normalizeAgent(agent) {
3173
+ return agent.trim().toLowerCase();
3174
+ }
3175
+ function loadConfig() {
3176
+ if (cached)
3177
+ return cached;
3178
+ if (!existsSync4(CONFIG_PATH)) {
3179
+ cached = {};
3180
+ return cached;
3181
+ }
3182
+ const config = readJsonFile(CONFIG_PATH) || {};
3183
+ if (typeof config.sync_agents === "string") {
3184
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
3185
+ }
3186
+ cached = config;
3187
+ return cached;
3188
+ }
3189
+ function getSyncAgentsFromConfig() {
3190
+ const config = loadConfig();
3191
+ const agents = config.sync_agents;
3192
+ if (Array.isArray(agents) && agents.length > 0)
3193
+ return agents.map(normalizeAgent);
3194
+ return null;
3195
+ }
3196
+ function getAgentTaskListId(agent) {
3197
+ const config = loadConfig();
3198
+ const key = normalizeAgent(agent);
3199
+ return config.agents?.[key]?.task_list_id || config.task_list_id || null;
3200
+ }
3201
+ function getAgentTasksDir(agent) {
3202
+ const config = loadConfig();
3203
+ const key = normalizeAgent(agent);
3204
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
3205
+ }
3206
+ var CONFIG_PATH, cached = null;
3207
+ var init_config = __esm(() => {
3208
+ init_sync_utils();
3209
+ CONFIG_PATH = join4(HOME, ".todos", "config.json");
3210
+ });
3211
+
3212
+ // src/lib/agent-tasks.ts
3213
+ import { existsSync as existsSync5 } from "fs";
3214
+ import { join as join5 } from "path";
3215
+ function agentBaseDir(agent) {
3216
+ const key = `TODOS_${agent.toUpperCase()}_TASKS_DIR`;
3217
+ return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join5(HOME, ".todos", "agents");
3218
+ }
3219
+ function getTaskListDir2(agent, taskListId) {
3220
+ return join5(agentBaseDir(agent), agent, taskListId);
3221
+ }
3222
+ function readAgentTask(dir, filename) {
3223
+ return readJsonFile(join5(dir, filename));
3224
+ }
3225
+ function writeAgentTask(dir, task) {
3226
+ writeJsonFile(join5(dir, `${task.id}.json`), task);
3227
+ }
3228
+ function taskToAgentTask(task, externalId, existingMeta) {
3229
+ return {
3230
+ id: externalId,
3231
+ title: task.title,
3232
+ description: task.description || "",
3233
+ status: task.status,
3234
+ priority: task.priority,
3235
+ assigned_to: task.assigned_to || task.agent_id || "",
3236
+ tags: task.tags || [],
3237
+ metadata: {
3238
+ ...existingMeta || {},
3239
+ ...task.metadata,
3240
+ todos_id: task.id,
3241
+ todos_updated_at: task.updated_at,
3242
+ todos_version: task.version
3243
+ }
3244
+ };
3245
+ }
3246
+ function metadataKey(agent) {
3247
+ return `${agent}_task_id`;
3248
+ }
3249
+ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
3250
+ const dir = getTaskListDir2(agent, taskListId);
3251
+ if (!existsSync5(dir))
3252
+ ensureDir2(dir);
3253
+ const filter = {};
3254
+ if (projectId)
3255
+ filter["project_id"] = projectId;
3256
+ const tasks = listTasks(filter);
3257
+ const existingByTodosId = new Map;
3258
+ const files = listJsonFiles(dir);
3259
+ for (const f of files) {
3260
+ const path = join5(dir, f);
3261
+ const at = readAgentTask(dir, f);
3262
+ if (at?.metadata?.["todos_id"]) {
3263
+ existingByTodosId.set(at.metadata["todos_id"], { task: at, mtimeMs: getFileMtimeMs(path) });
3264
+ }
3265
+ }
3266
+ let hwm = readHighWaterMark(dir);
3267
+ let pushed = 0;
3268
+ const errors = [];
3269
+ const metaKey = metadataKey(agent);
3270
+ const prefer = options.prefer || "remote";
3271
+ for (const task of tasks) {
3272
+ try {
3273
+ const existing = existingByTodosId.get(task.id);
3274
+ if (existing) {
3275
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
3276
+ const localUpdatedAt = parseTimestamp(task.updated_at);
3277
+ const remoteUpdatedAt = existing.mtimeMs;
3278
+ let recordConflict = false;
3279
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3280
+ if (prefer === "remote") {
3281
+ const conflict = {
3282
+ agent,
3283
+ direction: "push",
3284
+ prefer,
3285
+ local_updated_at: task.updated_at,
3286
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3287
+ detected_at: new Date().toISOString()
3288
+ };
3289
+ const newMeta = appendSyncConflict(task.metadata, conflict);
3290
+ updateTask(task.id, { version: task.version, metadata: newMeta });
3291
+ errors.push(`conflict push ${task.id}: remote newer`);
3292
+ continue;
3293
+ }
3294
+ recordConflict = true;
3295
+ }
3296
+ const updated = taskToAgentTask(task, existing.task.id, existing.task.metadata);
3297
+ writeAgentTask(dir, updated);
3298
+ if (recordConflict) {
3299
+ const latest = getTask(task.id);
3300
+ if (latest) {
3301
+ const conflict = {
3302
+ agent,
3303
+ direction: "push",
3304
+ prefer,
3305
+ local_updated_at: latest.updated_at,
3306
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
3307
+ detected_at: new Date().toISOString()
3308
+ };
3309
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
3310
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
3311
+ }
3312
+ }
3313
+ } else {
3314
+ const externalId = String(hwm);
3315
+ hwm++;
3316
+ const at = taskToAgentTask(task, externalId);
3317
+ writeAgentTask(dir, at);
3318
+ const current = getTask(task.id);
3319
+ if (current) {
3320
+ const newMeta = { ...current.metadata, [metaKey]: externalId };
3321
+ updateTask(task.id, { version: current.version, metadata: newMeta });
3322
+ }
3323
+ }
3324
+ pushed++;
3325
+ } catch (e) {
3326
+ errors.push(`push ${task.id}: ${e instanceof Error ? e.message : String(e)}`);
3327
+ }
3328
+ }
3329
+ writeHighWaterMark(dir, hwm);
3330
+ return { pushed, pulled: 0, errors };
3331
+ }
3332
+ function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
3333
+ const dir = getTaskListDir2(agent, taskListId);
3334
+ if (!existsSync5(dir)) {
3335
+ return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
3336
+ }
3337
+ const files = listJsonFiles(dir);
3338
+ let pulled = 0;
3339
+ const errors = [];
3340
+ const metaKey = metadataKey(agent);
3341
+ const prefer = options.prefer || "remote";
3342
+ const allTasks = listTasks({});
3343
+ const byExternalId = new Map;
3344
+ const byTodosId = new Map;
3345
+ for (const t of allTasks) {
3346
+ const extId = t.metadata[metaKey];
3347
+ if (extId)
3348
+ byExternalId.set(String(extId), t);
3349
+ byTodosId.set(t.id, t);
3350
+ }
3351
+ for (const f of files) {
3352
+ try {
3353
+ const filePath = join5(dir, f);
3354
+ const at = readAgentTask(dir, f);
3355
+ if (!at)
3356
+ continue;
3357
+ if (at.metadata?.["_internal"])
3358
+ continue;
3359
+ const todosId = at.metadata?.["todos_id"];
3360
+ const existingByMapping = byExternalId.get(at.id);
3361
+ const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
3362
+ const existing = existingByMapping || existingByTodos;
3363
+ if (existing) {
3364
+ const lastSyncedAt = parseTimestamp(at.metadata?.["todos_updated_at"]);
3365
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
3366
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
3367
+ let conflictMeta = null;
3368
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3369
+ const conflict = {
3370
+ agent,
3371
+ direction: "pull",
3372
+ prefer,
3373
+ local_updated_at: existing.updated_at,
3374
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3375
+ detected_at: new Date().toISOString()
3376
+ };
3377
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
3378
+ if (prefer === "local") {
3379
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
3380
+ errors.push(`conflict pull ${existing.id}: local newer`);
3381
+ continue;
3382
+ }
3383
+ }
3384
+ updateTask(existing.id, {
3385
+ version: existing.version,
3386
+ title: at.title,
3387
+ description: at.description || undefined,
3388
+ status: at.status,
3389
+ priority: at.priority,
3390
+ assigned_to: at.assigned_to || undefined,
3391
+ tags: at.tags || [],
3392
+ metadata: { ...conflictMeta || existing.metadata, ...at.metadata, [metaKey]: at.id }
3393
+ });
3394
+ } else {
3395
+ createTask({
3396
+ title: at.title,
3397
+ description: at.description || undefined,
3398
+ status: at.status,
3399
+ priority: at.priority || "medium",
3400
+ assigned_to: at.assigned_to || undefined,
3401
+ tags: at.tags || [],
3402
+ project_id: projectId,
3403
+ metadata: { ...at.metadata, [metaKey]: at.id }
3404
+ });
3405
+ }
3406
+ pulled++;
3407
+ } catch (e) {
3408
+ errors.push(`pull ${f}: ${e instanceof Error ? e.message : String(e)}`);
3409
+ }
3410
+ }
3411
+ return { pushed: 0, pulled, errors };
3412
+ }
3413
+ function syncAgentTaskList(agent, taskListId, projectId, options = {}) {
3414
+ const pullResult = pullFromAgentTaskList(agent, taskListId, projectId, options);
3415
+ const pushResult = pushToAgentTaskList(agent, taskListId, projectId, options);
3416
+ return {
3417
+ pushed: pushResult.pushed,
3418
+ pulled: pullResult.pulled,
3419
+ errors: [...pullResult.errors, ...pushResult.errors]
3420
+ };
3421
+ }
3422
+ var init_agent_tasks = __esm(() => {
3423
+ init_tasks();
3424
+ init_sync_utils();
3425
+ init_config();
3426
+ });
3427
+
3428
+ // src/lib/sync.ts
3429
+ function normalizeAgent2(agent) {
3430
+ return agent.trim().toLowerCase();
3431
+ }
3432
+ function isClaudeAgent(agent) {
3433
+ const a = normalizeAgent2(agent);
3434
+ return a === "claude" || a === "claude-code" || a === "claude_code";
3435
+ }
3436
+ function defaultSyncAgents() {
3437
+ const env = process.env["TODOS_SYNC_AGENTS"];
3438
+ if (env) {
3439
+ return env.split(",").map((a) => a.trim()).filter(Boolean);
3440
+ }
3441
+ const fromConfig = getSyncAgentsFromConfig();
3442
+ if (fromConfig && fromConfig.length > 0)
3443
+ return fromConfig;
3444
+ return ["claude", "codex", "gemini"];
3445
+ }
3446
+ function syncWithAgent(agent, taskListId, projectId, direction = "both", options = {}) {
3447
+ const normalized = normalizeAgent2(agent);
3448
+ if (isClaudeAgent(normalized)) {
3449
+ if (direction === "push")
3450
+ return pushToClaudeTaskList(taskListId, projectId, options);
3451
+ if (direction === "pull")
3452
+ return pullFromClaudeTaskList(taskListId, projectId, options);
3453
+ return syncClaudeTaskList(taskListId, projectId, options);
3454
+ }
3455
+ if (direction === "push")
3456
+ return pushToAgentTaskList(normalized, taskListId, projectId, options);
3457
+ if (direction === "pull")
3458
+ return pullFromAgentTaskList(normalized, taskListId, projectId, options);
3459
+ return syncAgentTaskList(normalized, taskListId, projectId, options);
3460
+ }
3461
+ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both", options = {}) {
3462
+ let pushed = 0;
3463
+ let pulled = 0;
3464
+ const errors = [];
3465
+ const normalized = agents.map(normalizeAgent2);
3466
+ if (direction === "pull" || direction === "both") {
3467
+ for (const agent of normalized) {
3468
+ const listId = taskListIdByAgent(agent);
3469
+ if (!listId) {
3470
+ errors.push(`sync ${agent}: missing task list id`);
3471
+ continue;
3472
+ }
3473
+ const result = syncWithAgent(agent, listId, projectId, "pull", options);
3474
+ pushed += result.pushed;
3475
+ pulled += result.pulled;
3476
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
3477
+ }
3478
+ }
3479
+ if (direction === "push" || direction === "both") {
3480
+ for (const agent of normalized) {
3481
+ const listId = taskListIdByAgent(agent);
3482
+ if (!listId) {
3483
+ errors.push(`sync ${agent}: missing task list id`);
3484
+ continue;
3485
+ }
3486
+ const result = syncWithAgent(agent, listId, projectId, "push", options);
3487
+ pushed += result.pushed;
3488
+ pulled += result.pulled;
3489
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
3490
+ }
3491
+ }
3492
+ return { pushed, pulled, errors };
3493
+ }
3494
+ var init_sync = __esm(() => {
3495
+ init_claude_tasks();
3496
+ init_agent_tasks();
3497
+ init_config();
2811
3498
  });
2812
3499
 
2813
3500
  // node_modules/zod/v3/helpers/util.js
@@ -6785,6 +7472,8 @@ function formatError(error) {
6785
7472
  return `Version conflict: ${error.message}`;
6786
7473
  if (error instanceof TaskNotFoundError)
6787
7474
  return `Not found: ${error.message}`;
7475
+ if (error instanceof PlanNotFoundError)
7476
+ return `Not found: ${error.message}`;
6788
7477
  if (error instanceof LockError)
6789
7478
  return `Lock error: ${error.message}`;
6790
7479
  if (error instanceof DependencyCycleError)
@@ -6800,6 +7489,16 @@ function resolveId(partialId, table = "tasks") {
6800
7489
  throw new Error(`Could not resolve ID: ${partialId}`);
6801
7490
  return id;
6802
7491
  }
7492
+ function resolveTaskListId(agent, explicit) {
7493
+ if (explicit)
7494
+ return explicit;
7495
+ const normalized = agent.trim().toLowerCase();
7496
+ if (normalized === "claude" || normalized === "claude-code" || normalized === "claude_code") {
7497
+ return process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || getAgentTaskListId(normalized) || null;
7498
+ }
7499
+ const key = `TODOS_${normalized.toUpperCase()}_TASK_LIST`;
7500
+ return process.env[key] || process.env["TODOS_TASK_LIST_ID"] || getAgentTaskListId(normalized) || "default";
7501
+ }
6803
7502
  function formatTask(task) {
6804
7503
  const parts = [
6805
7504
  `ID: ${task.id}`,
@@ -6819,6 +7518,8 @@ function formatTask(task) {
6819
7518
  parts.push(`Parent: ${task.parent_id}`);
6820
7519
  if (task.project_id)
6821
7520
  parts.push(`Project: ${task.project_id}`);
7521
+ if (task.plan_id)
7522
+ parts.push(`Plan: ${task.plan_id}`);
6822
7523
  if (task.tags.length > 0)
6823
7524
  parts.push(`Tags: ${task.tags.join(", ")}`);
6824
7525
  parts.push(`Version: ${task.version}`);
@@ -6838,8 +7539,10 @@ var init_mcp = __esm(() => {
6838
7539
  init_tasks();
6839
7540
  init_comments();
6840
7541
  init_projects();
7542
+ init_plans();
6841
7543
  init_search();
6842
- init_claude_tasks();
7544
+ init_sync();
7545
+ init_config();
6843
7546
  init_database();
6844
7547
  init_types();
6845
7548
  server = new McpServer({
@@ -6857,6 +7560,7 @@ var init_mcp = __esm(() => {
6857
7560
  assigned_to: exports_external.string().optional().describe("Assigned agent ID"),
6858
7561
  session_id: exports_external.string().optional().describe("Session ID"),
6859
7562
  working_dir: exports_external.string().optional().describe("Working directory context"),
7563
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to"),
6860
7564
  tags: exports_external.array(exports_external.string()).optional().describe("Task tags"),
6861
7565
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Arbitrary metadata")
6862
7566
  }, async (params) => {
@@ -6866,6 +7570,8 @@ var init_mcp = __esm(() => {
6866
7570
  resolved.project_id = resolveId(resolved.project_id, "projects");
6867
7571
  if (resolved.parent_id)
6868
7572
  resolved.parent_id = resolveId(resolved.parent_id);
7573
+ if (resolved.plan_id)
7574
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
6869
7575
  const task = createTask(resolved);
6870
7576
  return { content: [{ type: "text", text: `Task created:
6871
7577
  ${formatTask(task)}` }] };
@@ -6884,12 +7590,15 @@ ${formatTask(task)}` }] };
6884
7590
  exports_external.array(exports_external.enum(["low", "medium", "high", "critical"]))
6885
7591
  ]).optional().describe("Filter by priority"),
6886
7592
  assigned_to: exports_external.string().optional().describe("Filter by assigned agent"),
6887
- tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)")
7593
+ tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)"),
7594
+ plan_id: exports_external.string().optional().describe("Filter by plan")
6888
7595
  }, async (params) => {
6889
7596
  try {
6890
7597
  const resolved = { ...params };
6891
7598
  if (resolved.project_id)
6892
7599
  resolved.project_id = resolveId(resolved.project_id, "projects");
7600
+ if (resolved.plan_id)
7601
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
6893
7602
  const tasks = listTasks(resolved);
6894
7603
  if (tasks.length === 0) {
6895
7604
  return { content: [{ type: "text", text: "No tasks found." }] };
@@ -6963,7 +7672,8 @@ Parent: ${task.parent.id.slice(0, 8)} | ${task.parent.title}`);
6963
7672
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("New priority"),
6964
7673
  assigned_to: exports_external.string().optional().describe("Assign to agent"),
6965
7674
  tags: exports_external.array(exports_external.string()).optional().describe("New tags"),
6966
- metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata")
7675
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata"),
7676
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to")
6967
7677
  }, async ({ id, ...rest }) => {
6968
7678
  try {
6969
7679
  const resolvedId = resolveId(id);
@@ -7074,45 +7784,151 @@ ${formatTask(task)}` }] };
7074
7784
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7075
7785
  }
7076
7786
  });
7077
- server.tool("add_comment", "Add a comment/note to a task", {
7078
- task_id: exports_external.string().describe("Task ID (full or partial)"),
7079
- content: exports_external.string().describe("Comment content"),
7080
- agent_id: exports_external.string().optional().describe("Agent adding comment"),
7081
- session_id: exports_external.string().optional().describe("Session ID")
7082
- }, async ({ task_id, ...rest }) => {
7787
+ server.tool("add_comment", "Add a comment/note to a task", {
7788
+ task_id: exports_external.string().describe("Task ID (full or partial)"),
7789
+ content: exports_external.string().describe("Comment content"),
7790
+ agent_id: exports_external.string().optional().describe("Agent adding comment"),
7791
+ session_id: exports_external.string().optional().describe("Session ID")
7792
+ }, async ({ task_id, ...rest }) => {
7793
+ try {
7794
+ const resolvedId = resolveId(task_id);
7795
+ const comment = addComment({ task_id: resolvedId, ...rest });
7796
+ return { content: [{ type: "text", text: `Comment added (${comment.id.slice(0, 8)}) at ${comment.created_at}` }] };
7797
+ } catch (e) {
7798
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7799
+ }
7800
+ });
7801
+ server.tool("list_projects", "List all registered projects", {}, async () => {
7802
+ try {
7803
+ const projects = listProjects();
7804
+ if (projects.length === 0) {
7805
+ return { content: [{ type: "text", text: "No projects registered." }] };
7806
+ }
7807
+ const text = projects.map((p) => {
7808
+ const taskList = p.task_list_id ? ` [${p.task_list_id}]` : "";
7809
+ return `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${taskList}${p.description ? ` - ${p.description}` : ""}`;
7810
+ }).join(`
7811
+ `);
7812
+ return { content: [{ type: "text", text: `${projects.length} project(s):
7813
+ ${text}` }] };
7814
+ } catch (e) {
7815
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7816
+ }
7817
+ });
7818
+ server.tool("create_project", "Register a new project", {
7819
+ name: exports_external.string().describe("Project name"),
7820
+ path: exports_external.string().describe("Absolute path to project"),
7821
+ description: exports_external.string().optional().describe("Project description"),
7822
+ task_list_id: exports_external.string().optional().describe("Custom task list ID for Claude Code sync (defaults to todos-<slugified-name>)")
7823
+ }, async (params) => {
7824
+ try {
7825
+ const project = createProject(params);
7826
+ const taskList = project.task_list_id ? ` [${project.task_list_id}]` : "";
7827
+ return {
7828
+ content: [{
7829
+ type: "text",
7830
+ text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}${taskList}`
7831
+ }]
7832
+ };
7833
+ } catch (e) {
7834
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7835
+ }
7836
+ });
7837
+ server.tool("create_plan", "Create a new plan", {
7838
+ name: exports_external.string().describe("Plan name"),
7839
+ project_id: exports_external.string().optional().describe("Project ID"),
7840
+ description: exports_external.string().optional().describe("Plan description"),
7841
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("Plan status")
7842
+ }, async (params) => {
7843
+ try {
7844
+ const resolved = { ...params };
7845
+ if (resolved.project_id)
7846
+ resolved.project_id = resolveId(resolved.project_id, "projects");
7847
+ const plan = createPlan(resolved);
7848
+ return {
7849
+ content: [{
7850
+ type: "text",
7851
+ text: `Plan created: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
7852
+ }]
7853
+ };
7854
+ } catch (e) {
7855
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7856
+ }
7857
+ });
7858
+ server.tool("list_plans", "List plans with optional project filter", {
7859
+ project_id: exports_external.string().optional().describe("Filter by project")
7860
+ }, async ({ project_id }) => {
7861
+ try {
7862
+ const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
7863
+ const plans = listPlans(resolvedProjectId);
7864
+ if (plans.length === 0) {
7865
+ return { content: [{ type: "text", text: "No plans found." }] };
7866
+ }
7867
+ const text = plans.map((p) => {
7868
+ const project = p.project_id ? ` (project: ${p.project_id.slice(0, 8)})` : "";
7869
+ return `[${p.status}] ${p.id.slice(0, 8)} | ${p.name}${project}`;
7870
+ }).join(`
7871
+ `);
7872
+ return { content: [{ type: "text", text: `${plans.length} plan(s):
7873
+ ${text}` }] };
7874
+ } catch (e) {
7875
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7876
+ }
7877
+ });
7878
+ server.tool("get_plan", "Get plan details", {
7879
+ id: exports_external.string().describe("Plan ID (full or partial)")
7880
+ }, async ({ id }) => {
7083
7881
  try {
7084
- const resolvedId = resolveId(task_id);
7085
- const comment = addComment({ task_id: resolvedId, ...rest });
7086
- return { content: [{ type: "text", text: `Comment added (${comment.id.slice(0, 8)}) at ${comment.created_at}` }] };
7882
+ const resolvedId = resolveId(id, "plans");
7883
+ const plan = getPlan(resolvedId);
7884
+ if (!plan)
7885
+ return { content: [{ type: "text", text: `Plan not found: ${id}` }], isError: true };
7886
+ const parts = [
7887
+ `ID: ${plan.id}`,
7888
+ `Name: ${plan.name}`,
7889
+ `Status: ${plan.status}`
7890
+ ];
7891
+ if (plan.description)
7892
+ parts.push(`Description: ${plan.description}`);
7893
+ if (plan.project_id)
7894
+ parts.push(`Project: ${plan.project_id}`);
7895
+ parts.push(`Created: ${plan.created_at}`);
7896
+ parts.push(`Updated: ${plan.updated_at}`);
7897
+ return { content: [{ type: "text", text: parts.join(`
7898
+ `) }] };
7087
7899
  } catch (e) {
7088
7900
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7089
7901
  }
7090
7902
  });
7091
- server.tool("list_projects", "List all registered projects", {}, async () => {
7903
+ server.tool("update_plan", "Update a plan", {
7904
+ id: exports_external.string().describe("Plan ID (full or partial)"),
7905
+ name: exports_external.string().optional().describe("New name"),
7906
+ description: exports_external.string().optional().describe("New description"),
7907
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("New status")
7908
+ }, async ({ id, ...rest }) => {
7092
7909
  try {
7093
- const projects = listProjects();
7094
- if (projects.length === 0) {
7095
- return { content: [{ type: "text", text: "No projects registered." }] };
7096
- }
7097
- const text = projects.map((p) => `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${p.description ? ` - ${p.description}` : ""}`).join(`
7098
- `);
7099
- return { content: [{ type: "text", text: `${projects.length} project(s):
7100
- ${text}` }] };
7910
+ const resolvedId = resolveId(id, "plans");
7911
+ const plan = updatePlan(resolvedId, rest);
7912
+ return {
7913
+ content: [{
7914
+ type: "text",
7915
+ text: `Plan updated: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
7916
+ }]
7917
+ };
7101
7918
  } catch (e) {
7102
7919
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7103
7920
  }
7104
7921
  });
7105
- server.tool("create_project", "Register a new project", {
7106
- name: exports_external.string().describe("Project name"),
7107
- path: exports_external.string().describe("Absolute path to project"),
7108
- description: exports_external.string().optional().describe("Project description")
7109
- }, async (params) => {
7922
+ server.tool("delete_plan", "Delete a plan", {
7923
+ id: exports_external.string().describe("Plan ID (full or partial)")
7924
+ }, async ({ id }) => {
7110
7925
  try {
7111
- const project = createProject(params);
7926
+ const resolvedId = resolveId(id, "plans");
7927
+ const deleted = deletePlan(resolvedId);
7112
7928
  return {
7113
7929
  content: [{
7114
7930
  type: "text",
7115
- text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}`
7931
+ text: deleted ? `Plan ${id} deleted.` : `Plan ${id} not found.`
7116
7932
  }]
7117
7933
  };
7118
7934
  } catch (e) {
@@ -7137,30 +7953,42 @@ ${text}` }] };
7137
7953
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7138
7954
  }
7139
7955
  });
7140
- server.tool("sync", "Sync tasks with a Claude Code task list. Auto-detects task list from session ID if not specified. Use --push to write SQLite tasks to Claude task list, --pull to import, or both for bidirectional sync.", {
7141
- task_list_id: exports_external.string().optional().describe("Claude Code task list ID (defaults to session ID)"),
7142
- project_id: exports_external.string().optional().describe("Limit sync to a project"),
7143
- direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->Claude), pull (Claude->SQLite), or both (default)")
7144
- }, async ({ task_list_id, project_id, direction }) => {
7956
+ server.tool("sync", "Sync tasks with an agent task list (Claude uses native task list; others use JSON lists).", {
7957
+ task_list_id: exports_external.string().optional().describe("Task list ID (required for Claude)"),
7958
+ agent: exports_external.string().optional().describe("Agent/provider name (default: claude)"),
7959
+ all_agents: exports_external.boolean().optional().describe("Sync across all configured agents"),
7960
+ project_id: exports_external.string().optional().describe("Project ID \u2014 its task_list_id will be used for Claude if task_list_id is not provided"),
7961
+ direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->agent), pull (agent->SQLite), or both (default)"),
7962
+ prefer: exports_external.enum(["local", "remote"]).optional().describe("Conflict strategy")
7963
+ }, async ({ task_list_id, agent, all_agents, project_id, direction, prefer }) => {
7145
7964
  try {
7146
7965
  const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
7147
- const taskListId = task_list_id || process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"];
7148
- if (!taskListId) {
7149
- return { content: [{ type: "text", text: "Could not detect task list ID. Pass task_list_id or set CLAUDE_CODE_TASK_LIST_ID." }], isError: true };
7150
- }
7966
+ const project = resolvedProjectId ? getProject(resolvedProjectId) : undefined;
7967
+ const dir = direction ?? "both";
7968
+ const options = { prefer: prefer ?? "remote" };
7151
7969
  let result;
7152
- if (direction === "push") {
7153
- result = pushToClaudeTaskList(taskListId, resolvedProjectId);
7154
- } else if (direction === "pull") {
7155
- result = pullFromClaudeTaskList(taskListId, resolvedProjectId);
7970
+ if (all_agents) {
7971
+ const agents = defaultSyncAgents();
7972
+ result = syncWithAgents(agents, (a) => resolveTaskListId(a, task_list_id || project?.task_list_id || undefined), resolvedProjectId, dir, options);
7156
7973
  } else {
7157
- result = syncClaudeTaskList(taskListId, resolvedProjectId);
7974
+ const resolvedAgent = agent || "claude";
7975
+ const taskListId = resolveTaskListId(resolvedAgent, task_list_id || project?.task_list_id || undefined);
7976
+ if (!taskListId) {
7977
+ return {
7978
+ content: [{
7979
+ type: "text",
7980
+ text: `Could not determine task list ID for ${resolvedAgent}. Provide task_list_id or set task_list_id on the project.`
7981
+ }],
7982
+ isError: true
7983
+ };
7984
+ }
7985
+ result = syncWithAgent(resolvedAgent, taskListId, resolvedProjectId, dir, options);
7158
7986
  }
7159
7987
  const parts = [];
7160
7988
  if (result.pulled > 0)
7161
- parts.push(`Pulled ${result.pulled} task(s) from Claude task list.`);
7989
+ parts.push(`Pulled ${result.pulled} task(s).`);
7162
7990
  if (result.pushed > 0)
7163
- parts.push(`Pushed ${result.pushed} task(s) to Claude task list.`);
7991
+ parts.push(`Pushed ${result.pushed} task(s).`);
7164
7992
  if (result.pulled === 0 && result.pushed === 0 && result.errors.length === 0) {
7165
7993
  parts.push("Nothing to sync.");
7166
7994
  }
@@ -7190,30 +8018,34 @@ ${text}` }] };
7190
8018
  // src/server/serve.ts
7191
8019
  var exports_serve = {};
7192
8020
  __export(exports_serve, {
7193
- startServer: () => startServer
8021
+ startServer: () => startServer,
8022
+ createFetchHandler: () => createFetchHandler
7194
8023
  });
7195
8024
  import { execSync } from "child_process";
7196
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
7197
- import { join as join3, dirname as dirname2, extname } from "path";
8025
+ import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
8026
+ import { join as join6, dirname as dirname2, extname } from "path";
7198
8027
  import { fileURLToPath } from "url";
7199
8028
  function resolveDashboardDir() {
7200
8029
  const candidates = [];
7201
8030
  try {
7202
8031
  const scriptDir = dirname2(fileURLToPath(import.meta.url));
7203
- candidates.push(join3(scriptDir, "..", "dashboard", "dist"));
7204
- candidates.push(join3(scriptDir, "..", "..", "dashboard", "dist"));
8032
+ candidates.push(join6(scriptDir, "..", "dashboard", "dist"));
8033
+ candidates.push(join6(scriptDir, "..", "..", "dashboard", "dist"));
7205
8034
  } catch {}
7206
8035
  if (process.argv[1]) {
7207
8036
  const mainDir = dirname2(process.argv[1]);
7208
- candidates.push(join3(mainDir, "..", "dashboard", "dist"));
7209
- candidates.push(join3(mainDir, "..", "..", "dashboard", "dist"));
8037
+ candidates.push(join6(mainDir, "..", "dashboard", "dist"));
8038
+ candidates.push(join6(mainDir, "..", "..", "dashboard", "dist"));
7210
8039
  }
7211
- candidates.push(join3(process.cwd(), "dashboard", "dist"));
8040
+ candidates.push(join6(process.cwd(), "dashboard", "dist"));
7212
8041
  for (const candidate of candidates) {
7213
- if (existsSync3(candidate))
8042
+ if (existsSync6(candidate))
7214
8043
  return candidate;
7215
8044
  }
7216
- return join3(process.cwd(), "dashboard", "dist");
8045
+ return join6(process.cwd(), "dashboard", "dist");
8046
+ }
8047
+ function randomPort() {
8048
+ return 20000 + Math.floor(Math.random() * 20000);
7217
8049
  }
7218
8050
  function json(data, status = 200, port) {
7219
8051
  return new Response(JSON.stringify(data), {
@@ -7227,14 +8059,21 @@ function json(data, status = 200, port) {
7227
8059
  }
7228
8060
  function getPackageVersion() {
7229
8061
  try {
7230
- const pkgPath = join3(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
8062
+ const pkgPath = join6(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7231
8063
  return JSON.parse(readFileSync2(pkgPath, "utf-8")).version || "0.0.0";
7232
8064
  } catch {
7233
8065
  return "0.0.0";
7234
8066
  }
7235
8067
  }
8068
+ async function parseJsonBody(req) {
8069
+ try {
8070
+ return await req.json();
8071
+ } catch {
8072
+ return null;
8073
+ }
8074
+ }
7236
8075
  function serveStaticFile(filePath) {
7237
- if (!existsSync3(filePath))
8076
+ if (!existsSync6(filePath))
7238
8077
  return null;
7239
8078
  const ext = extname(filePath);
7240
8079
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
@@ -7242,299 +8081,474 @@ function serveStaticFile(filePath) {
7242
8081
  headers: { "Content-Type": contentType }
7243
8082
  });
7244
8083
  }
7245
- async function startServer(port, options) {
7246
- const shouldOpen = options?.open ?? true;
7247
- const dashboardDir = resolveDashboardDir();
7248
- const dashboardExists = existsSync3(dashboardDir);
7249
- if (!dashboardExists) {
7250
- console.error(`
7251
- Dashboard not found at: ${dashboardDir}`);
7252
- console.error(`Run this to build it:
7253
- `);
7254
- console.error(` cd dashboard && bun install && bun run build
7255
- `);
7256
- console.error(`Or from the project root:
7257
- `);
7258
- console.error(` bun run build:dashboard
7259
- `);
7260
- }
7261
- const server2 = Bun.serve({
7262
- port,
7263
- async fetch(req) {
7264
- const url = new URL(req.url);
7265
- const path = url.pathname;
7266
- const method = req.method;
7267
- if (path === "/api/tasks" && method === "GET") {
7268
- try {
7269
- const filter = {};
7270
- const status = url.searchParams.get("status");
7271
- const priority = url.searchParams.get("priority");
7272
- const projectId = url.searchParams.get("project_id");
7273
- if (status)
7274
- filter.status = status;
7275
- if (priority)
7276
- filter.priority = priority;
7277
- if (projectId)
7278
- filter.project_id = projectId;
7279
- const tasks = listTasks(filter);
7280
- const projectCache = new Map;
7281
- const enriched = tasks.map((t) => {
7282
- let projectName;
7283
- if (t.project_id) {
7284
- if (projectCache.has(t.project_id)) {
7285
- projectName = projectCache.get(t.project_id);
7286
- } else {
7287
- const p = getProject(t.project_id);
7288
- projectName = p?.name;
7289
- if (projectName)
7290
- projectCache.set(t.project_id, projectName);
7291
- }
7292
- }
7293
- return { ...t, project_name: projectName };
7294
- });
7295
- return json(enriched, 200, port);
7296
- } catch (e) {
7297
- return json({ error: e instanceof Error ? e.message : "Failed to list tasks" }, 500, port);
7298
- }
7299
- }
7300
- const taskGetMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7301
- if (taskGetMatch && method === "GET") {
7302
- try {
7303
- const id = taskGetMatch[1];
7304
- const task = getTaskWithRelations(id);
7305
- if (!task)
7306
- return json({ error: "Task not found" }, 404, port);
8084
+ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8085
+ const dir = dashboardDir || resolveDashboardDir();
8086
+ const hasDashboard = dashboardExists ?? existsSync6(dir);
8087
+ return async (req) => {
8088
+ const url = new URL(req.url);
8089
+ let path = url.pathname;
8090
+ if (path.startsWith("/api/v1/")) {
8091
+ path = path.replace("/api/v1", "/api");
8092
+ }
8093
+ const method = req.method;
8094
+ const port = getPort();
8095
+ if (path === "/api/tasks" && method === "GET") {
8096
+ try {
8097
+ const filter = {};
8098
+ const status = url.searchParams.get("status");
8099
+ const priority = url.searchParams.get("priority");
8100
+ const projectId = url.searchParams.get("project_id");
8101
+ if (status)
8102
+ filter.status = status;
8103
+ if (priority)
8104
+ filter.priority = priority;
8105
+ if (projectId)
8106
+ filter.project_id = projectId;
8107
+ const tasks = listTasks(filter);
8108
+ const projectCache = new Map;
8109
+ const planCache = new Map;
8110
+ const enriched = tasks.map((t) => {
7307
8111
  let projectName;
7308
- if (task.project_id) {
7309
- const p = getProject(task.project_id);
7310
- projectName = p?.name;
8112
+ if (t.project_id) {
8113
+ if (projectCache.has(t.project_id)) {
8114
+ projectName = projectCache.get(t.project_id);
8115
+ } else {
8116
+ const p = getProject(t.project_id);
8117
+ projectName = p?.name;
8118
+ if (projectName)
8119
+ projectCache.set(t.project_id, projectName);
8120
+ }
7311
8121
  }
7312
- return json({ ...task, project_name: projectName }, 200, port);
7313
- } catch (e) {
7314
- return json({ error: e instanceof Error ? e.message : "Failed to get task" }, 500, port);
7315
- }
7316
- }
7317
- if (path === "/api/tasks" && method === "POST") {
7318
- try {
7319
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7320
- if (contentLength > MAX_BODY_SIZE)
7321
- return json({ error: "Request body too large" }, 413, port);
7322
- const body = await req.json();
7323
- if (!body.title || typeof body.title !== "string") {
7324
- return json({ error: "Missing required field: title" }, 400, port);
8122
+ let planName;
8123
+ if (t.plan_id) {
8124
+ if (planCache.has(t.plan_id)) {
8125
+ planName = planCache.get(t.plan_id);
8126
+ } else {
8127
+ const pl = getPlan(t.plan_id);
8128
+ planName = pl?.name;
8129
+ if (planName)
8130
+ planCache.set(t.plan_id, planName);
8131
+ }
7325
8132
  }
7326
- const task = createTask({
7327
- title: body.title,
7328
- description: body.description,
7329
- priority: body.priority,
7330
- project_id: body.project_id,
7331
- parent_id: body.parent_id,
7332
- tags: body.tags,
7333
- assigned_to: body.assigned_to,
7334
- agent_id: body.agent_id,
7335
- status: body.status
7336
- });
7337
- return json(task, 201, port);
7338
- } catch (e) {
7339
- return json({ error: e instanceof Error ? e.message : "Failed to create task" }, 500, port);
7340
- }
8133
+ return { ...t, project_name: projectName, plan_name: planName };
8134
+ });
8135
+ return json(enriched, 200, port);
8136
+ } catch (e) {
8137
+ return json({ error: e instanceof Error ? e.message : "Failed to list tasks" }, 500, port);
7341
8138
  }
7342
- const taskPatchMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7343
- if (taskPatchMatch && method === "PATCH") {
7344
- try {
7345
- const id = taskPatchMatch[1];
7346
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7347
- if (contentLength > MAX_BODY_SIZE)
7348
- return json({ error: "Request body too large" }, 413, port);
7349
- const body = await req.json();
7350
- if (typeof body.version !== "number") {
7351
- return json({ error: "Missing required field: version" }, 400, port);
7352
- }
7353
- const task = updateTask(id, {
7354
- version: body.version,
7355
- title: body.title,
7356
- description: body.description,
7357
- status: body.status,
7358
- priority: body.priority,
7359
- assigned_to: body.assigned_to,
7360
- tags: body.tags,
7361
- metadata: body.metadata
7362
- });
7363
- return json(task, 200, port);
7364
- } catch (e) {
7365
- const status = e instanceof Error && e.name === "VersionConflictError" ? 409 : 500;
7366
- return json({ error: e instanceof Error ? e.message : "Failed to update task" }, status, port);
8139
+ }
8140
+ const taskGetMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8141
+ if (taskGetMatch && method === "GET") {
8142
+ try {
8143
+ const id = taskGetMatch[1];
8144
+ const task = getTaskWithRelations(id);
8145
+ if (!task)
8146
+ return json({ error: "Task not found" }, 404, port);
8147
+ let projectName;
8148
+ if (task.project_id) {
8149
+ const p = getProject(task.project_id);
8150
+ projectName = p?.name;
7367
8151
  }
8152
+ return json({ ...task, project_name: projectName }, 200, port);
8153
+ } catch (e) {
8154
+ return json({ error: e instanceof Error ? e.message : "Failed to get task" }, 500, port);
7368
8155
  }
7369
- const taskDeleteMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7370
- if (taskDeleteMatch && method === "DELETE") {
7371
- try {
7372
- const id = taskDeleteMatch[1];
7373
- const deleted = deleteTask(id);
7374
- if (!deleted)
7375
- return json({ error: "Task not found" }, 404, port);
7376
- return json({ deleted: true }, 200, port);
7377
- } catch (e) {
7378
- return json({ error: e instanceof Error ? e.message : "Failed to delete task" }, 500, port);
7379
- }
8156
+ }
8157
+ if (path === "/api/tasks" && method === "POST") {
8158
+ try {
8159
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8160
+ if (contentLength > MAX_BODY_SIZE)
8161
+ return json({ error: "Request body too large" }, 413, port);
8162
+ const body = await parseJsonBody(req);
8163
+ if (!body)
8164
+ return json({ error: "Invalid JSON" }, 400, port);
8165
+ if (!body.title || typeof body.title !== "string") {
8166
+ return json({ error: "Missing required field: title" }, 400, port);
8167
+ }
8168
+ const parsed = createTaskSchema.safeParse(body);
8169
+ if (!parsed.success) {
8170
+ return json({ error: "Invalid request body" }, 400, port);
8171
+ }
8172
+ const task = createTask({
8173
+ title: parsed.data.title,
8174
+ description: parsed.data.description,
8175
+ priority: parsed.data.priority,
8176
+ project_id: parsed.data.project_id,
8177
+ parent_id: parsed.data.parent_id,
8178
+ plan_id: parsed.data.plan_id,
8179
+ tags: parsed.data.tags,
8180
+ assigned_to: parsed.data.assigned_to,
8181
+ agent_id: parsed.data.agent_id,
8182
+ status: parsed.data.status
8183
+ });
8184
+ return json(task, 201, port);
8185
+ } catch (e) {
8186
+ return json({ error: e instanceof Error ? e.message : "Failed to create task" }, 500, port);
7380
8187
  }
7381
- const taskStartMatch = path.match(/^\/api\/tasks\/([^/]+)\/start$/);
7382
- if (taskStartMatch && method === "POST") {
7383
- try {
7384
- const id = taskStartMatch[1];
7385
- const body = await req.json();
7386
- const agentId = body.agent_id || "dashboard";
7387
- const task = startTask(id, agentId);
7388
- return json(task, 200, port);
7389
- } catch (e) {
7390
- const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
7391
- return json({ error: e instanceof Error ? e.message : "Failed to start task" }, status, port);
7392
- }
7393
- }
7394
- const taskCompleteMatch = path.match(/^\/api\/tasks\/([^/]+)\/complete$/);
7395
- if (taskCompleteMatch && method === "POST") {
7396
- try {
7397
- const id = taskCompleteMatch[1];
7398
- const body = await req.json();
7399
- const agentId = body.agent_id;
7400
- const task = completeTask(id, agentId);
7401
- return json(task, 200, port);
7402
- } catch (e) {
7403
- const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
7404
- return json({ error: e instanceof Error ? e.message : "Failed to complete task" }, status, port);
7405
- }
7406
- }
7407
- const commentsGetMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
7408
- if (commentsGetMatch && method === "GET") {
7409
- try {
7410
- const taskId = commentsGetMatch[1];
7411
- const comments = listComments(taskId);
7412
- return json(comments, 200, port);
7413
- } catch (e) {
7414
- return json({ error: e instanceof Error ? e.message : "Failed to list comments" }, 500, port);
7415
- }
8188
+ }
8189
+ const taskPatchMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8190
+ if (taskPatchMatch && method === "PATCH") {
8191
+ try {
8192
+ const id = taskPatchMatch[1];
8193
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8194
+ if (contentLength > MAX_BODY_SIZE)
8195
+ return json({ error: "Request body too large" }, 413, port);
8196
+ const body = await parseJsonBody(req);
8197
+ if (!body)
8198
+ return json({ error: "Invalid JSON" }, 400, port);
8199
+ if (typeof body.version !== "number") {
8200
+ return json({ error: "Missing required field: version" }, 400, port);
8201
+ }
8202
+ const parsed = updateTaskSchema.safeParse(body);
8203
+ if (!parsed.success) {
8204
+ return json({ error: "Invalid request body" }, 400, port);
8205
+ }
8206
+ const task = updateTask(id, {
8207
+ version: parsed.data.version,
8208
+ title: parsed.data.title,
8209
+ description: parsed.data.description,
8210
+ status: parsed.data.status,
8211
+ priority: parsed.data.priority,
8212
+ assigned_to: parsed.data.assigned_to,
8213
+ plan_id: parsed.data.plan_id,
8214
+ tags: parsed.data.tags,
8215
+ metadata: parsed.data.metadata
8216
+ });
8217
+ return json(task, 200, port);
8218
+ } catch (e) {
8219
+ const status = e instanceof Error && e.name === "VersionConflictError" ? 409 : 500;
8220
+ return json({ error: e instanceof Error ? e.message : "Failed to update task" }, status, port);
7416
8221
  }
7417
- const commentsPostMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
7418
- if (commentsPostMatch && method === "POST") {
7419
- try {
7420
- const taskId = commentsPostMatch[1];
7421
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7422
- if (contentLength > MAX_BODY_SIZE)
7423
- return json({ error: "Request body too large" }, 413, port);
7424
- const body = await req.json();
7425
- if (!body.content || typeof body.content !== "string") {
7426
- return json({ error: "Missing required field: content" }, 400, port);
7427
- }
7428
- const comment = addComment({
7429
- task_id: taskId,
7430
- content: body.content,
7431
- agent_id: body.agent_id,
7432
- session_id: body.session_id
7433
- });
7434
- return json(comment, 201, port);
7435
- } catch (e) {
7436
- return json({ error: e instanceof Error ? e.message : "Failed to add comment" }, 500, port);
7437
- }
8222
+ }
8223
+ const taskDeleteMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8224
+ if (taskDeleteMatch && method === "DELETE") {
8225
+ try {
8226
+ const id = taskDeleteMatch[1];
8227
+ const deleted = deleteTask(id);
8228
+ if (!deleted)
8229
+ return json({ error: "Task not found" }, 404, port);
8230
+ return json({ deleted: true }, 200, port);
8231
+ } catch (e) {
8232
+ return json({ error: e instanceof Error ? e.message : "Failed to delete task" }, 500, port);
7438
8233
  }
7439
- if (path === "/api/projects" && method === "GET") {
7440
- try {
7441
- const projects = listProjects();
7442
- return json(projects, 200, port);
7443
- } catch (e) {
7444
- return json({ error: e instanceof Error ? e.message : "Failed to list projects" }, 500, port);
7445
- }
8234
+ }
8235
+ const taskStartMatch = path.match(/^\/api\/tasks\/([^/]+)\/start$/);
8236
+ if (taskStartMatch && method === "POST") {
8237
+ try {
8238
+ const id = taskStartMatch[1];
8239
+ const body = await parseJsonBody(req);
8240
+ if (!body)
8241
+ return json({ error: "Invalid JSON" }, 400, port);
8242
+ const parsed = agentSchema.safeParse(body);
8243
+ if (!parsed.success)
8244
+ return json({ error: "Invalid request body" }, 400, port);
8245
+ const agentId = parsed.data.agent_id || "dashboard";
8246
+ const task = startTask(id, agentId);
8247
+ return json(task, 200, port);
8248
+ } catch (e) {
8249
+ const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
8250
+ return json({ error: e instanceof Error ? e.message : "Failed to start task" }, status, port);
8251
+ }
8252
+ }
8253
+ const taskCompleteMatch = path.match(/^\/api\/tasks\/([^/]+)\/complete$/);
8254
+ if (taskCompleteMatch && method === "POST") {
8255
+ try {
8256
+ const id = taskCompleteMatch[1];
8257
+ const body = await parseJsonBody(req);
8258
+ if (!body)
8259
+ return json({ error: "Invalid JSON" }, 400, port);
8260
+ const parsed = agentSchema.safeParse(body);
8261
+ if (!parsed.success)
8262
+ return json({ error: "Invalid request body" }, 400, port);
8263
+ const agentId = parsed.data.agent_id;
8264
+ const task = completeTask(id, agentId);
8265
+ return json(task, 200, port);
8266
+ } catch (e) {
8267
+ const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
8268
+ return json({ error: e instanceof Error ? e.message : "Failed to complete task" }, status, port);
8269
+ }
8270
+ }
8271
+ const commentsGetMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
8272
+ if (commentsGetMatch && method === "GET") {
8273
+ try {
8274
+ const taskId = commentsGetMatch[1];
8275
+ const comments = listComments(taskId);
8276
+ return json(comments, 200, port);
8277
+ } catch (e) {
8278
+ return json({ error: e instanceof Error ? e.message : "Failed to list comments" }, 500, port);
7446
8279
  }
7447
- if (path === "/api/projects" && method === "POST") {
7448
- try {
7449
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7450
- if (contentLength > MAX_BODY_SIZE)
7451
- return json({ error: "Request body too large" }, 413, port);
7452
- const body = await req.json();
7453
- if (!body.name || typeof body.name !== "string") {
7454
- return json({ error: "Missing required field: name" }, 400, port);
7455
- }
7456
- const project = createProject({
7457
- name: body.name,
7458
- path: body.path || process.cwd(),
7459
- description: body.description
7460
- });
7461
- return json(project, 201, port);
7462
- } catch (e) {
7463
- return json({ error: e instanceof Error ? e.message : "Failed to create project" }, 500, port);
7464
- }
8280
+ }
8281
+ const commentsPostMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
8282
+ if (commentsPostMatch && method === "POST") {
8283
+ try {
8284
+ const taskId = commentsPostMatch[1];
8285
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8286
+ if (contentLength > MAX_BODY_SIZE)
8287
+ return json({ error: "Request body too large" }, 413, port);
8288
+ const body = await parseJsonBody(req);
8289
+ if (!body)
8290
+ return json({ error: "Invalid JSON" }, 400, port);
8291
+ if (!body.content || typeof body.content !== "string") {
8292
+ return json({ error: "Missing required field: content" }, 400, port);
8293
+ }
8294
+ const parsed = createCommentSchema.safeParse(body);
8295
+ if (!parsed.success) {
8296
+ return json({ error: "Invalid request body" }, 400, port);
8297
+ }
8298
+ const comment = addComment({
8299
+ task_id: taskId,
8300
+ content: parsed.data.content,
8301
+ agent_id: parsed.data.agent_id,
8302
+ session_id: parsed.data.session_id
8303
+ });
8304
+ return json(comment, 201, port);
8305
+ } catch (e) {
8306
+ return json({ error: e instanceof Error ? e.message : "Failed to add comment" }, 500, port);
7465
8307
  }
7466
- if (path === "/api/search" && method === "GET") {
7467
- try {
7468
- const q = url.searchParams.get("q");
7469
- if (!q)
7470
- return json({ error: "Missing query parameter: q" }, 400, port);
7471
- const projectId = url.searchParams.get("project_id") || undefined;
7472
- const results = searchTasks(q, projectId);
7473
- return json(results, 200, port);
7474
- } catch (e) {
7475
- return json({ error: e instanceof Error ? e.message : "Search failed" }, 500, port);
7476
- }
8308
+ }
8309
+ if (path === "/api/projects" && method === "GET") {
8310
+ try {
8311
+ const projects = listProjects();
8312
+ return json(projects, 200, port);
8313
+ } catch (e) {
8314
+ return json({ error: e instanceof Error ? e.message : "Failed to list projects" }, 500, port);
7477
8315
  }
7478
- if (path === "/api/system/version" && method === "GET") {
7479
- try {
7480
- const current = getPackageVersion();
7481
- const npmRes = await fetch("https://registry.npmjs.org/@hasna/todos/latest");
7482
- if (!npmRes.ok) {
7483
- return json({ current, latest: current, updateAvailable: false }, 200, port);
7484
- }
7485
- const data = await npmRes.json();
7486
- const latest = data.version;
7487
- return json({ current, latest, updateAvailable: current !== latest }, 200, port);
7488
- } catch {
7489
- const current = getPackageVersion();
8316
+ }
8317
+ if (path === "/api/projects" && method === "POST") {
8318
+ try {
8319
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8320
+ if (contentLength > MAX_BODY_SIZE)
8321
+ return json({ error: "Request body too large" }, 413, port);
8322
+ const body = await parseJsonBody(req);
8323
+ if (!body)
8324
+ return json({ error: "Invalid JSON" }, 400, port);
8325
+ if (!body.name || typeof body.name !== "string") {
8326
+ return json({ error: "Missing required field: name" }, 400, port);
8327
+ }
8328
+ const parsed = createProjectSchema.safeParse(body);
8329
+ if (!parsed.success) {
8330
+ return json({ error: "Invalid request body" }, 400, port);
8331
+ }
8332
+ const project = createProject({
8333
+ name: parsed.data.name,
8334
+ path: parsed.data.path || process.cwd(),
8335
+ description: parsed.data.description,
8336
+ task_list_id: parsed.data.task_list_id
8337
+ });
8338
+ return json(project, 201, port);
8339
+ } catch (e) {
8340
+ return json({ error: e instanceof Error ? e.message : "Failed to create project" }, 500, port);
8341
+ }
8342
+ }
8343
+ if (path === "/api/search" && method === "GET") {
8344
+ try {
8345
+ const q = url.searchParams.get("q");
8346
+ if (!q)
8347
+ return json({ error: "Missing query parameter: q" }, 400, port);
8348
+ const projectId = url.searchParams.get("project_id") || undefined;
8349
+ const results = searchTasks(q, projectId);
8350
+ return json(results, 200, port);
8351
+ } catch (e) {
8352
+ return json({ error: e instanceof Error ? e.message : "Search failed" }, 500, port);
8353
+ }
8354
+ }
8355
+ if (path === "/api/system/version" && method === "GET") {
8356
+ try {
8357
+ const current = getPackageVersion();
8358
+ const npmRes = await fetch("https://registry.npmjs.org/@hasna/todos/latest");
8359
+ if (!npmRes.ok) {
7490
8360
  return json({ current, latest: current, updateAvailable: false }, 200, port);
7491
8361
  }
8362
+ const data = await npmRes.json();
8363
+ const latest = data.version;
8364
+ return json({ current, latest, updateAvailable: current !== latest }, 200, port);
8365
+ } catch {
8366
+ const current = getPackageVersion();
8367
+ return json({ current, latest: current, updateAvailable: false }, 200, port);
7492
8368
  }
7493
- if (path === "/api/system/update" && method === "POST") {
8369
+ }
8370
+ if (path === "/api/system/update" && method === "POST") {
8371
+ try {
8372
+ let useBun = false;
7494
8373
  try {
7495
- let useBun = false;
7496
- try {
7497
- execSync("which bun", { stdio: "ignore" });
7498
- useBun = true;
7499
- } catch {}
7500
- const cmd = useBun ? "bun add -g @hasna/todos@latest" : "npm install -g @hasna/todos@latest";
7501
- execSync(cmd, { stdio: "ignore", timeout: 60000 });
7502
- return json({ success: true, message: "Updated! Restart the server to use the new version." }, 200, port);
7503
- } catch (e) {
7504
- return json({ success: false, message: e instanceof Error ? e.message : "Update failed" }, 500, port);
7505
- }
7506
- }
7507
- if (method === "OPTIONS") {
7508
- return new Response(null, {
7509
- headers: {
7510
- "Access-Control-Allow-Origin": `http://localhost:${port}`,
7511
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
7512
- "Access-Control-Allow-Headers": "Content-Type"
8374
+ execSync("which bun", { stdio: "ignore" });
8375
+ useBun = true;
8376
+ } catch {}
8377
+ const cmd = useBun ? "bun add -g @hasna/todos@latest" : "npm install -g @hasna/todos@latest";
8378
+ execSync(cmd, { stdio: "ignore", timeout: 60000 });
8379
+ return json({ success: true, message: "Updated! Restart the server to use the new version." }, 200, port);
8380
+ } catch (e) {
8381
+ return json({ success: false, message: e instanceof Error ? e.message : "Update failed" }, 500, port);
8382
+ }
8383
+ }
8384
+ if (path === "/api/plans" && method === "GET") {
8385
+ try {
8386
+ const projectId = url.searchParams.get("project_id") || undefined;
8387
+ const plans = listPlans(projectId);
8388
+ const enriched = plans.map((p) => {
8389
+ const db = getDatabase();
8390
+ const row = db.query("SELECT COUNT(*) as count FROM tasks WHERE plan_id = ?").get(p.id);
8391
+ let projectName;
8392
+ if (p.project_id) {
8393
+ const proj = getProject(p.project_id);
8394
+ projectName = proj?.name;
7513
8395
  }
8396
+ return { ...p, task_count: row?.count ?? 0, project_name: projectName };
7514
8397
  });
8398
+ return json(enriched, 200, port);
8399
+ } catch (e) {
8400
+ return json({ error: e instanceof Error ? e.message : "Failed to list plans" }, 500, port);
8401
+ }
8402
+ }
8403
+ if (path === "/api/plans" && method === "POST") {
8404
+ try {
8405
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8406
+ if (contentLength > MAX_BODY_SIZE)
8407
+ return json({ error: "Request body too large" }, 413, port);
8408
+ const body = await parseJsonBody(req);
8409
+ if (!body)
8410
+ return json({ error: "Invalid JSON" }, 400, port);
8411
+ if (!body.name || typeof body.name !== "string") {
8412
+ return json({ error: "Missing required field: name" }, 400, port);
8413
+ }
8414
+ const parsed = createPlanSchema.safeParse(body);
8415
+ if (!parsed.success) {
8416
+ return json({ error: "Invalid request body" }, 400, port);
8417
+ }
8418
+ const plan = createPlan(parsed.data);
8419
+ return json(plan, 201, port);
8420
+ } catch (e) {
8421
+ return json({ error: e instanceof Error ? e.message : "Failed to create plan" }, 500, port);
8422
+ }
8423
+ }
8424
+ const planGetMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8425
+ if (planGetMatch && method === "GET") {
8426
+ try {
8427
+ const id = planGetMatch[1];
8428
+ const plan = getPlan(id);
8429
+ if (!plan)
8430
+ return json({ error: "Plan not found" }, 404, port);
8431
+ return json(plan, 200, port);
8432
+ } catch (e) {
8433
+ return json({ error: e instanceof Error ? e.message : "Failed to get plan" }, 500, port);
8434
+ }
8435
+ }
8436
+ const planPatchMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8437
+ if (planPatchMatch && method === "PATCH") {
8438
+ try {
8439
+ const id = planPatchMatch[1];
8440
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8441
+ if (contentLength > MAX_BODY_SIZE)
8442
+ return json({ error: "Request body too large" }, 413, port);
8443
+ const body = await parseJsonBody(req);
8444
+ if (!body)
8445
+ return json({ error: "Invalid JSON" }, 400, port);
8446
+ const parsed = updatePlanSchema.safeParse(body);
8447
+ if (!parsed.success) {
8448
+ return json({ error: "Invalid request body" }, 400, port);
8449
+ }
8450
+ const plan = updatePlan(id, parsed.data);
8451
+ return json(plan, 200, port);
8452
+ } catch (e) {
8453
+ const status = e instanceof Error && e.name === "PlanNotFoundError" ? 404 : 500;
8454
+ return json({ error: e instanceof Error ? e.message : "Failed to update plan" }, status, port);
8455
+ }
8456
+ }
8457
+ const planDeleteMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8458
+ if (planDeleteMatch && method === "DELETE") {
8459
+ try {
8460
+ const id = planDeleteMatch[1];
8461
+ const deleted = deletePlan(id);
8462
+ if (!deleted)
8463
+ return json({ error: "Plan not found" }, 404, port);
8464
+ return json({ deleted: true }, 200, port);
8465
+ } catch (e) {
8466
+ return json({ error: e instanceof Error ? e.message : "Failed to delete plan" }, 500, port);
7515
8467
  }
7516
- if (dashboardExists && (method === "GET" || method === "HEAD")) {
7517
- if (path !== "/") {
7518
- const filePath = join3(dashboardDir, path);
7519
- const res2 = serveStaticFile(filePath);
7520
- if (res2)
7521
- return res2;
8468
+ }
8469
+ const projectDeleteMatch = path.match(/^\/api\/projects\/([^/]+)$/);
8470
+ if (projectDeleteMatch && method === "DELETE") {
8471
+ try {
8472
+ const id = projectDeleteMatch[1];
8473
+ const deleted = deleteProject(id);
8474
+ if (!deleted)
8475
+ return json({ error: "Project not found" }, 404, port);
8476
+ return json({ deleted: true }, 200, port);
8477
+ } catch (e) {
8478
+ return json({ error: e instanceof Error ? e.message : "Failed to delete project" }, 500, port);
8479
+ }
8480
+ }
8481
+ if (method === "OPTIONS") {
8482
+ return new Response(null, {
8483
+ headers: {
8484
+ "Access-Control-Allow-Origin": `http://localhost:${port}`,
8485
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
8486
+ "Access-Control-Allow-Headers": "Content-Type"
7522
8487
  }
7523
- const indexPath = join3(dashboardDir, "index.html");
7524
- const res = serveStaticFile(indexPath);
7525
- if (res)
7526
- return res;
8488
+ });
8489
+ }
8490
+ if (hasDashboard && (method === "GET" || method === "HEAD")) {
8491
+ if (path !== "/") {
8492
+ const filePath = join6(dir, path);
8493
+ const res2 = serveStaticFile(filePath);
8494
+ if (res2)
8495
+ return res2;
7527
8496
  }
7528
- return json({ error: "Not found" }, 404, port);
8497
+ const indexPath = join6(dir, "index.html");
8498
+ const res = serveStaticFile(indexPath);
8499
+ if (res)
8500
+ return res;
7529
8501
  }
7530
- });
8502
+ return json({ error: "Not found" }, 404, port);
8503
+ };
8504
+ }
8505
+ async function startServer(port, options) {
8506
+ const shouldOpen = options?.open ?? true;
8507
+ const dashboardDir = resolveDashboardDir();
8508
+ const dashboardExists = existsSync6(dashboardDir);
8509
+ if (!dashboardExists) {
8510
+ console.error(`
8511
+ Dashboard not found at: ${dashboardDir}`);
8512
+ console.error(`Run this to build it:
8513
+ `);
8514
+ console.error(` cd dashboard && bun install && bun run build
8515
+ `);
8516
+ console.error(`Or from the project root:
8517
+ `);
8518
+ console.error(` bun run build:dashboard
8519
+ `);
8520
+ }
8521
+ let actualPort = port;
8522
+ const fetchHandler = createFetchHandler(() => actualPort, dashboardDir, dashboardExists);
8523
+ const attempts = port === 0 ? 20 : 1;
8524
+ let server2 = null;
8525
+ let lastError;
8526
+ for (let i = 0;i < attempts; i++) {
8527
+ const candidate = port === 0 ? randomPort() : port;
8528
+ try {
8529
+ server2 = Bun.serve({
8530
+ port: candidate,
8531
+ fetch: fetchHandler
8532
+ });
8533
+ actualPort = server2.port;
8534
+ break;
8535
+ } catch (e) {
8536
+ lastError = e;
8537
+ if (port !== 0) {
8538
+ throw e;
8539
+ }
8540
+ }
8541
+ }
8542
+ if (!server2) {
8543
+ throw lastError;
8544
+ }
7531
8545
  const shutdown = () => {
7532
8546
  server2.stop();
7533
8547
  process.exit(0);
7534
8548
  };
7535
8549
  process.on("SIGINT", shutdown);
7536
8550
  process.on("SIGTERM", shutdown);
7537
- const serverUrl = `http://localhost:${port}`;
8551
+ const serverUrl = `http://localhost:${actualPort}`;
7538
8552
  console.log(`Todos Dashboard running at ${serverUrl}`);
7539
8553
  if (shouldOpen) {
7540
8554
  try {
@@ -7545,10 +8559,13 @@ Dashboard not found at: ${dashboardDir}`);
7545
8559
  }
7546
8560
  return server2;
7547
8561
  }
7548
- var MIME_TYPES, SECURITY_HEADERS, MAX_BODY_SIZE;
8562
+ var MIME_TYPES, SECURITY_HEADERS, MAX_BODY_SIZE, createTaskSchema, updateTaskSchema, createProjectSchema, createCommentSchema, createPlanSchema, updatePlanSchema, agentSchema;
7549
8563
  var init_serve = __esm(() => {
8564
+ init_zod();
7550
8565
  init_tasks();
7551
8566
  init_projects();
8567
+ init_plans();
8568
+ init_database();
7552
8569
  init_comments();
7553
8570
  init_search();
7554
8571
  MIME_TYPES = {
@@ -7568,6 +8585,54 @@ var init_serve = __esm(() => {
7568
8585
  "X-Frame-Options": "DENY"
7569
8586
  };
7570
8587
  MAX_BODY_SIZE = 1024 * 1024;
8588
+ createTaskSchema = exports_external.object({
8589
+ title: exports_external.string(),
8590
+ description: exports_external.string().optional(),
8591
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
8592
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
8593
+ project_id: exports_external.string().optional(),
8594
+ parent_id: exports_external.string().optional(),
8595
+ plan_id: exports_external.string().optional(),
8596
+ tags: exports_external.array(exports_external.string()).optional(),
8597
+ assigned_to: exports_external.string().optional(),
8598
+ agent_id: exports_external.string().optional()
8599
+ });
8600
+ updateTaskSchema = exports_external.object({
8601
+ version: exports_external.number(),
8602
+ title: exports_external.string().optional(),
8603
+ description: exports_external.string().optional(),
8604
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
8605
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
8606
+ assigned_to: exports_external.string().optional(),
8607
+ plan_id: exports_external.string().optional(),
8608
+ tags: exports_external.array(exports_external.string()).optional(),
8609
+ metadata: exports_external.record(exports_external.unknown()).optional()
8610
+ });
8611
+ createProjectSchema = exports_external.object({
8612
+ name: exports_external.string(),
8613
+ path: exports_external.string().optional(),
8614
+ description: exports_external.string().optional(),
8615
+ task_list_id: exports_external.string().optional()
8616
+ });
8617
+ createCommentSchema = exports_external.object({
8618
+ content: exports_external.string(),
8619
+ agent_id: exports_external.string().optional(),
8620
+ session_id: exports_external.string().optional()
8621
+ });
8622
+ createPlanSchema = exports_external.object({
8623
+ name: exports_external.string(),
8624
+ project_id: exports_external.string().optional(),
8625
+ description: exports_external.string().optional(),
8626
+ status: exports_external.enum(["active", "completed", "archived"]).optional()
8627
+ });
8628
+ updatePlanSchema = exports_external.object({
8629
+ name: exports_external.string().optional(),
8630
+ description: exports_external.string().optional(),
8631
+ status: exports_external.enum(["active", "completed", "archived"]).optional()
8632
+ });
8633
+ agentSchema = exports_external.object({
8634
+ agent_id: exports_external.string().optional()
8635
+ });
7571
8636
  });
7572
8637
 
7573
8638
  // src/cli/components/Header.tsx
@@ -8647,17 +9712,19 @@ var {
8647
9712
  init_database();
8648
9713
  init_tasks();
8649
9714
  init_projects();
9715
+ init_plans();
8650
9716
  init_comments();
8651
9717
  init_search();
8652
- init_claude_tasks();
9718
+ init_sync();
9719
+ init_config();
8653
9720
  import chalk from "chalk";
8654
9721
  import { execSync as execSync2 } from "child_process";
8655
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
8656
- import { basename, dirname as dirname3, join as join4, resolve as resolve2 } from "path";
9722
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
9723
+ import { basename, dirname as dirname3, join as join7, resolve as resolve2 } from "path";
8657
9724
  import { fileURLToPath as fileURLToPath2 } from "url";
8658
9725
  function getPackageVersion2() {
8659
9726
  try {
8660
- const pkgPath = join4(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
9727
+ const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8661
9728
  return JSON.parse(readFileSync3(pkgPath, "utf-8")).version || "0.0.0";
8662
9729
  } catch {
8663
9730
  return "0.0.0";
@@ -8684,20 +9751,21 @@ function detectGitRoot() {
8684
9751
  return null;
8685
9752
  }
8686
9753
  }
8687
- function autoProject(opts) {
9754
+ function autoDetectProject(opts) {
8688
9755
  if (opts.project) {
8689
- const p = getProjectByPath(resolve2(opts.project));
8690
- return p?.id;
9756
+ return getProjectByPath(resolve2(opts.project)) ?? undefined;
8691
9757
  }
8692
9758
  if (process.env["TODOS_AUTO_PROJECT"] === "false")
8693
9759
  return;
8694
9760
  const gitRoot = detectGitRoot();
8695
9761
  if (gitRoot) {
8696
- const p = ensureProject(basename(gitRoot), gitRoot);
8697
- return p.id;
9762
+ return ensureProject(basename(gitRoot), gitRoot);
8698
9763
  }
8699
9764
  return;
8700
9765
  }
9766
+ function autoProject(opts) {
9767
+ return autoDetectProject(opts)?.id;
9768
+ }
8701
9769
  function output(data, jsonMode) {
8702
9770
  if (jsonMode) {
8703
9771
  console.log(JSON.stringify(data, null, 2));
@@ -8722,10 +9790,11 @@ function formatTaskLine(t) {
8722
9790
  const lock = t.locked_by ? chalk.magenta(` [locked:${t.locked_by}]`) : "";
8723
9791
  const assigned = t.assigned_to ? chalk.cyan(` -> ${t.assigned_to}`) : "";
8724
9792
  const tags = t.tags.length > 0 ? chalk.dim(` [${t.tags.join(",")}]`) : "";
8725
- return `${chalk.dim(t.id.slice(0, 8))} ${statusFn(t.status.padEnd(11))} ${priorityFn(t.priority.padEnd(8))} ${t.title}${assigned}${lock}${tags}`;
9793
+ const plan = t.plan_id ? chalk.magenta(` [plan:${t.plan_id.slice(0, 8)}]`) : "";
9794
+ return `${chalk.dim(t.id.slice(0, 8))} ${statusFn(t.status.padEnd(11))} ${priorityFn(t.priority.padEnd(8))} ${t.title}${assigned}${lock}${tags}${plan}`;
8726
9795
  }
8727
9796
  program2.name("todos").description("Universal task management for AI coding agents").version(getPackageVersion2()).option("--project <path>", "Project path").option("--json", "Output as JSON").option("--agent <name>", "Agent name").option("--session <id>", "Session ID");
8728
- program2.command("add <title>").description("Create a new task").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("--parent <id>", "Parent task ID").option("--tags <tags>", "Comma-separated tags").option("--assign <agent>", "Assign to agent").option("--status <status>", "Initial status").action((title, opts) => {
9797
+ program2.command("add <title>").description("Create a new task").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("--parent <id>", "Parent task ID").option("--tags <tags>", "Comma-separated tags").option("--plan <id>", "Assign to a plan").option("--assign <agent>", "Assign to agent").option("--status <status>", "Initial status").action((title, opts) => {
8729
9798
  const globalOpts = program2.opts();
8730
9799
  const projectId = autoProject(globalOpts);
8731
9800
  const task = createTask({
@@ -8734,6 +9803,15 @@ program2.command("add <title>").description("Create a new task").option("-d, --d
8734
9803
  priority: opts.priority,
8735
9804
  parent_id: opts.parent ? resolveTaskId(opts.parent) : undefined,
8736
9805
  tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined,
9806
+ plan_id: opts.plan ? (() => {
9807
+ const db = getDatabase();
9808
+ const id = resolvePartialId(db, "plans", opts.plan);
9809
+ if (!id) {
9810
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.plan}`));
9811
+ process.exit(1);
9812
+ }
9813
+ return id;
9814
+ })() : undefined,
8737
9815
  assigned_to: opts.assign,
8738
9816
  status: opts.status,
8739
9817
  agent_id: globalOpts.agent,
@@ -8810,6 +9888,8 @@ program2.command("show <id>").description("Show full task details").action((id)
8810
9888
  console.log(` ${chalk.dim("Locked:")} ${task.locked_by} (at ${task.locked_at})`);
8811
9889
  if (task.project_id)
8812
9890
  console.log(` ${chalk.dim("Project:")} ${task.project_id}`);
9891
+ if (task.plan_id)
9892
+ console.log(` ${chalk.dim("Plan:")} ${task.plan_id}`);
8813
9893
  if (task.working_dir)
8814
9894
  console.log(` ${chalk.dim("WorkDir:")} ${task.working_dir}`);
8815
9895
  if (task.parent)
@@ -8958,45 +10038,114 @@ program2.command("delete <id>").description("Delete a task").action((id) => {
8958
10038
  process.exit(1);
8959
10039
  }
8960
10040
  });
8961
- program2.command("plan <title>").description("Create a plan with subtasks").option("-d, --description <text>", "Plan description").option("--tasks <tasks>", "Comma-separated subtask titles").option("-p, --priority <priority>", "Priority").action((title, opts) => {
10041
+ program2.command("plans").description("List and manage plans").option("--add <name>", "Create a plan").option("-d, --description <text>", "Plan description (with --add)").option("--show <id>", "Show plan details with its tasks").option("--delete <id>", "Delete a plan").option("--complete <id>", "Mark a plan as completed").action((opts) => {
8962
10042
  const globalOpts = program2.opts();
8963
10043
  const projectId = autoProject(globalOpts);
8964
- const parent = createTask({
8965
- title,
8966
- description: opts.description,
8967
- priority: opts.priority,
8968
- agent_id: globalOpts.agent,
8969
- session_id: globalOpts.session,
8970
- project_id: projectId,
8971
- working_dir: process.cwd()
8972
- });
8973
- const subtasks = [];
8974
- if (opts.tasks) {
8975
- const taskTitles = opts.tasks.split(",").map((t) => t.trim());
8976
- for (const st of taskTitles) {
8977
- subtasks.push(createTask({
8978
- title: st,
8979
- parent_id: parent.id,
8980
- priority: opts.priority,
8981
- agent_id: globalOpts.agent,
8982
- session_id: globalOpts.session,
8983
- project_id: projectId,
8984
- working_dir: process.cwd()
8985
- }));
10044
+ if (opts.add) {
10045
+ const plan = createPlan({
10046
+ name: opts.add,
10047
+ description: opts.description,
10048
+ project_id: projectId
10049
+ });
10050
+ if (globalOpts.json) {
10051
+ output(plan, true);
10052
+ } else {
10053
+ console.log(chalk.green("Plan created:"));
10054
+ console.log(`${chalk.dim(plan.id.slice(0, 8))} ${chalk.bold(plan.name)} ${chalk.cyan(`[${plan.status}]`)}`);
8986
10055
  }
10056
+ return;
8987
10057
  }
8988
- if (globalOpts.json) {
8989
- output({ parent, subtasks }, true);
8990
- } else {
8991
- console.log(chalk.green("Plan created:"));
8992
- console.log(formatTaskLine(parent));
8993
- if (subtasks.length > 0) {
10058
+ if (opts.show) {
10059
+ const db = getDatabase();
10060
+ const resolvedId = resolvePartialId(db, "plans", opts.show);
10061
+ if (!resolvedId) {
10062
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.show}`));
10063
+ process.exit(1);
10064
+ }
10065
+ const plan = getPlan(resolvedId);
10066
+ if (!plan) {
10067
+ console.error(chalk.red(`Plan not found: ${opts.show}`));
10068
+ process.exit(1);
10069
+ }
10070
+ const tasks = listTasks({ plan_id: resolvedId });
10071
+ if (globalOpts.json) {
10072
+ output({ plan, tasks }, true);
10073
+ return;
10074
+ }
10075
+ console.log(chalk.bold(`Plan Details:
10076
+ `));
10077
+ console.log(` ${chalk.dim("ID:")} ${plan.id}`);
10078
+ console.log(` ${chalk.dim("Name:")} ${plan.name}`);
10079
+ console.log(` ${chalk.dim("Status:")} ${chalk.cyan(plan.status)}`);
10080
+ if (plan.description)
10081
+ console.log(` ${chalk.dim("Desc:")} ${plan.description}`);
10082
+ if (plan.project_id)
10083
+ console.log(` ${chalk.dim("Project:")} ${plan.project_id}`);
10084
+ console.log(` ${chalk.dim("Created:")} ${plan.created_at}`);
10085
+ if (tasks.length > 0) {
8994
10086
  console.log(chalk.bold(`
8995
- Subtasks:`));
8996
- for (const st of subtasks) {
8997
- console.log(` ${formatTaskLine(st)}`);
10087
+ Tasks (${tasks.length}):`));
10088
+ for (const t of tasks) {
10089
+ console.log(` ${formatTaskLine(t)}`);
10090
+ }
10091
+ } else {
10092
+ console.log(chalk.dim(`
10093
+ No tasks in this plan.`));
10094
+ }
10095
+ return;
10096
+ }
10097
+ if (opts.delete) {
10098
+ const db = getDatabase();
10099
+ const resolvedId = resolvePartialId(db, "plans", opts.delete);
10100
+ if (!resolvedId) {
10101
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.delete}`));
10102
+ process.exit(1);
10103
+ }
10104
+ const deleted = deletePlan(resolvedId);
10105
+ if (globalOpts.json) {
10106
+ output({ deleted }, true);
10107
+ } else if (deleted) {
10108
+ console.log(chalk.green("Plan deleted."));
10109
+ } else {
10110
+ console.error(chalk.red("Plan not found."));
10111
+ process.exit(1);
10112
+ }
10113
+ return;
10114
+ }
10115
+ if (opts.complete) {
10116
+ const db = getDatabase();
10117
+ const resolvedId = resolvePartialId(db, "plans", opts.complete);
10118
+ if (!resolvedId) {
10119
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.complete}`));
10120
+ process.exit(1);
10121
+ }
10122
+ try {
10123
+ const plan = updatePlan(resolvedId, { status: "completed" });
10124
+ if (globalOpts.json) {
10125
+ output(plan, true);
10126
+ } else {
10127
+ console.log(chalk.green("Plan completed:"));
10128
+ console.log(`${chalk.dim(plan.id.slice(0, 8))} ${chalk.bold(plan.name)} ${chalk.cyan(`[${plan.status}]`)}`);
8998
10129
  }
10130
+ } catch (e) {
10131
+ handleError(e);
8999
10132
  }
10133
+ return;
10134
+ }
10135
+ const plans = listPlans(projectId);
10136
+ if (globalOpts.json) {
10137
+ output(plans, true);
10138
+ return;
10139
+ }
10140
+ if (plans.length === 0) {
10141
+ console.log(chalk.dim("No plans found."));
10142
+ return;
10143
+ }
10144
+ console.log(chalk.bold(`${plans.length} plan(s):
10145
+ `));
10146
+ for (const p of plans) {
10147
+ const desc = p.description ? chalk.dim(` - ${p.description}`) : "";
10148
+ console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.cyan(`[${p.status}]`)}${desc}`);
9000
10149
  }
9001
10150
  });
9002
10151
  program2.command("comment <id> <text>").description("Add a comment to a task").action((id, text) => {
@@ -9083,16 +10232,27 @@ program2.command("deps <id>").description("Manage task dependencies").option("--
9083
10232
  }
9084
10233
  }
9085
10234
  });
9086
- program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--name <name>", "Project name (with --add)").action((opts) => {
10235
+ program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--name <name>", "Project name (with --add)").option("--task-list-id <id>", "Custom task list ID (with --add)").action((opts) => {
9087
10236
  const globalOpts = program2.opts();
9088
10237
  if (opts.add) {
9089
10238
  const projectPath = resolve2(opts.add);
9090
10239
  const name = opts.name || basename(projectPath);
9091
- const project = ensureProject(name, projectPath);
10240
+ const existing = getProjectByPath(projectPath);
10241
+ let project;
10242
+ if (existing) {
10243
+ project = existing;
10244
+ if (opts.taskListId) {
10245
+ project = updateProject(existing.id, { task_list_id: opts.taskListId });
10246
+ }
10247
+ } else {
10248
+ project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
10249
+ }
9092
10250
  if (globalOpts.json) {
9093
10251
  output(project, true);
9094
10252
  } else {
9095
10253
  console.log(chalk.green(`Project registered: ${project.name} (${project.path})`));
10254
+ if (project.task_list_id)
10255
+ console.log(chalk.dim(` Task list: ${project.task_list_id}`));
9096
10256
  }
9097
10257
  return;
9098
10258
  }
@@ -9108,7 +10268,8 @@ program2.command("projects").description("List and manage projects").option("--a
9108
10268
  console.log(chalk.bold(`${projects.length} project(s):
9109
10269
  `));
9110
10270
  for (const p of projects) {
9111
- console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.dim(p.path)}${p.description ? ` - ${p.description}` : ""}`);
10271
+ const taskList = p.task_list_id ? chalk.cyan(` [${p.task_list_id}]`) : "";
10272
+ console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.dim(p.path)}${taskList}${p.description ? ` - ${p.description}` : ""}`);
9112
10273
  }
9113
10274
  });
9114
10275
  program2.command("export").description("Export tasks").option("-f, --format <format>", "Format: json or md", "json").action((opts) => {
@@ -9128,33 +10289,43 @@ program2.command("export").description("Export tasks").option("-f, --format <for
9128
10289
  console.log(JSON.stringify(tasks, null, 2));
9129
10290
  }
9130
10291
  });
9131
- function resolveClaudeTaskListId(explicit) {
9132
- return explicit || process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || null;
10292
+ function resolveTaskListId2(agent, explicit, projectTaskListId) {
10293
+ if (explicit)
10294
+ return explicit;
10295
+ const normalized = agent.trim().toLowerCase();
10296
+ if (normalized === "claude" || normalized === "claude-code" || normalized === "claude_code") {
10297
+ return process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || getAgentTaskListId(normalized) || projectTaskListId || null;
10298
+ }
10299
+ const key = `TODOS_${normalized.toUpperCase()}_TASK_LIST`;
10300
+ return process.env[key] || process.env["TODOS_TASK_LIST_ID"] || getAgentTaskListId(normalized) || "default";
9133
10301
  }
9134
- program2.command("sync").description("Sync tasks with a Claude Code task list").option("--task-list <id>", "Task list ID (auto-detects from CLAUDE_CODE_TASK_LIST_ID or CLAUDE_CODE_SESSION_ID)").option("--push", "One-way: push SQLite tasks to Claude task list").option("--pull", "One-way: pull Claude task list into SQLite").action((opts) => {
10302
+ program2.command("sync").description("Sync tasks with an agent task list (Claude uses native task list; others use JSON lists)").option("--task-list <id>", "Task list ID (Claude auto-detects from CLAUDE_CODE_TASK_LIST_ID or CLAUDE_CODE_SESSION_ID)").option("--agent <name>", "Agent/provider to sync (default: claude)").option("--all", "Sync across all configured agents (TODOS_SYNC_AGENTS or default: claude,codex,gemini)").option("--push", "One-way: push SQLite tasks to agent task list").option("--pull", "One-way: pull agent task list into SQLite").option("--prefer <side>", "Conflict strategy: local or remote", "remote").action((opts) => {
9135
10303
  const globalOpts = program2.opts();
9136
- const projectId = autoProject(globalOpts);
9137
- const taskListId = resolveClaudeTaskListId(opts.taskList);
9138
- if (!taskListId) {
9139
- console.error(chalk.red("Could not detect task list ID. Use --task-list <id>, or run inside a Claude Code session."));
9140
- process.exit(1);
9141
- }
10304
+ const project = autoDetectProject(globalOpts);
10305
+ const projectId = project?.id;
10306
+ const direction = opts.push && !opts.pull ? "push" : opts.pull && !opts.push ? "pull" : "both";
9142
10307
  let result;
9143
- if (opts.push && !opts.pull) {
9144
- result = pushToClaudeTaskList(taskListId, projectId);
9145
- } else if (opts.pull && !opts.push) {
9146
- result = pullFromClaudeTaskList(taskListId, projectId);
10308
+ const prefer = opts.prefer === "local" ? "local" : "remote";
10309
+ if (opts.all) {
10310
+ const agents = defaultSyncAgents();
10311
+ result = syncWithAgents(agents, (agent) => resolveTaskListId2(agent, opts.taskList, project?.task_list_id), projectId, direction, { prefer });
9147
10312
  } else {
9148
- result = syncClaudeTaskList(taskListId, projectId);
10313
+ const agent = opts.agent || "claude";
10314
+ const taskListId = resolveTaskListId2(agent, opts.taskList, project?.task_list_id);
10315
+ if (!taskListId) {
10316
+ console.error(chalk.red(`Could not detect task list ID for ${agent}. Use --task-list <id> or set appropriate env vars.`));
10317
+ process.exit(1);
10318
+ }
10319
+ result = syncWithAgent(agent, taskListId, projectId, direction, { prefer });
9149
10320
  }
9150
10321
  if (globalOpts.json) {
9151
10322
  output(result, true);
9152
10323
  return;
9153
10324
  }
9154
10325
  if (result.pulled > 0)
9155
- console.log(chalk.green(`Pulled ${result.pulled} task(s) from Claude task list.`));
10326
+ console.log(chalk.green(`Pulled ${result.pulled} task(s).`));
9156
10327
  if (result.pushed > 0)
9157
- console.log(chalk.green(`Pushed ${result.pushed} task(s) to Claude task list.`));
10328
+ console.log(chalk.green(`Pushed ${result.pushed} task(s).`));
9158
10329
  if (result.pulled === 0 && result.pushed === 0 && result.errors.length === 0) {
9159
10330
  console.log(chalk.dim("Nothing to sync."));
9160
10331
  }
@@ -9170,45 +10341,38 @@ hooks.command("install").description("Install Claude Code hooks for auto-sync").
9170
10341
  if (p)
9171
10342
  todosBin = p;
9172
10343
  } catch {}
9173
- const hooksDir = join4(process.cwd(), ".claude", "hooks");
9174
- if (!existsSync4(hooksDir))
10344
+ const hooksDir = join7(process.cwd(), ".claude", "hooks");
10345
+ if (!existsSync7(hooksDir))
9175
10346
  mkdirSync3(hooksDir, { recursive: true });
9176
10347
  const hookScript = `#!/usr/bin/env bash
9177
10348
  # Auto-generated by: todos hooks install
9178
10349
  # Syncs todos with Claude Code task list on tool use events.
9179
- # Reads session_id and tool_name from the hook JSON stdin.
10350
+ # Uses session_id when available; falls back to project-based task_list_id.
9180
10351
 
9181
10352
  INPUT=$(cat)
9182
10353
 
9183
- # Extract session_id from stdin JSON (hooks always receive this)
9184
10354
  SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || true)
9185
-
9186
- # Task list priority: env override > session ID from hook input
9187
- TASK_LIST="\${TODOS_CLAUDE_TASK_LIST:-\${CLAUDE_CODE_TASK_LIST_ID:-$SESSION_ID}}"
9188
-
9189
- if [ -z "$TASK_LIST" ]; then
9190
- exit 0
9191
- fi
10355
+ TASK_LIST="\${TODOS_CLAUDE_TASK_LIST:-\${SESSION_ID}}"
9192
10356
 
9193
10357
  TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || true)
9194
10358
 
9195
10359
  case "$TOOL_NAME" in
9196
10360
  TaskCreate|TaskUpdate)
9197
- ${todosBin} sync --pull --task-list "$TASK_LIST" 2>/dev/null || true
10361
+ TODOS_CLAUDE_TASK_LIST="$TASK_LIST" ${todosBin} sync --all --pull 2>/dev/null || true
9198
10362
  ;;
9199
10363
  mcp__todos__*)
9200
- ${todosBin} sync --push --task-list "$TASK_LIST" 2>/dev/null || true
10364
+ TODOS_CLAUDE_TASK_LIST="$TASK_LIST" ${todosBin} sync --all --push 2>/dev/null || true
9201
10365
  ;;
9202
10366
  esac
9203
10367
 
9204
10368
  exit 0
9205
10369
  `;
9206
- const hookPath = join4(hooksDir, "todos-sync.sh");
10370
+ const hookPath = join7(hooksDir, "todos-sync.sh");
9207
10371
  writeFileSync2(hookPath, hookScript);
9208
10372
  execSync2(`chmod +x "${hookPath}"`);
9209
10373
  console.log(chalk.green(`Hook script created: ${hookPath}`));
9210
- const settingsPath = join4(process.cwd(), ".claude", "settings.json");
9211
- const settings = readJsonFile(settingsPath);
10374
+ const settingsPath = join7(process.cwd(), ".claude", "settings.json");
10375
+ const settings = readJsonFile2(settingsPath);
9212
10376
  if (!settings["hooks"]) {
9213
10377
  settings["hooks"] = {};
9214
10378
  }
@@ -9232,9 +10396,9 @@ exit 0
9232
10396
  hooks: [{ type: "command", command: hookPath }]
9233
10397
  });
9234
10398
  hooksConfig["PostToolUse"] = filtered;
9235
- writeJsonFile(settingsPath, settings);
10399
+ writeJsonFile2(settingsPath, settings);
9236
10400
  console.log(chalk.green(`Claude Code hooks configured in: ${settingsPath}`));
9237
- console.log(chalk.dim("Task list ID auto-detected from hook stdin session_id."));
10401
+ console.log(chalk.dim("Task list ID auto-detected from project."));
9238
10402
  });
9239
10403
  program2.command("mcp").description("Start MCP server (stdio)").option("--register <agent>", "Register MCP server with an agent (claude, codex, gemini, all)").option("--unregister <agent>", "Unregister MCP server from an agent (claude, codex, gemini, all)").option("-g, --global", "Register/unregister globally (user-level) instead of project-level").action(async (opts) => {
9240
10404
  if (opts.register) {
@@ -9254,13 +10418,13 @@ function getMcpBinaryPath() {
9254
10418
  if (p)
9255
10419
  return p;
9256
10420
  } catch {}
9257
- const bunBin = join4(HOME2, ".bun", "bin", "todos-mcp");
9258
- if (existsSync4(bunBin))
10421
+ const bunBin = join7(HOME2, ".bun", "bin", "todos-mcp");
10422
+ if (existsSync7(bunBin))
9259
10423
  return bunBin;
9260
10424
  return "todos-mcp";
9261
10425
  }
9262
- function readJsonFile(path) {
9263
- if (!existsSync4(path))
10426
+ function readJsonFile2(path) {
10427
+ if (!existsSync7(path))
9264
10428
  return {};
9265
10429
  try {
9266
10430
  return JSON.parse(readFileSync3(path, "utf-8"));
@@ -9268,27 +10432,27 @@ function readJsonFile(path) {
9268
10432
  return {};
9269
10433
  }
9270
10434
  }
9271
- function writeJsonFile(path, data) {
10435
+ function writeJsonFile2(path, data) {
9272
10436
  const dir = dirname3(path);
9273
- if (!existsSync4(dir))
10437
+ if (!existsSync7(dir))
9274
10438
  mkdirSync3(dir, { recursive: true });
9275
10439
  writeFileSync2(path, JSON.stringify(data, null, 2) + `
9276
10440
  `);
9277
10441
  }
9278
10442
  function readTomlFile(path) {
9279
- if (!existsSync4(path))
10443
+ if (!existsSync7(path))
9280
10444
  return "";
9281
10445
  return readFileSync3(path, "utf-8");
9282
10446
  }
9283
10447
  function writeTomlFile(path, content) {
9284
10448
  const dir = dirname3(path);
9285
- if (!existsSync4(dir))
10449
+ if (!existsSync7(dir))
9286
10450
  mkdirSync3(dir, { recursive: true });
9287
10451
  writeFileSync2(path, content);
9288
10452
  }
9289
10453
  function registerClaude(binPath, global) {
9290
- const configPath = global ? join4(HOME2, ".claude", ".mcp.json") : join4(process.cwd(), ".mcp.json");
9291
- const config = readJsonFile(configPath);
10454
+ const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10455
+ const config = readJsonFile2(configPath);
9292
10456
  if (!config["mcpServers"]) {
9293
10457
  config["mcpServers"] = {};
9294
10458
  }
@@ -9297,25 +10461,25 @@ function registerClaude(binPath, global) {
9297
10461
  command: binPath,
9298
10462
  args: []
9299
10463
  };
9300
- writeJsonFile(configPath, config);
10464
+ writeJsonFile2(configPath, config);
9301
10465
  const scope = global ? "global" : "project";
9302
10466
  console.log(chalk.green(`Claude Code (${scope}): registered in ${configPath}`));
9303
10467
  }
9304
10468
  function unregisterClaude(global) {
9305
- const configPath = global ? join4(HOME2, ".claude", ".mcp.json") : join4(process.cwd(), ".mcp.json");
9306
- const config = readJsonFile(configPath);
10469
+ const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10470
+ const config = readJsonFile2(configPath);
9307
10471
  const servers = config["mcpServers"];
9308
10472
  if (!servers || !("todos" in servers)) {
9309
10473
  console.log(chalk.dim(`Claude Code: todos not found in ${configPath}`));
9310
10474
  return;
9311
10475
  }
9312
10476
  delete servers["todos"];
9313
- writeJsonFile(configPath, config);
10477
+ writeJsonFile2(configPath, config);
9314
10478
  const scope = global ? "global" : "project";
9315
10479
  console.log(chalk.green(`Claude Code (${scope}): unregistered from ${configPath}`));
9316
10480
  }
9317
10481
  function registerCodex(binPath) {
9318
- const configPath = join4(HOME2, ".codex", "config.toml");
10482
+ const configPath = join7(HOME2, ".codex", "config.toml");
9319
10483
  let content = readTomlFile(configPath);
9320
10484
  content = removeTomlBlock(content, "mcp_servers.todos");
9321
10485
  const block = `
@@ -9329,7 +10493,7 @@ args = []
9329
10493
  console.log(chalk.green(`Codex CLI: registered in ${configPath}`));
9330
10494
  }
9331
10495
  function unregisterCodex() {
9332
- const configPath = join4(HOME2, ".codex", "config.toml");
10496
+ const configPath = join7(HOME2, ".codex", "config.toml");
9333
10497
  let content = readTomlFile(configPath);
9334
10498
  if (!content.includes("[mcp_servers.todos]")) {
9335
10499
  console.log(chalk.dim(`Codex CLI: todos not found in ${configPath}`));
@@ -9362,8 +10526,8 @@ function removeTomlBlock(content, blockName) {
9362
10526
  `);
9363
10527
  }
9364
10528
  function registerGemini(binPath) {
9365
- const configPath = join4(HOME2, ".gemini", "settings.json");
9366
- const config = readJsonFile(configPath);
10529
+ const configPath = join7(HOME2, ".gemini", "settings.json");
10530
+ const config = readJsonFile2(configPath);
9367
10531
  if (!config["mcpServers"]) {
9368
10532
  config["mcpServers"] = {};
9369
10533
  }
@@ -9372,19 +10536,19 @@ function registerGemini(binPath) {
9372
10536
  command: binPath,
9373
10537
  args: []
9374
10538
  };
9375
- writeJsonFile(configPath, config);
10539
+ writeJsonFile2(configPath, config);
9376
10540
  console.log(chalk.green(`Gemini CLI: registered in ${configPath}`));
9377
10541
  }
9378
10542
  function unregisterGemini() {
9379
- const configPath = join4(HOME2, ".gemini", "settings.json");
9380
- const config = readJsonFile(configPath);
10543
+ const configPath = join7(HOME2, ".gemini", "settings.json");
10544
+ const config = readJsonFile2(configPath);
9381
10545
  const servers = config["mcpServers"];
9382
10546
  if (!servers || !("todos" in servers)) {
9383
10547
  console.log(chalk.dim(`Gemini CLI: todos not found in ${configPath}`));
9384
10548
  return;
9385
10549
  }
9386
10550
  delete servers["todos"];
9387
- writeJsonFile(configPath, config);
10551
+ writeJsonFile2(configPath, config);
9388
10552
  console.log(chalk.green(`Gemini CLI: unregistered from ${configPath}`));
9389
10553
  }
9390
10554
  function registerMcp(agent, global) {