@hasna/todos 0.3.6 → 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/mcp/index.js CHANGED
@@ -4010,6 +4010,15 @@ class TaskNotFoundError extends Error {
4010
4010
  this.name = "TaskNotFoundError";
4011
4011
  }
4012
4012
  }
4013
+ class PlanNotFoundError extends Error {
4014
+ planId;
4015
+ constructor(planId) {
4016
+ super(`Plan not found: ${planId}`);
4017
+ this.planId = planId;
4018
+ this.name = "PlanNotFoundError";
4019
+ }
4020
+ }
4021
+
4013
4022
  class LockError extends Error {
4014
4023
  taskId;
4015
4024
  lockedBy;
@@ -4037,19 +4046,54 @@ import { Database } from "bun:sqlite";
4037
4046
  import { existsSync, mkdirSync } from "fs";
4038
4047
  import { dirname, join, resolve } from "path";
4039
4048
  var LOCK_EXPIRY_MINUTES = 30;
4049
+ function isInMemoryDb(path) {
4050
+ return path === ":memory:" || path.startsWith("file::memory:");
4051
+ }
4052
+ function findNearestTodosDb(startDir) {
4053
+ let dir = resolve(startDir);
4054
+ while (true) {
4055
+ const candidate = join(dir, ".todos", "todos.db");
4056
+ if (existsSync(candidate))
4057
+ return candidate;
4058
+ const parent = dirname(dir);
4059
+ if (parent === dir)
4060
+ break;
4061
+ dir = parent;
4062
+ }
4063
+ return null;
4064
+ }
4065
+ function findGitRoot(startDir) {
4066
+ let dir = resolve(startDir);
4067
+ while (true) {
4068
+ if (existsSync(join(dir, ".git")))
4069
+ return dir;
4070
+ const parent = dirname(dir);
4071
+ if (parent === dir)
4072
+ break;
4073
+ dir = parent;
4074
+ }
4075
+ return null;
4076
+ }
4040
4077
  function getDbPath() {
4041
4078
  if (process.env["TODOS_DB_PATH"]) {
4042
4079
  return process.env["TODOS_DB_PATH"];
4043
4080
  }
4044
4081
  const cwd = process.cwd();
4045
- const localDb = join(cwd, ".todos", "todos.db");
4046
- if (existsSync(localDb)) {
4047
- return localDb;
4082
+ const nearest = findNearestTodosDb(cwd);
4083
+ if (nearest)
4084
+ return nearest;
4085
+ if (process.env["TODOS_DB_SCOPE"] === "project") {
4086
+ const gitRoot = findGitRoot(cwd);
4087
+ if (gitRoot) {
4088
+ return join(gitRoot, ".todos", "todos.db");
4089
+ }
4048
4090
  }
4049
4091
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4050
4092
  return join(home, ".todos", "todos.db");
4051
4093
  }
4052
4094
  function ensureDir(filePath) {
4095
+ if (isInMemoryDb(filePath))
4096
+ return;
4053
4097
  const dir = dirname(resolve(filePath));
4054
4098
  if (!existsSync(dir)) {
4055
4099
  mkdirSync(dir, { recursive: true });
@@ -4131,6 +4175,37 @@ var MIGRATIONS = [
4131
4175
  );
4132
4176
 
4133
4177
  INSERT OR IGNORE INTO _migrations (id) VALUES (1);
4178
+ `,
4179
+ `
4180
+ ALTER TABLE projects ADD COLUMN task_list_id TEXT;
4181
+ INSERT OR IGNORE INTO _migrations (id) VALUES (2);
4182
+ `,
4183
+ `
4184
+ CREATE TABLE IF NOT EXISTS task_tags (
4185
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
4186
+ tag TEXT NOT NULL,
4187
+ PRIMARY KEY (task_id, tag)
4188
+ );
4189
+ CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags(tag);
4190
+ CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id);
4191
+
4192
+ INSERT OR IGNORE INTO _migrations (id) VALUES (3);
4193
+ `,
4194
+ `
4195
+ CREATE TABLE IF NOT EXISTS plans (
4196
+ id TEXT PRIMARY KEY,
4197
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
4198
+ name TEXT NOT NULL,
4199
+ description TEXT,
4200
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
4201
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
4202
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4203
+ );
4204
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans(project_id);
4205
+ CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
4206
+ ALTER TABLE tasks ADD COLUMN plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL;
4207
+ CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id);
4208
+ INSERT OR IGNORE INTO _migrations (id) VALUES (4);
4134
4209
  `
4135
4210
  ];
4136
4211
  var _db = null;
@@ -4144,6 +4219,7 @@ function getDatabase(dbPath) {
4144
4219
  _db.run("PRAGMA busy_timeout = 5000");
4145
4220
  _db.run("PRAGMA foreign_keys = ON");
4146
4221
  runMigrations(_db);
4222
+ backfillTaskTags(_db);
4147
4223
  return _db;
4148
4224
  }
4149
4225
  function runMigrations(db) {
@@ -4159,6 +4235,35 @@ function runMigrations(db) {
4159
4235
  }
4160
4236
  }
4161
4237
  }
4238
+ function backfillTaskTags(db) {
4239
+ try {
4240
+ const count = db.query("SELECT COUNT(*) as count FROM task_tags").get();
4241
+ if (count && count.count > 0)
4242
+ return;
4243
+ } catch {
4244
+ return;
4245
+ }
4246
+ try {
4247
+ const rows = db.query("SELECT id, tags FROM tasks WHERE tags IS NOT NULL AND tags != '[]'").all();
4248
+ if (rows.length === 0)
4249
+ return;
4250
+ const insert = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
4251
+ for (const row of rows) {
4252
+ if (!row.tags)
4253
+ continue;
4254
+ let tags = [];
4255
+ try {
4256
+ tags = JSON.parse(row.tags);
4257
+ } catch {
4258
+ continue;
4259
+ }
4260
+ for (const tag of tags) {
4261
+ if (tag)
4262
+ insert.run(row.id, tag);
4263
+ }
4264
+ }
4265
+ } catch {}
4266
+ }
4162
4267
  function now() {
4163
4268
  return new Date().toISOString();
4164
4269
  }
@@ -4172,6 +4277,14 @@ function isLockExpired(lockedAt) {
4172
4277
  const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
4173
4278
  return Date.now() - lockTime > expiryMs;
4174
4279
  }
4280
+ function lockExpiryCutoff(nowMs = Date.now()) {
4281
+ const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
4282
+ return new Date(nowMs - expiryMs).toISOString();
4283
+ }
4284
+ function clearExpiredLocks(db) {
4285
+ const cutoff = lockExpiryCutoff();
4286
+ db.run("UPDATE tasks SET locked_by = NULL, locked_at = NULL WHERE locked_at IS NOT NULL AND locked_at < ?", [cutoff]);
4287
+ }
4175
4288
  function resolvePartialId(db, table, partialId) {
4176
4289
  if (partialId.length >= 36) {
4177
4290
  const row = db.query(`SELECT id FROM ${table} WHERE id = ?`).get(partialId);
@@ -4197,15 +4310,30 @@ function rowToTask(row) {
4197
4310
  priority: row.priority
4198
4311
  };
4199
4312
  }
4313
+ function insertTaskTags(taskId, tags, db) {
4314
+ if (tags.length === 0)
4315
+ return;
4316
+ const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
4317
+ for (const tag of tags) {
4318
+ if (tag)
4319
+ stmt.run(taskId, tag);
4320
+ }
4321
+ }
4322
+ function replaceTaskTags(taskId, tags, db) {
4323
+ db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
4324
+ insertTaskTags(taskId, tags, db);
4325
+ }
4200
4326
  function createTask(input, db) {
4201
4327
  const d = db || getDatabase();
4202
4328
  const id = uuid();
4203
4329
  const timestamp = now();
4204
- 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)
4205
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
4330
+ const tags = input.tags || [];
4331
+ 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)
4332
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
4206
4333
  id,
4207
4334
  input.project_id || null,
4208
4335
  input.parent_id || null,
4336
+ input.plan_id || null,
4209
4337
  input.title,
4210
4338
  input.description || null,
4211
4339
  input.status || "pending",
@@ -4214,11 +4342,14 @@ function createTask(input, db) {
4214
4342
  input.assigned_to || null,
4215
4343
  input.session_id || null,
4216
4344
  input.working_dir || null,
4217
- JSON.stringify(input.tags || []),
4345
+ JSON.stringify(tags),
4218
4346
  JSON.stringify(input.metadata || {}),
4219
4347
  timestamp,
4220
4348
  timestamp
4221
4349
  ]);
4350
+ if (tags.length > 0) {
4351
+ insertTaskTags(id, tags, d);
4352
+ }
4222
4353
  return getTask(id, d);
4223
4354
  }
4224
4355
  function getTask(id, db) {
@@ -4256,6 +4387,7 @@ function getTaskWithRelations(id, db) {
4256
4387
  }
4257
4388
  function listTasks(filter = {}, db) {
4258
4389
  const d = db || getDatabase();
4390
+ clearExpiredLocks(d);
4259
4391
  const conditions = [];
4260
4392
  const params = [];
4261
4393
  if (filter.project_id) {
@@ -4301,9 +4433,13 @@ function listTasks(filter = {}, db) {
4301
4433
  params.push(filter.session_id);
4302
4434
  }
4303
4435
  if (filter.tags && filter.tags.length > 0) {
4304
- const tagConditions = filter.tags.map(() => "tags LIKE ?");
4305
- conditions.push(`(${tagConditions.join(" OR ")})`);
4306
- params.push(...filter.tags.map((t) => `%"${t}"%`));
4436
+ const placeholders = filter.tags.map(() => "?").join(",");
4437
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
4438
+ params.push(...filter.tags);
4439
+ }
4440
+ if (filter.plan_id) {
4441
+ conditions.push("plan_id = ?");
4442
+ params.push(filter.plan_id);
4307
4443
  }
4308
4444
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4309
4445
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
@@ -4353,12 +4489,19 @@ function updateTask(id, input, db) {
4353
4489
  sets.push("metadata = ?");
4354
4490
  params.push(JSON.stringify(input.metadata));
4355
4491
  }
4492
+ if (input.plan_id !== undefined) {
4493
+ sets.push("plan_id = ?");
4494
+ params.push(input.plan_id);
4495
+ }
4356
4496
  params.push(id, input.version);
4357
4497
  const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
4358
4498
  if (result.changes === 0) {
4359
4499
  const current = getTask(id, d);
4360
4500
  throw new VersionConflictError(id, input.version, current?.version ?? -1);
4361
4501
  }
4502
+ if (input.tags !== undefined) {
4503
+ replaceTaskTags(id, input.tags, d);
4504
+ }
4362
4505
  return getTask(id, d);
4363
4506
  }
4364
4507
  function deleteTask(id, db) {
@@ -4368,15 +4511,18 @@ function deleteTask(id, db) {
4368
4511
  }
4369
4512
  function startTask(id, agentId, db) {
4370
4513
  const d = db || getDatabase();
4371
- const task = getTask(id, d);
4372
- if (!task)
4373
- throw new TaskNotFoundError(id);
4374
- if (task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
4375
- throw new LockError(id, task.locked_by);
4376
- }
4514
+ const cutoff = lockExpiryCutoff();
4377
4515
  const timestamp = now();
4378
- d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
4379
- WHERE id = ?`, [agentId, agentId, timestamp, timestamp, id]);
4516
+ const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
4517
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, id, agentId, cutoff]);
4518
+ if (result.changes === 0) {
4519
+ const current = getTask(id, d);
4520
+ if (!current)
4521
+ throw new TaskNotFoundError(id);
4522
+ if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
4523
+ throw new LockError(id, current.locked_by);
4524
+ }
4525
+ }
4380
4526
  return getTask(id, d);
4381
4527
  }
4382
4528
  function completeTask(id, agentId, db) {
@@ -4397,20 +4543,26 @@ function lockTask(id, agentId, db) {
4397
4543
  const task = getTask(id, d);
4398
4544
  if (!task)
4399
4545
  throw new TaskNotFoundError(id);
4400
- if (task.locked_by === agentId) {
4546
+ if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
4401
4547
  return { success: true, locked_by: agentId, locked_at: task.locked_at };
4402
4548
  }
4403
- if (task.locked_by && !isLockExpired(task.locked_at)) {
4404
- return {
4405
- success: false,
4406
- locked_by: task.locked_by,
4407
- locked_at: task.locked_at,
4408
- error: `Task is locked by ${task.locked_by}`
4409
- };
4410
- }
4549
+ const cutoff = lockExpiryCutoff();
4411
4550
  const timestamp = now();
4412
- d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
4413
- WHERE id = ?`, [agentId, timestamp, timestamp, id]);
4551
+ const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
4552
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
4553
+ if (result.changes === 0) {
4554
+ const current = getTask(id, d);
4555
+ if (!current)
4556
+ throw new TaskNotFoundError(id);
4557
+ if (current.locked_by && !isLockExpired(current.locked_at)) {
4558
+ return {
4559
+ success: false,
4560
+ locked_by: current.locked_by,
4561
+ locked_at: current.locked_at,
4562
+ error: `Task is locked by ${current.locked_by}`
4563
+ };
4564
+ }
4565
+ }
4414
4566
  return { success: true, locked_by: agentId, locked_at: timestamp };
4415
4567
  }
4416
4568
  function unlockTask(id, agentId, db) {
@@ -4485,12 +4637,16 @@ function getComment(id, db) {
4485
4637
  }
4486
4638
 
4487
4639
  // src/db/projects.ts
4640
+ function slugify(name) {
4641
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
4642
+ }
4488
4643
  function createProject(input, db) {
4489
4644
  const d = db || getDatabase();
4490
4645
  const id = uuid();
4491
4646
  const timestamp = now();
4492
- d.run(`INSERT INTO projects (id, name, path, description, created_at, updated_at)
4493
- VALUES (?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, timestamp, timestamp]);
4647
+ const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
4648
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, created_at, updated_at)
4649
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, timestamp, timestamp]);
4494
4650
  return getProject(id, d);
4495
4651
  }
4496
4652
  function getProject(id, db) {
@@ -4503,6 +4659,64 @@ function listProjects(db) {
4503
4659
  return d.query("SELECT * FROM projects ORDER BY name").all();
4504
4660
  }
4505
4661
 
4662
+ // src/db/plans.ts
4663
+ function createPlan(input, db) {
4664
+ const d = db || getDatabase();
4665
+ const id = uuid();
4666
+ const timestamp = now();
4667
+ d.run(`INSERT INTO plans (id, project_id, name, description, status, created_at, updated_at)
4668
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
4669
+ id,
4670
+ input.project_id || null,
4671
+ input.name,
4672
+ input.description || null,
4673
+ input.status || "active",
4674
+ timestamp,
4675
+ timestamp
4676
+ ]);
4677
+ return getPlan(id, d);
4678
+ }
4679
+ function getPlan(id, db) {
4680
+ const d = db || getDatabase();
4681
+ const row = d.query("SELECT * FROM plans WHERE id = ?").get(id);
4682
+ return row;
4683
+ }
4684
+ function listPlans(projectId, db) {
4685
+ const d = db || getDatabase();
4686
+ if (projectId) {
4687
+ return d.query("SELECT * FROM plans WHERE project_id = ? ORDER BY created_at DESC").all(projectId);
4688
+ }
4689
+ return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
4690
+ }
4691
+ function updatePlan(id, input, db) {
4692
+ const d = db || getDatabase();
4693
+ const plan = getPlan(id, d);
4694
+ if (!plan)
4695
+ throw new PlanNotFoundError(id);
4696
+ const sets = ["updated_at = ?"];
4697
+ const params = [now()];
4698
+ if (input.name !== undefined) {
4699
+ sets.push("name = ?");
4700
+ params.push(input.name);
4701
+ }
4702
+ if (input.description !== undefined) {
4703
+ sets.push("description = ?");
4704
+ params.push(input.description);
4705
+ }
4706
+ if (input.status !== undefined) {
4707
+ sets.push("status = ?");
4708
+ params.push(input.status);
4709
+ }
4710
+ params.push(id);
4711
+ d.run(`UPDATE plans SET ${sets.join(", ")} WHERE id = ?`, params);
4712
+ return getPlan(id, d);
4713
+ }
4714
+ function deletePlan(id, db) {
4715
+ const d = db || getDatabase();
4716
+ const result = d.run("DELETE FROM plans WHERE id = ?", [id]);
4717
+ return result.changes > 0;
4718
+ }
4719
+
4506
4720
  // src/lib/search.ts
4507
4721
  function rowToTask2(row) {
4508
4722
  return {
@@ -4515,8 +4729,9 @@ function rowToTask2(row) {
4515
4729
  }
4516
4730
  function searchTasks(query, projectId, db) {
4517
4731
  const d = db || getDatabase();
4732
+ clearExpiredLocks(d);
4518
4733
  const pattern = `%${query}%`;
4519
- let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)`;
4734
+ 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 ?))`;
4520
4735
  const params = [pattern, pattern, pattern];
4521
4736
  if (projectId) {
4522
4737
  sql += " AND project_id = ?";
@@ -4530,11 +4745,32 @@ function searchTasks(query, projectId, db) {
4530
4745
  }
4531
4746
 
4532
4747
  // src/lib/claude-tasks.ts
4533
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, writeFileSync } from "fs";
4748
+ import { existsSync as existsSync3 } from "fs";
4749
+ import { join as join3 } from "path";
4750
+
4751
+ // src/lib/sync-utils.ts
4752
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync as readdirSync2, statSync, writeFileSync } from "fs";
4534
4753
  import { join as join2 } from "path";
4535
4754
  var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4536
- function getTaskListDir(taskListId) {
4537
- return join2(HOME, ".claude", "tasks", taskListId);
4755
+ function ensureDir2(dir) {
4756
+ if (!existsSync2(dir))
4757
+ mkdirSync2(dir, { recursive: true });
4758
+ }
4759
+ function listJsonFiles(dir) {
4760
+ if (!existsSync2(dir))
4761
+ return [];
4762
+ return readdirSync2(dir).filter((f) => f.endsWith(".json"));
4763
+ }
4764
+ function readJsonFile(path) {
4765
+ try {
4766
+ return JSON.parse(readFileSync(path, "utf-8"));
4767
+ } catch {
4768
+ return null;
4769
+ }
4770
+ }
4771
+ function writeJsonFile(path, data) {
4772
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
4773
+ `);
4538
4774
  }
4539
4775
  function readHighWaterMark(dir) {
4540
4776
  const path = join2(dir, ".highwatermark");
@@ -4546,17 +4782,34 @@ function readHighWaterMark(dir) {
4546
4782
  function writeHighWaterMark(dir, value) {
4547
4783
  writeFileSync(join2(dir, ".highwatermark"), String(value));
4548
4784
  }
4549
- function readClaudeTask(dir, filename) {
4785
+ function getFileMtimeMs(path) {
4550
4786
  try {
4551
- const content = readFileSync(join2(dir, filename), "utf-8");
4552
- return JSON.parse(content);
4787
+ return statSync(path).mtimeMs;
4553
4788
  } catch {
4554
4789
  return null;
4555
4790
  }
4556
4791
  }
4792
+ function parseTimestamp(value) {
4793
+ if (typeof value !== "string")
4794
+ return null;
4795
+ const parsed = Date.parse(value);
4796
+ return Number.isNaN(parsed) ? null : parsed;
4797
+ }
4798
+ function appendSyncConflict(metadata, conflict, limit = 5) {
4799
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
4800
+ const next = [conflict, ...current].slice(0, limit);
4801
+ return { ...metadata, sync_conflicts: next };
4802
+ }
4803
+
4804
+ // src/lib/claude-tasks.ts
4805
+ function getTaskListDir(taskListId) {
4806
+ return join3(HOME, ".claude", "tasks", taskListId);
4807
+ }
4808
+ function readClaudeTask(dir, filename) {
4809
+ return readJsonFile(join3(dir, filename));
4810
+ }
4557
4811
  function writeClaudeTask(dir, task) {
4558
- writeFileSync(join2(dir, `${task.id}.json`), JSON.stringify(task, null, 2) + `
4559
- `);
4812
+ writeJsonFile(join3(dir, `${task.id}.json`), task);
4560
4813
  }
4561
4814
  function toClaudeStatus(status) {
4562
4815
  if (status === "pending" || status === "in_progress" || status === "completed") {
@@ -4567,7 +4820,7 @@ function toClaudeStatus(status) {
4567
4820
  function toSqliteStatus(status) {
4568
4821
  return status;
4569
4822
  }
4570
- function taskToClaudeTask(task, claudeTaskId) {
4823
+ function taskToClaudeTask(task, claudeTaskId, existingMeta) {
4571
4824
  return {
4572
4825
  id: claudeTaskId,
4573
4826
  subject: task.title,
@@ -4578,39 +4831,80 @@ function taskToClaudeTask(task, claudeTaskId) {
4578
4831
  blocks: [],
4579
4832
  blockedBy: [],
4580
4833
  metadata: {
4834
+ ...existingMeta || {},
4581
4835
  todos_id: task.id,
4582
- priority: task.priority
4836
+ priority: task.priority,
4837
+ todos_updated_at: task.updated_at,
4838
+ todos_version: task.version
4583
4839
  }
4584
4840
  };
4585
4841
  }
4586
- function pushToClaudeTaskList(taskListId, projectId) {
4842
+ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
4587
4843
  const dir = getTaskListDir(taskListId);
4588
- if (!existsSync2(dir))
4589
- mkdirSync2(dir, { recursive: true });
4844
+ if (!existsSync3(dir))
4845
+ ensureDir2(dir);
4590
4846
  const filter = {};
4591
4847
  if (projectId)
4592
4848
  filter["project_id"] = projectId;
4593
4849
  const tasks = listTasks(filter);
4594
4850
  const existingByTodosId = new Map;
4595
- const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
4851
+ const files = listJsonFiles(dir);
4596
4852
  for (const f of files) {
4853
+ const path = join3(dir, f);
4597
4854
  const ct = readClaudeTask(dir, f);
4598
4855
  if (ct?.metadata?.["todos_id"]) {
4599
- existingByTodosId.set(ct.metadata["todos_id"], ct);
4856
+ existingByTodosId.set(ct.metadata["todos_id"], { task: ct, mtimeMs: getFileMtimeMs(path) });
4600
4857
  }
4601
4858
  }
4602
4859
  let hwm = readHighWaterMark(dir);
4603
4860
  let pushed = 0;
4604
4861
  const errors2 = [];
4862
+ const prefer = options.prefer || "remote";
4605
4863
  for (const task of tasks) {
4606
4864
  try {
4607
4865
  const existing = existingByTodosId.get(task.id);
4608
4866
  if (existing) {
4609
- const updated = taskToClaudeTask(task, existing.id);
4610
- updated.blocks = existing.blocks;
4611
- updated.blockedBy = existing.blockedBy;
4612
- updated.activeForm = existing.activeForm;
4867
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
4868
+ const localUpdatedAt = parseTimestamp(task.updated_at);
4869
+ const remoteUpdatedAt = existing.mtimeMs;
4870
+ let recordConflict = false;
4871
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
4872
+ if (prefer === "remote") {
4873
+ const conflict = {
4874
+ agent: "claude",
4875
+ direction: "push",
4876
+ prefer,
4877
+ local_updated_at: task.updated_at,
4878
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
4879
+ detected_at: new Date().toISOString()
4880
+ };
4881
+ const newMeta = appendSyncConflict(task.metadata, conflict);
4882
+ updateTask(task.id, { version: task.version, metadata: newMeta });
4883
+ errors2.push(`conflict push ${task.id}: remote newer`);
4884
+ continue;
4885
+ }
4886
+ recordConflict = true;
4887
+ }
4888
+ const updated = taskToClaudeTask(task, existing.task.id, existing.task.metadata);
4889
+ updated.blocks = existing.task.blocks;
4890
+ updated.blockedBy = existing.task.blockedBy;
4891
+ updated.activeForm = existing.task.activeForm;
4613
4892
  writeClaudeTask(dir, updated);
4893
+ if (recordConflict) {
4894
+ const latest = getTask(task.id);
4895
+ if (latest) {
4896
+ const conflict = {
4897
+ agent: "claude",
4898
+ direction: "push",
4899
+ prefer,
4900
+ local_updated_at: latest.updated_at,
4901
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
4902
+ detected_at: new Date().toISOString()
4903
+ };
4904
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
4905
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
4906
+ }
4907
+ }
4614
4908
  } else {
4615
4909
  const claudeId = String(hwm);
4616
4910
  hwm++;
@@ -4630,14 +4924,15 @@ function pushToClaudeTaskList(taskListId, projectId) {
4630
4924
  writeHighWaterMark(dir, hwm);
4631
4925
  return { pushed, pulled: 0, errors: errors2 };
4632
4926
  }
4633
- function pullFromClaudeTaskList(taskListId, projectId) {
4927
+ function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
4634
4928
  const dir = getTaskListDir(taskListId);
4635
- if (!existsSync2(dir)) {
4929
+ if (!existsSync3(dir)) {
4636
4930
  return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
4637
4931
  }
4638
4932
  const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
4639
4933
  let pulled = 0;
4640
4934
  const errors2 = [];
4935
+ const prefer = options.prefer || "remote";
4641
4936
  const allTasks = listTasks({});
4642
4937
  const byClaudeId = new Map;
4643
4938
  for (const t of allTasks) {
@@ -4651,6 +4946,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
4651
4946
  }
4652
4947
  for (const f of files) {
4653
4948
  try {
4949
+ const filePath = join3(dir, f);
4654
4950
  const ct = readClaudeTask(dir, f);
4655
4951
  if (!ct)
4656
4952
  continue;
@@ -4661,13 +4957,33 @@ function pullFromClaudeTaskList(taskListId, projectId) {
4661
4957
  const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
4662
4958
  const existing = existingByMapping || existingByTodos;
4663
4959
  if (existing) {
4960
+ const lastSyncedAt = parseTimestamp(ct.metadata?.["todos_updated_at"]);
4961
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
4962
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
4963
+ let conflictMeta = null;
4964
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
4965
+ const conflict = {
4966
+ agent: "claude",
4967
+ direction: "pull",
4968
+ prefer,
4969
+ local_updated_at: existing.updated_at,
4970
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
4971
+ detected_at: new Date().toISOString()
4972
+ };
4973
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
4974
+ if (prefer === "local") {
4975
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
4976
+ errors2.push(`conflict pull ${existing.id}: local newer`);
4977
+ continue;
4978
+ }
4979
+ }
4664
4980
  updateTask(existing.id, {
4665
4981
  version: existing.version,
4666
4982
  title: ct.subject,
4667
4983
  description: ct.description || undefined,
4668
4984
  status: toSqliteStatus(ct.status),
4669
4985
  assigned_to: ct.owner || undefined,
4670
- metadata: { ...existing.metadata, claude_task_id: ct.id }
4986
+ metadata: { ...conflictMeta || existing.metadata, claude_task_id: ct.id, ...ct.metadata }
4671
4987
  });
4672
4988
  } else {
4673
4989
  createTask({
@@ -4676,7 +4992,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
4676
4992
  status: toSqliteStatus(ct.status),
4677
4993
  assigned_to: ct.owner || undefined,
4678
4994
  project_id: projectId,
4679
- metadata: { claude_task_id: ct.id },
4995
+ metadata: { ...ct.metadata, claude_task_id: ct.id },
4680
4996
  priority: ct.metadata?.["priority"] || "medium"
4681
4997
  });
4682
4998
  }
@@ -4687,9 +5003,9 @@ function pullFromClaudeTaskList(taskListId, projectId) {
4687
5003
  }
4688
5004
  return { pushed: 0, pulled, errors: errors2 };
4689
5005
  }
4690
- function syncClaudeTaskList(taskListId, projectId) {
4691
- const pullResult = pullFromClaudeTaskList(taskListId, projectId);
4692
- const pushResult = pushToClaudeTaskList(taskListId, projectId);
5006
+ function syncClaudeTaskList(taskListId, projectId, options = {}) {
5007
+ const pullResult = pullFromClaudeTaskList(taskListId, projectId, options);
5008
+ const pushResult = pushToClaudeTaskList(taskListId, projectId, options);
4693
5009
  return {
4694
5010
  pushed: pushResult.pushed,
4695
5011
  pulled: pullResult.pulled,
@@ -4697,6 +5013,326 @@ function syncClaudeTaskList(taskListId, projectId) {
4697
5013
  };
4698
5014
  }
4699
5015
 
5016
+ // src/lib/agent-tasks.ts
5017
+ import { existsSync as existsSync5 } from "fs";
5018
+ import { join as join5 } from "path";
5019
+
5020
+ // src/lib/config.ts
5021
+ import { existsSync as existsSync4 } from "fs";
5022
+ import { join as join4 } from "path";
5023
+ var CONFIG_PATH = join4(HOME, ".todos", "config.json");
5024
+ var cached = null;
5025
+ function normalizeAgent(agent) {
5026
+ return agent.trim().toLowerCase();
5027
+ }
5028
+ function loadConfig() {
5029
+ if (cached)
5030
+ return cached;
5031
+ if (!existsSync4(CONFIG_PATH)) {
5032
+ cached = {};
5033
+ return cached;
5034
+ }
5035
+ const config = readJsonFile(CONFIG_PATH) || {};
5036
+ if (typeof config.sync_agents === "string") {
5037
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
5038
+ }
5039
+ cached = config;
5040
+ return cached;
5041
+ }
5042
+ function getSyncAgentsFromConfig() {
5043
+ const config = loadConfig();
5044
+ const agents = config.sync_agents;
5045
+ if (Array.isArray(agents) && agents.length > 0)
5046
+ return agents.map(normalizeAgent);
5047
+ return null;
5048
+ }
5049
+ function getAgentTaskListId(agent) {
5050
+ const config = loadConfig();
5051
+ const key = normalizeAgent(agent);
5052
+ return config.agents?.[key]?.task_list_id || config.task_list_id || null;
5053
+ }
5054
+ function getAgentTasksDir(agent) {
5055
+ const config = loadConfig();
5056
+ const key = normalizeAgent(agent);
5057
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
5058
+ }
5059
+
5060
+ // src/lib/agent-tasks.ts
5061
+ function agentBaseDir(agent) {
5062
+ const key = `TODOS_${agent.toUpperCase()}_TASKS_DIR`;
5063
+ return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join5(HOME, ".todos", "agents");
5064
+ }
5065
+ function getTaskListDir2(agent, taskListId) {
5066
+ return join5(agentBaseDir(agent), agent, taskListId);
5067
+ }
5068
+ function readAgentTask(dir, filename) {
5069
+ return readJsonFile(join5(dir, filename));
5070
+ }
5071
+ function writeAgentTask(dir, task) {
5072
+ writeJsonFile(join5(dir, `${task.id}.json`), task);
5073
+ }
5074
+ function taskToAgentTask(task, externalId, existingMeta) {
5075
+ return {
5076
+ id: externalId,
5077
+ title: task.title,
5078
+ description: task.description || "",
5079
+ status: task.status,
5080
+ priority: task.priority,
5081
+ assigned_to: task.assigned_to || task.agent_id || "",
5082
+ tags: task.tags || [],
5083
+ metadata: {
5084
+ ...existingMeta || {},
5085
+ ...task.metadata,
5086
+ todos_id: task.id,
5087
+ todos_updated_at: task.updated_at,
5088
+ todos_version: task.version
5089
+ }
5090
+ };
5091
+ }
5092
+ function metadataKey(agent) {
5093
+ return `${agent}_task_id`;
5094
+ }
5095
+ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
5096
+ const dir = getTaskListDir2(agent, taskListId);
5097
+ if (!existsSync5(dir))
5098
+ ensureDir2(dir);
5099
+ const filter = {};
5100
+ if (projectId)
5101
+ filter["project_id"] = projectId;
5102
+ const tasks = listTasks(filter);
5103
+ const existingByTodosId = new Map;
5104
+ const files = listJsonFiles(dir);
5105
+ for (const f of files) {
5106
+ const path = join5(dir, f);
5107
+ const at = readAgentTask(dir, f);
5108
+ if (at?.metadata?.["todos_id"]) {
5109
+ existingByTodosId.set(at.metadata["todos_id"], { task: at, mtimeMs: getFileMtimeMs(path) });
5110
+ }
5111
+ }
5112
+ let hwm = readHighWaterMark(dir);
5113
+ let pushed = 0;
5114
+ const errors2 = [];
5115
+ const metaKey = metadataKey(agent);
5116
+ const prefer = options.prefer || "remote";
5117
+ for (const task of tasks) {
5118
+ try {
5119
+ const existing = existingByTodosId.get(task.id);
5120
+ if (existing) {
5121
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
5122
+ const localUpdatedAt = parseTimestamp(task.updated_at);
5123
+ const remoteUpdatedAt = existing.mtimeMs;
5124
+ let recordConflict = false;
5125
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
5126
+ if (prefer === "remote") {
5127
+ const conflict = {
5128
+ agent,
5129
+ direction: "push",
5130
+ prefer,
5131
+ local_updated_at: task.updated_at,
5132
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
5133
+ detected_at: new Date().toISOString()
5134
+ };
5135
+ const newMeta = appendSyncConflict(task.metadata, conflict);
5136
+ updateTask(task.id, { version: task.version, metadata: newMeta });
5137
+ errors2.push(`conflict push ${task.id}: remote newer`);
5138
+ continue;
5139
+ }
5140
+ recordConflict = true;
5141
+ }
5142
+ const updated = taskToAgentTask(task, existing.task.id, existing.task.metadata);
5143
+ writeAgentTask(dir, updated);
5144
+ if (recordConflict) {
5145
+ const latest = getTask(task.id);
5146
+ if (latest) {
5147
+ const conflict = {
5148
+ agent,
5149
+ direction: "push",
5150
+ prefer,
5151
+ local_updated_at: latest.updated_at,
5152
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
5153
+ detected_at: new Date().toISOString()
5154
+ };
5155
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
5156
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
5157
+ }
5158
+ }
5159
+ } else {
5160
+ const externalId = String(hwm);
5161
+ hwm++;
5162
+ const at = taskToAgentTask(task, externalId);
5163
+ writeAgentTask(dir, at);
5164
+ const current = getTask(task.id);
5165
+ if (current) {
5166
+ const newMeta = { ...current.metadata, [metaKey]: externalId };
5167
+ updateTask(task.id, { version: current.version, metadata: newMeta });
5168
+ }
5169
+ }
5170
+ pushed++;
5171
+ } catch (e) {
5172
+ errors2.push(`push ${task.id}: ${e instanceof Error ? e.message : String(e)}`);
5173
+ }
5174
+ }
5175
+ writeHighWaterMark(dir, hwm);
5176
+ return { pushed, pulled: 0, errors: errors2 };
5177
+ }
5178
+ function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
5179
+ const dir = getTaskListDir2(agent, taskListId);
5180
+ if (!existsSync5(dir)) {
5181
+ return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
5182
+ }
5183
+ const files = listJsonFiles(dir);
5184
+ let pulled = 0;
5185
+ const errors2 = [];
5186
+ const metaKey = metadataKey(agent);
5187
+ const prefer = options.prefer || "remote";
5188
+ const allTasks = listTasks({});
5189
+ const byExternalId = new Map;
5190
+ const byTodosId = new Map;
5191
+ for (const t of allTasks) {
5192
+ const extId = t.metadata[metaKey];
5193
+ if (extId)
5194
+ byExternalId.set(String(extId), t);
5195
+ byTodosId.set(t.id, t);
5196
+ }
5197
+ for (const f of files) {
5198
+ try {
5199
+ const filePath = join5(dir, f);
5200
+ const at = readAgentTask(dir, f);
5201
+ if (!at)
5202
+ continue;
5203
+ if (at.metadata?.["_internal"])
5204
+ continue;
5205
+ const todosId = at.metadata?.["todos_id"];
5206
+ const existingByMapping = byExternalId.get(at.id);
5207
+ const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
5208
+ const existing = existingByMapping || existingByTodos;
5209
+ if (existing) {
5210
+ const lastSyncedAt = parseTimestamp(at.metadata?.["todos_updated_at"]);
5211
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
5212
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
5213
+ let conflictMeta = null;
5214
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
5215
+ const conflict = {
5216
+ agent,
5217
+ direction: "pull",
5218
+ prefer,
5219
+ local_updated_at: existing.updated_at,
5220
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
5221
+ detected_at: new Date().toISOString()
5222
+ };
5223
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
5224
+ if (prefer === "local") {
5225
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
5226
+ errors2.push(`conflict pull ${existing.id}: local newer`);
5227
+ continue;
5228
+ }
5229
+ }
5230
+ updateTask(existing.id, {
5231
+ version: existing.version,
5232
+ title: at.title,
5233
+ description: at.description || undefined,
5234
+ status: at.status,
5235
+ priority: at.priority,
5236
+ assigned_to: at.assigned_to || undefined,
5237
+ tags: at.tags || [],
5238
+ metadata: { ...conflictMeta || existing.metadata, ...at.metadata, [metaKey]: at.id }
5239
+ });
5240
+ } else {
5241
+ createTask({
5242
+ title: at.title,
5243
+ description: at.description || undefined,
5244
+ status: at.status,
5245
+ priority: at.priority || "medium",
5246
+ assigned_to: at.assigned_to || undefined,
5247
+ tags: at.tags || [],
5248
+ project_id: projectId,
5249
+ metadata: { ...at.metadata, [metaKey]: at.id }
5250
+ });
5251
+ }
5252
+ pulled++;
5253
+ } catch (e) {
5254
+ errors2.push(`pull ${f}: ${e instanceof Error ? e.message : String(e)}`);
5255
+ }
5256
+ }
5257
+ return { pushed: 0, pulled, errors: errors2 };
5258
+ }
5259
+ function syncAgentTaskList(agent, taskListId, projectId, options = {}) {
5260
+ const pullResult = pullFromAgentTaskList(agent, taskListId, projectId, options);
5261
+ const pushResult = pushToAgentTaskList(agent, taskListId, projectId, options);
5262
+ return {
5263
+ pushed: pushResult.pushed,
5264
+ pulled: pullResult.pulled,
5265
+ errors: [...pullResult.errors, ...pushResult.errors]
5266
+ };
5267
+ }
5268
+
5269
+ // src/lib/sync.ts
5270
+ function normalizeAgent2(agent) {
5271
+ return agent.trim().toLowerCase();
5272
+ }
5273
+ function isClaudeAgent(agent) {
5274
+ const a = normalizeAgent2(agent);
5275
+ return a === "claude" || a === "claude-code" || a === "claude_code";
5276
+ }
5277
+ function defaultSyncAgents() {
5278
+ const env = process.env["TODOS_SYNC_AGENTS"];
5279
+ if (env) {
5280
+ return env.split(",").map((a) => a.trim()).filter(Boolean);
5281
+ }
5282
+ const fromConfig = getSyncAgentsFromConfig();
5283
+ if (fromConfig && fromConfig.length > 0)
5284
+ return fromConfig;
5285
+ return ["claude", "codex", "gemini"];
5286
+ }
5287
+ function syncWithAgent(agent, taskListId, projectId, direction = "both", options = {}) {
5288
+ const normalized = normalizeAgent2(agent);
5289
+ if (isClaudeAgent(normalized)) {
5290
+ if (direction === "push")
5291
+ return pushToClaudeTaskList(taskListId, projectId, options);
5292
+ if (direction === "pull")
5293
+ return pullFromClaudeTaskList(taskListId, projectId, options);
5294
+ return syncClaudeTaskList(taskListId, projectId, options);
5295
+ }
5296
+ if (direction === "push")
5297
+ return pushToAgentTaskList(normalized, taskListId, projectId, options);
5298
+ if (direction === "pull")
5299
+ return pullFromAgentTaskList(normalized, taskListId, projectId, options);
5300
+ return syncAgentTaskList(normalized, taskListId, projectId, options);
5301
+ }
5302
+ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both", options = {}) {
5303
+ let pushed = 0;
5304
+ let pulled = 0;
5305
+ const errors2 = [];
5306
+ const normalized = agents.map(normalizeAgent2);
5307
+ if (direction === "pull" || direction === "both") {
5308
+ for (const agent of normalized) {
5309
+ const listId = taskListIdByAgent(agent);
5310
+ if (!listId) {
5311
+ errors2.push(`sync ${agent}: missing task list id`);
5312
+ continue;
5313
+ }
5314
+ const result = syncWithAgent(agent, listId, projectId, "pull", options);
5315
+ pushed += result.pushed;
5316
+ pulled += result.pulled;
5317
+ errors2.push(...result.errors.map((e) => `${agent}: ${e}`));
5318
+ }
5319
+ }
5320
+ if (direction === "push" || direction === "both") {
5321
+ for (const agent of normalized) {
5322
+ const listId = taskListIdByAgent(agent);
5323
+ if (!listId) {
5324
+ errors2.push(`sync ${agent}: missing task list id`);
5325
+ continue;
5326
+ }
5327
+ const result = syncWithAgent(agent, listId, projectId, "push", options);
5328
+ pushed += result.pushed;
5329
+ pulled += result.pulled;
5330
+ errors2.push(...result.errors.map((e) => `${agent}: ${e}`));
5331
+ }
5332
+ }
5333
+ return { pushed, pulled, errors: errors2 };
5334
+ }
5335
+
4700
5336
  // src/mcp/index.ts
4701
5337
  var server = new McpServer({
4702
5338
  name: "todos",
@@ -4707,6 +5343,8 @@ function formatError(error) {
4707
5343
  return `Version conflict: ${error.message}`;
4708
5344
  if (error instanceof TaskNotFoundError)
4709
5345
  return `Not found: ${error.message}`;
5346
+ if (error instanceof PlanNotFoundError)
5347
+ return `Not found: ${error.message}`;
4710
5348
  if (error instanceof LockError)
4711
5349
  return `Lock error: ${error.message}`;
4712
5350
  if (error instanceof DependencyCycleError)
@@ -4722,6 +5360,16 @@ function resolveId(partialId, table = "tasks") {
4722
5360
  throw new Error(`Could not resolve ID: ${partialId}`);
4723
5361
  return id;
4724
5362
  }
5363
+ function resolveTaskListId(agent, explicit) {
5364
+ if (explicit)
5365
+ return explicit;
5366
+ const normalized = agent.trim().toLowerCase();
5367
+ if (normalized === "claude" || normalized === "claude-code" || normalized === "claude_code") {
5368
+ return process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || getAgentTaskListId(normalized) || null;
5369
+ }
5370
+ const key = `TODOS_${normalized.toUpperCase()}_TASK_LIST`;
5371
+ return process.env[key] || process.env["TODOS_TASK_LIST_ID"] || getAgentTaskListId(normalized) || "default";
5372
+ }
4725
5373
  function formatTask(task) {
4726
5374
  const parts = [
4727
5375
  `ID: ${task.id}`,
@@ -4741,6 +5389,8 @@ function formatTask(task) {
4741
5389
  parts.push(`Parent: ${task.parent_id}`);
4742
5390
  if (task.project_id)
4743
5391
  parts.push(`Project: ${task.project_id}`);
5392
+ if (task.plan_id)
5393
+ parts.push(`Plan: ${task.plan_id}`);
4744
5394
  if (task.tags.length > 0)
4745
5395
  parts.push(`Tags: ${task.tags.join(", ")}`);
4746
5396
  parts.push(`Version: ${task.version}`);
@@ -4761,6 +5411,7 @@ server.tool("create_task", "Create a new task", {
4761
5411
  assigned_to: exports_external.string().optional().describe("Assigned agent ID"),
4762
5412
  session_id: exports_external.string().optional().describe("Session ID"),
4763
5413
  working_dir: exports_external.string().optional().describe("Working directory context"),
5414
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to"),
4764
5415
  tags: exports_external.array(exports_external.string()).optional().describe("Task tags"),
4765
5416
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Arbitrary metadata")
4766
5417
  }, async (params) => {
@@ -4770,6 +5421,8 @@ server.tool("create_task", "Create a new task", {
4770
5421
  resolved.project_id = resolveId(resolved.project_id, "projects");
4771
5422
  if (resolved.parent_id)
4772
5423
  resolved.parent_id = resolveId(resolved.parent_id);
5424
+ if (resolved.plan_id)
5425
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
4773
5426
  const task = createTask(resolved);
4774
5427
  return { content: [{ type: "text", text: `Task created:
4775
5428
  ${formatTask(task)}` }] };
@@ -4788,12 +5441,15 @@ server.tool("list_tasks", "List tasks with optional filters", {
4788
5441
  exports_external.array(exports_external.enum(["low", "medium", "high", "critical"]))
4789
5442
  ]).optional().describe("Filter by priority"),
4790
5443
  assigned_to: exports_external.string().optional().describe("Filter by assigned agent"),
4791
- tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)")
5444
+ tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)"),
5445
+ plan_id: exports_external.string().optional().describe("Filter by plan")
4792
5446
  }, async (params) => {
4793
5447
  try {
4794
5448
  const resolved = { ...params };
4795
5449
  if (resolved.project_id)
4796
5450
  resolved.project_id = resolveId(resolved.project_id, "projects");
5451
+ if (resolved.plan_id)
5452
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
4797
5453
  const tasks = listTasks(resolved);
4798
5454
  if (tasks.length === 0) {
4799
5455
  return { content: [{ type: "text", text: "No tasks found." }] };
@@ -4867,7 +5523,8 @@ server.tool("update_task", "Update task fields (requires version for optimistic
4867
5523
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("New priority"),
4868
5524
  assigned_to: exports_external.string().optional().describe("Assign to agent"),
4869
5525
  tags: exports_external.array(exports_external.string()).optional().describe("New tags"),
4870
- metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata")
5526
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata"),
5527
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to")
4871
5528
  }, async ({ id, ...rest }) => {
4872
5529
  try {
4873
5530
  const resolvedId = resolveId(id);
@@ -4998,7 +5655,10 @@ server.tool("list_projects", "List all registered projects", {}, async () => {
4998
5655
  if (projects.length === 0) {
4999
5656
  return { content: [{ type: "text", text: "No projects registered." }] };
5000
5657
  }
5001
- const text = projects.map((p) => `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${p.description ? ` - ${p.description}` : ""}`).join(`
5658
+ const text = projects.map((p) => {
5659
+ const taskList = p.task_list_id ? ` [${p.task_list_id}]` : "";
5660
+ return `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${taskList}${p.description ? ` - ${p.description}` : ""}`;
5661
+ }).join(`
5002
5662
  `);
5003
5663
  return { content: [{ type: "text", text: `${projects.length} project(s):
5004
5664
  ${text}` }] };
@@ -5009,14 +5669,117 @@ ${text}` }] };
5009
5669
  server.tool("create_project", "Register a new project", {
5010
5670
  name: exports_external.string().describe("Project name"),
5011
5671
  path: exports_external.string().describe("Absolute path to project"),
5012
- description: exports_external.string().optional().describe("Project description")
5672
+ description: exports_external.string().optional().describe("Project description"),
5673
+ task_list_id: exports_external.string().optional().describe("Custom task list ID for Claude Code sync (defaults to todos-<slugified-name>)")
5013
5674
  }, async (params) => {
5014
5675
  try {
5015
5676
  const project = createProject(params);
5677
+ const taskList = project.task_list_id ? ` [${project.task_list_id}]` : "";
5016
5678
  return {
5017
5679
  content: [{
5018
5680
  type: "text",
5019
- text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}`
5681
+ text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}${taskList}`
5682
+ }]
5683
+ };
5684
+ } catch (e) {
5685
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
5686
+ }
5687
+ });
5688
+ server.tool("create_plan", "Create a new plan", {
5689
+ name: exports_external.string().describe("Plan name"),
5690
+ project_id: exports_external.string().optional().describe("Project ID"),
5691
+ description: exports_external.string().optional().describe("Plan description"),
5692
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("Plan status")
5693
+ }, async (params) => {
5694
+ try {
5695
+ const resolved = { ...params };
5696
+ if (resolved.project_id)
5697
+ resolved.project_id = resolveId(resolved.project_id, "projects");
5698
+ const plan = createPlan(resolved);
5699
+ return {
5700
+ content: [{
5701
+ type: "text",
5702
+ text: `Plan created: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
5703
+ }]
5704
+ };
5705
+ } catch (e) {
5706
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
5707
+ }
5708
+ });
5709
+ server.tool("list_plans", "List plans with optional project filter", {
5710
+ project_id: exports_external.string().optional().describe("Filter by project")
5711
+ }, async ({ project_id }) => {
5712
+ try {
5713
+ const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
5714
+ const plans = listPlans(resolvedProjectId);
5715
+ if (plans.length === 0) {
5716
+ return { content: [{ type: "text", text: "No plans found." }] };
5717
+ }
5718
+ const text = plans.map((p) => {
5719
+ const project = p.project_id ? ` (project: ${p.project_id.slice(0, 8)})` : "";
5720
+ return `[${p.status}] ${p.id.slice(0, 8)} | ${p.name}${project}`;
5721
+ }).join(`
5722
+ `);
5723
+ return { content: [{ type: "text", text: `${plans.length} plan(s):
5724
+ ${text}` }] };
5725
+ } catch (e) {
5726
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
5727
+ }
5728
+ });
5729
+ server.tool("get_plan", "Get plan details", {
5730
+ id: exports_external.string().describe("Plan ID (full or partial)")
5731
+ }, async ({ id }) => {
5732
+ try {
5733
+ const resolvedId = resolveId(id, "plans");
5734
+ const plan = getPlan(resolvedId);
5735
+ if (!plan)
5736
+ return { content: [{ type: "text", text: `Plan not found: ${id}` }], isError: true };
5737
+ const parts = [
5738
+ `ID: ${plan.id}`,
5739
+ `Name: ${plan.name}`,
5740
+ `Status: ${plan.status}`
5741
+ ];
5742
+ if (plan.description)
5743
+ parts.push(`Description: ${plan.description}`);
5744
+ if (plan.project_id)
5745
+ parts.push(`Project: ${plan.project_id}`);
5746
+ parts.push(`Created: ${plan.created_at}`);
5747
+ parts.push(`Updated: ${plan.updated_at}`);
5748
+ return { content: [{ type: "text", text: parts.join(`
5749
+ `) }] };
5750
+ } catch (e) {
5751
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
5752
+ }
5753
+ });
5754
+ server.tool("update_plan", "Update a plan", {
5755
+ id: exports_external.string().describe("Plan ID (full or partial)"),
5756
+ name: exports_external.string().optional().describe("New name"),
5757
+ description: exports_external.string().optional().describe("New description"),
5758
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("New status")
5759
+ }, async ({ id, ...rest }) => {
5760
+ try {
5761
+ const resolvedId = resolveId(id, "plans");
5762
+ const plan = updatePlan(resolvedId, rest);
5763
+ return {
5764
+ content: [{
5765
+ type: "text",
5766
+ text: `Plan updated: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
5767
+ }]
5768
+ };
5769
+ } catch (e) {
5770
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
5771
+ }
5772
+ });
5773
+ server.tool("delete_plan", "Delete a plan", {
5774
+ id: exports_external.string().describe("Plan ID (full or partial)")
5775
+ }, async ({ id }) => {
5776
+ try {
5777
+ const resolvedId = resolveId(id, "plans");
5778
+ const deleted = deletePlan(resolvedId);
5779
+ return {
5780
+ content: [{
5781
+ type: "text",
5782
+ text: deleted ? `Plan ${id} deleted.` : `Plan ${id} not found.`
5020
5783
  }]
5021
5784
  };
5022
5785
  } catch (e) {
@@ -5041,27 +5804,42 @@ ${text}` }] };
5041
5804
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
5042
5805
  }
5043
5806
  });
5044
- server.tool("sync", "Sync tasks with a Claude Code task list. Writes SQLite tasks as JSON files to ~/.claude/tasks/<task_list_id>/ so they appear in Claude Code's native task UI. The task_list_id is your Claude Code session ID (visible in the conversation or via CLAUDE_CODE_SESSION_ID).", {
5045
- task_list_id: exports_external.string().describe("Claude Code task list ID \u2014 use your session ID"),
5046
- project_id: exports_external.string().optional().describe("Limit sync to a project"),
5047
- direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->Claude), pull (Claude->SQLite), or both (default)")
5048
- }, async ({ task_list_id, project_id, direction }) => {
5807
+ server.tool("sync", "Sync tasks with an agent task list (Claude uses native task list; others use JSON lists).", {
5808
+ task_list_id: exports_external.string().optional().describe("Task list ID (required for Claude)"),
5809
+ agent: exports_external.string().optional().describe("Agent/provider name (default: claude)"),
5810
+ all_agents: exports_external.boolean().optional().describe("Sync across all configured agents"),
5811
+ 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"),
5812
+ direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->agent), pull (agent->SQLite), or both (default)"),
5813
+ prefer: exports_external.enum(["local", "remote"]).optional().describe("Conflict strategy")
5814
+ }, async ({ task_list_id, agent, all_agents, project_id, direction, prefer }) => {
5049
5815
  try {
5050
5816
  const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
5051
- const taskListId = task_list_id;
5817
+ const project = resolvedProjectId ? getProject(resolvedProjectId) : undefined;
5818
+ const dir = direction ?? "both";
5819
+ const options = { prefer: prefer ?? "remote" };
5052
5820
  let result;
5053
- if (direction === "push") {
5054
- result = pushToClaudeTaskList(taskListId, resolvedProjectId);
5055
- } else if (direction === "pull") {
5056
- result = pullFromClaudeTaskList(taskListId, resolvedProjectId);
5821
+ if (all_agents) {
5822
+ const agents = defaultSyncAgents();
5823
+ result = syncWithAgents(agents, (a) => resolveTaskListId(a, task_list_id || project?.task_list_id || undefined), resolvedProjectId, dir, options);
5057
5824
  } else {
5058
- result = syncClaudeTaskList(taskListId, resolvedProjectId);
5825
+ const resolvedAgent = agent || "claude";
5826
+ const taskListId = resolveTaskListId(resolvedAgent, task_list_id || project?.task_list_id || undefined);
5827
+ if (!taskListId) {
5828
+ return {
5829
+ content: [{
5830
+ type: "text",
5831
+ text: `Could not determine task list ID for ${resolvedAgent}. Provide task_list_id or set task_list_id on the project.`
5832
+ }],
5833
+ isError: true
5834
+ };
5835
+ }
5836
+ result = syncWithAgent(resolvedAgent, taskListId, resolvedProjectId, dir, options);
5059
5837
  }
5060
5838
  const parts = [];
5061
5839
  if (result.pulled > 0)
5062
- parts.push(`Pulled ${result.pulled} task(s) from Claude task list.`);
5840
+ parts.push(`Pulled ${result.pulled} task(s).`);
5063
5841
  if (result.pushed > 0)
5064
- parts.push(`Pushed ${result.pushed} task(s) to Claude task list.`);
5842
+ parts.push(`Pushed ${result.pushed} task(s).`);
5065
5843
  if (result.pulled === 0 && result.pushed === 0 && result.errors.length === 0) {
5066
5844
  parts.push("Nothing to sync.");
5067
5845
  }