@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/index.js CHANGED
@@ -4,19 +4,54 @@ import { Database } from "bun:sqlite";
4
4
  import { existsSync, mkdirSync } from "fs";
5
5
  import { dirname, join, resolve } from "path";
6
6
  var LOCK_EXPIRY_MINUTES = 30;
7
+ function isInMemoryDb(path) {
8
+ return path === ":memory:" || path.startsWith("file::memory:");
9
+ }
10
+ function findNearestTodosDb(startDir) {
11
+ let dir = resolve(startDir);
12
+ while (true) {
13
+ const candidate = join(dir, ".todos", "todos.db");
14
+ if (existsSync(candidate))
15
+ return candidate;
16
+ const parent = dirname(dir);
17
+ if (parent === dir)
18
+ break;
19
+ dir = parent;
20
+ }
21
+ return null;
22
+ }
23
+ function findGitRoot(startDir) {
24
+ let dir = resolve(startDir);
25
+ while (true) {
26
+ if (existsSync(join(dir, ".git")))
27
+ return dir;
28
+ const parent = dirname(dir);
29
+ if (parent === dir)
30
+ break;
31
+ dir = parent;
32
+ }
33
+ return null;
34
+ }
7
35
  function getDbPath() {
8
36
  if (process.env["TODOS_DB_PATH"]) {
9
37
  return process.env["TODOS_DB_PATH"];
10
38
  }
11
39
  const cwd = process.cwd();
12
- const localDb = join(cwd, ".todos", "todos.db");
13
- if (existsSync(localDb)) {
14
- return localDb;
40
+ const nearest = findNearestTodosDb(cwd);
41
+ if (nearest)
42
+ return nearest;
43
+ if (process.env["TODOS_DB_SCOPE"] === "project") {
44
+ const gitRoot = findGitRoot(cwd);
45
+ if (gitRoot) {
46
+ return join(gitRoot, ".todos", "todos.db");
47
+ }
15
48
  }
16
49
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
17
50
  return join(home, ".todos", "todos.db");
18
51
  }
19
52
  function ensureDir(filePath) {
53
+ if (isInMemoryDb(filePath))
54
+ return;
20
55
  const dir = dirname(resolve(filePath));
21
56
  if (!existsSync(dir)) {
22
57
  mkdirSync(dir, { recursive: true });
@@ -98,6 +133,37 @@ var MIGRATIONS = [
98
133
  );
99
134
 
100
135
  INSERT OR IGNORE INTO _migrations (id) VALUES (1);
136
+ `,
137
+ `
138
+ ALTER TABLE projects ADD COLUMN task_list_id TEXT;
139
+ INSERT OR IGNORE INTO _migrations (id) VALUES (2);
140
+ `,
141
+ `
142
+ CREATE TABLE IF NOT EXISTS task_tags (
143
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
144
+ tag TEXT NOT NULL,
145
+ PRIMARY KEY (task_id, tag)
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags(tag);
148
+ CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id);
149
+
150
+ INSERT OR IGNORE INTO _migrations (id) VALUES (3);
151
+ `,
152
+ `
153
+ CREATE TABLE IF NOT EXISTS plans (
154
+ id TEXT PRIMARY KEY,
155
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
156
+ name TEXT NOT NULL,
157
+ description TEXT,
158
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
159
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
160
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
161
+ );
162
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans(project_id);
163
+ CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
164
+ ALTER TABLE tasks ADD COLUMN plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL;
165
+ CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id);
166
+ INSERT OR IGNORE INTO _migrations (id) VALUES (4);
101
167
  `
102
168
  ];
103
169
  var _db = null;
@@ -111,6 +177,7 @@ function getDatabase(dbPath) {
111
177
  _db.run("PRAGMA busy_timeout = 5000");
112
178
  _db.run("PRAGMA foreign_keys = ON");
113
179
  runMigrations(_db);
180
+ backfillTaskTags(_db);
114
181
  return _db;
115
182
  }
116
183
  function runMigrations(db) {
@@ -126,6 +193,35 @@ function runMigrations(db) {
126
193
  }
127
194
  }
128
195
  }
196
+ function backfillTaskTags(db) {
197
+ try {
198
+ const count = db.query("SELECT COUNT(*) as count FROM task_tags").get();
199
+ if (count && count.count > 0)
200
+ return;
201
+ } catch {
202
+ return;
203
+ }
204
+ try {
205
+ const rows = db.query("SELECT id, tags FROM tasks WHERE tags IS NOT NULL AND tags != '[]'").all();
206
+ if (rows.length === 0)
207
+ return;
208
+ const insert = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
209
+ for (const row of rows) {
210
+ if (!row.tags)
211
+ continue;
212
+ let tags = [];
213
+ try {
214
+ tags = JSON.parse(row.tags);
215
+ } catch {
216
+ continue;
217
+ }
218
+ for (const tag of tags) {
219
+ if (tag)
220
+ insert.run(row.id, tag);
221
+ }
222
+ }
223
+ } catch {}
224
+ }
129
225
  function closeDatabase() {
130
226
  if (_db) {
131
227
  _db.close();
@@ -148,6 +244,14 @@ function isLockExpired(lockedAt) {
148
244
  const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
149
245
  return Date.now() - lockTime > expiryMs;
150
246
  }
247
+ function lockExpiryCutoff(nowMs = Date.now()) {
248
+ const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
249
+ return new Date(nowMs - expiryMs).toISOString();
250
+ }
251
+ function clearExpiredLocks(db) {
252
+ const cutoff = lockExpiryCutoff();
253
+ db.run("UPDATE tasks SET locked_by = NULL, locked_at = NULL WHERE locked_at IS NOT NULL AND locked_at < ?", [cutoff]);
254
+ }
151
255
  function resolvePartialId(db, table, partialId) {
152
256
  if (partialId.length >= 36) {
153
257
  const row = db.query(`SELECT id FROM ${table} WHERE id = ?`).get(partialId);
@@ -176,6 +280,7 @@ var TASK_PRIORITIES = [
176
280
  "high",
177
281
  "critical"
178
282
  ];
283
+ var PLAN_STATUSES = ["active", "completed", "archived"];
179
284
 
180
285
  class VersionConflictError extends Error {
181
286
  taskId;
@@ -208,6 +313,15 @@ class ProjectNotFoundError extends Error {
208
313
  }
209
314
  }
210
315
 
316
+ class PlanNotFoundError extends Error {
317
+ planId;
318
+ constructor(planId) {
319
+ super(`Plan not found: ${planId}`);
320
+ this.planId = planId;
321
+ this.name = "PlanNotFoundError";
322
+ }
323
+ }
324
+
211
325
  class LockError extends Error {
212
326
  taskId;
213
327
  lockedBy;
@@ -240,15 +354,30 @@ function rowToTask(row) {
240
354
  priority: row.priority
241
355
  };
242
356
  }
357
+ function insertTaskTags(taskId, tags, db) {
358
+ if (tags.length === 0)
359
+ return;
360
+ const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
361
+ for (const tag of tags) {
362
+ if (tag)
363
+ stmt.run(taskId, tag);
364
+ }
365
+ }
366
+ function replaceTaskTags(taskId, tags, db) {
367
+ db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
368
+ insertTaskTags(taskId, tags, db);
369
+ }
243
370
  function createTask(input, db) {
244
371
  const d = db || getDatabase();
245
372
  const id = uuid();
246
373
  const timestamp = now();
247
- 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)
248
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
374
+ const tags = input.tags || [];
375
+ 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)
376
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
249
377
  id,
250
378
  input.project_id || null,
251
379
  input.parent_id || null,
380
+ input.plan_id || null,
252
381
  input.title,
253
382
  input.description || null,
254
383
  input.status || "pending",
@@ -257,11 +386,14 @@ function createTask(input, db) {
257
386
  input.assigned_to || null,
258
387
  input.session_id || null,
259
388
  input.working_dir || null,
260
- JSON.stringify(input.tags || []),
389
+ JSON.stringify(tags),
261
390
  JSON.stringify(input.metadata || {}),
262
391
  timestamp,
263
392
  timestamp
264
393
  ]);
394
+ if (tags.length > 0) {
395
+ insertTaskTags(id, tags, d);
396
+ }
265
397
  return getTask(id, d);
266
398
  }
267
399
  function getTask(id, db) {
@@ -299,6 +431,7 @@ function getTaskWithRelations(id, db) {
299
431
  }
300
432
  function listTasks(filter = {}, db) {
301
433
  const d = db || getDatabase();
434
+ clearExpiredLocks(d);
302
435
  const conditions = [];
303
436
  const params = [];
304
437
  if (filter.project_id) {
@@ -344,9 +477,13 @@ function listTasks(filter = {}, db) {
344
477
  params.push(filter.session_id);
345
478
  }
346
479
  if (filter.tags && filter.tags.length > 0) {
347
- const tagConditions = filter.tags.map(() => "tags LIKE ?");
348
- conditions.push(`(${tagConditions.join(" OR ")})`);
349
- params.push(...filter.tags.map((t) => `%"${t}"%`));
480
+ const placeholders = filter.tags.map(() => "?").join(",");
481
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
482
+ params.push(...filter.tags);
483
+ }
484
+ if (filter.plan_id) {
485
+ conditions.push("plan_id = ?");
486
+ params.push(filter.plan_id);
350
487
  }
351
488
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
352
489
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
@@ -396,12 +533,19 @@ function updateTask(id, input, db) {
396
533
  sets.push("metadata = ?");
397
534
  params.push(JSON.stringify(input.metadata));
398
535
  }
536
+ if (input.plan_id !== undefined) {
537
+ sets.push("plan_id = ?");
538
+ params.push(input.plan_id);
539
+ }
399
540
  params.push(id, input.version);
400
541
  const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
401
542
  if (result.changes === 0) {
402
543
  const current = getTask(id, d);
403
544
  throw new VersionConflictError(id, input.version, current?.version ?? -1);
404
545
  }
546
+ if (input.tags !== undefined) {
547
+ replaceTaskTags(id, input.tags, d);
548
+ }
405
549
  return getTask(id, d);
406
550
  }
407
551
  function deleteTask(id, db) {
@@ -411,15 +555,18 @@ function deleteTask(id, db) {
411
555
  }
412
556
  function startTask(id, agentId, db) {
413
557
  const d = db || getDatabase();
414
- const task = getTask(id, d);
415
- if (!task)
416
- throw new TaskNotFoundError(id);
417
- if (task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
418
- throw new LockError(id, task.locked_by);
419
- }
558
+ const cutoff = lockExpiryCutoff();
420
559
  const timestamp = now();
421
- d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
422
- WHERE id = ?`, [agentId, agentId, timestamp, timestamp, id]);
560
+ const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
561
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, id, agentId, cutoff]);
562
+ if (result.changes === 0) {
563
+ const current = getTask(id, d);
564
+ if (!current)
565
+ throw new TaskNotFoundError(id);
566
+ if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
567
+ throw new LockError(id, current.locked_by);
568
+ }
569
+ }
423
570
  return getTask(id, d);
424
571
  }
425
572
  function completeTask(id, agentId, db) {
@@ -440,20 +587,26 @@ function lockTask(id, agentId, db) {
440
587
  const task = getTask(id, d);
441
588
  if (!task)
442
589
  throw new TaskNotFoundError(id);
443
- if (task.locked_by === agentId) {
590
+ if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
444
591
  return { success: true, locked_by: agentId, locked_at: task.locked_at };
445
592
  }
446
- if (task.locked_by && !isLockExpired(task.locked_at)) {
447
- return {
448
- success: false,
449
- locked_by: task.locked_by,
450
- locked_at: task.locked_at,
451
- error: `Task is locked by ${task.locked_by}`
452
- };
453
- }
593
+ const cutoff = lockExpiryCutoff();
454
594
  const timestamp = now();
455
- d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
456
- WHERE id = ?`, [agentId, timestamp, timestamp, id]);
595
+ const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
596
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
597
+ if (result.changes === 0) {
598
+ const current = getTask(id, d);
599
+ if (!current)
600
+ throw new TaskNotFoundError(id);
601
+ if (current.locked_by && !isLockExpired(current.locked_at)) {
602
+ return {
603
+ success: false,
604
+ locked_by: current.locked_by,
605
+ locked_at: current.locked_at,
606
+ error: `Task is locked by ${current.locked_by}`
607
+ };
608
+ }
609
+ }
457
610
  return { success: true, locked_by: agentId, locked_at: timestamp };
458
611
  }
459
612
  function unlockTask(id, agentId, db) {
@@ -511,12 +664,16 @@ function wouldCreateCycle(taskId, dependsOn, db) {
511
664
  return false;
512
665
  }
513
666
  // src/db/projects.ts
667
+ function slugify(name) {
668
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
669
+ }
514
670
  function createProject(input, db) {
515
671
  const d = db || getDatabase();
516
672
  const id = uuid();
517
673
  const timestamp = now();
518
- d.run(`INSERT INTO projects (id, name, path, description, created_at, updated_at)
519
- VALUES (?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, timestamp, timestamp]);
674
+ const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
675
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, created_at, updated_at)
676
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, timestamp, timestamp]);
520
677
  return getProject(id, d);
521
678
  }
522
679
  function getProject(id, db) {
@@ -548,6 +705,10 @@ function updateProject(id, input, db) {
548
705
  sets.push("description = ?");
549
706
  params.push(input.description);
550
707
  }
708
+ if (input.task_list_id !== undefined) {
709
+ sets.push("task_list_id = ?");
710
+ params.push(input.task_list_id);
711
+ }
551
712
  params.push(id);
552
713
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
553
714
  return getProject(id, d);
@@ -564,6 +725,63 @@ function ensureProject(name, path, db) {
564
725
  return existing;
565
726
  return createProject({ name, path }, d);
566
727
  }
728
+ // src/db/plans.ts
729
+ function createPlan(input, db) {
730
+ const d = db || getDatabase();
731
+ const id = uuid();
732
+ const timestamp = now();
733
+ d.run(`INSERT INTO plans (id, project_id, name, description, status, created_at, updated_at)
734
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
735
+ id,
736
+ input.project_id || null,
737
+ input.name,
738
+ input.description || null,
739
+ input.status || "active",
740
+ timestamp,
741
+ timestamp
742
+ ]);
743
+ return getPlan(id, d);
744
+ }
745
+ function getPlan(id, db) {
746
+ const d = db || getDatabase();
747
+ const row = d.query("SELECT * FROM plans WHERE id = ?").get(id);
748
+ return row;
749
+ }
750
+ function listPlans(projectId, db) {
751
+ const d = db || getDatabase();
752
+ if (projectId) {
753
+ return d.query("SELECT * FROM plans WHERE project_id = ? ORDER BY created_at DESC").all(projectId);
754
+ }
755
+ return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
756
+ }
757
+ function updatePlan(id, input, db) {
758
+ const d = db || getDatabase();
759
+ const plan = getPlan(id, d);
760
+ if (!plan)
761
+ throw new PlanNotFoundError(id);
762
+ const sets = ["updated_at = ?"];
763
+ const params = [now()];
764
+ if (input.name !== undefined) {
765
+ sets.push("name = ?");
766
+ params.push(input.name);
767
+ }
768
+ if (input.description !== undefined) {
769
+ sets.push("description = ?");
770
+ params.push(input.description);
771
+ }
772
+ if (input.status !== undefined) {
773
+ sets.push("status = ?");
774
+ params.push(input.status);
775
+ }
776
+ params.push(id);
777
+ d.run(`UPDATE plans SET ${sets.join(", ")} WHERE id = ?`, params);
778
+ return getPlan(id, d);
779
+ }
780
+ function deletePlan(id, db) {
781
+ const d = db || getDatabase();
782
+ const result = d.run("DELETE FROM plans WHERE id = ?", [id]);
783
+ return result.changes > 0;
784
+ }
567
785
  // src/db/comments.ts
568
786
  function addComment(input, db) {
569
787
  const d = db || getDatabase();
@@ -652,8 +870,9 @@ function rowToTask2(row) {
652
870
  }
653
871
  function searchTasks(query, projectId, db) {
654
872
  const d = db || getDatabase();
873
+ clearExpiredLocks(d);
655
874
  const pattern = `%${query}%`;
656
- let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)`;
875
+ 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 ?))`;
657
876
  const params = [pattern, pattern, pattern];
658
877
  if (projectId) {
659
878
  sql += " AND project_id = ?";
@@ -665,20 +884,609 @@ function searchTasks(query, projectId, db) {
665
884
  const rows = d.query(sql).all(...params);
666
885
  return rows.map(rowToTask2);
667
886
  }
887
+ // src/lib/claude-tasks.ts
888
+ import { existsSync as existsSync3 } from "fs";
889
+ import { join as join3 } from "path";
890
+
891
+ // src/lib/sync-utils.ts
892
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync as readdirSync2, statSync, writeFileSync } from "fs";
893
+ import { join as join2 } from "path";
894
+ var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
895
+ function ensureDir2(dir) {
896
+ if (!existsSync2(dir))
897
+ mkdirSync2(dir, { recursive: true });
898
+ }
899
+ function listJsonFiles(dir) {
900
+ if (!existsSync2(dir))
901
+ return [];
902
+ return readdirSync2(dir).filter((f) => f.endsWith(".json"));
903
+ }
904
+ function readJsonFile(path) {
905
+ try {
906
+ return JSON.parse(readFileSync(path, "utf-8"));
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
911
+ function writeJsonFile(path, data) {
912
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
913
+ `);
914
+ }
915
+ function readHighWaterMark(dir) {
916
+ const path = join2(dir, ".highwatermark");
917
+ if (!existsSync2(path))
918
+ return 1;
919
+ const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
920
+ return isNaN(val) ? 1 : val;
921
+ }
922
+ function writeHighWaterMark(dir, value) {
923
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
924
+ }
925
+ function getFileMtimeMs(path) {
926
+ try {
927
+ return statSync(path).mtimeMs;
928
+ } catch {
929
+ return null;
930
+ }
931
+ }
932
+ function parseTimestamp(value) {
933
+ if (typeof value !== "string")
934
+ return null;
935
+ const parsed = Date.parse(value);
936
+ return Number.isNaN(parsed) ? null : parsed;
937
+ }
938
+ function appendSyncConflict(metadata, conflict, limit = 5) {
939
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
940
+ const next = [conflict, ...current].slice(0, limit);
941
+ return { ...metadata, sync_conflicts: next };
942
+ }
943
+
944
+ // src/lib/claude-tasks.ts
945
+ function getTaskListDir(taskListId) {
946
+ return join3(HOME, ".claude", "tasks", taskListId);
947
+ }
948
+ function readClaudeTask(dir, filename) {
949
+ return readJsonFile(join3(dir, filename));
950
+ }
951
+ function writeClaudeTask(dir, task) {
952
+ writeJsonFile(join3(dir, `${task.id}.json`), task);
953
+ }
954
+ function toClaudeStatus(status) {
955
+ if (status === "pending" || status === "in_progress" || status === "completed") {
956
+ return status;
957
+ }
958
+ return "completed";
959
+ }
960
+ function toSqliteStatus(status) {
961
+ return status;
962
+ }
963
+ function taskToClaudeTask(task, claudeTaskId, existingMeta) {
964
+ return {
965
+ id: claudeTaskId,
966
+ subject: task.title,
967
+ description: task.description || "",
968
+ activeForm: "",
969
+ status: toClaudeStatus(task.status),
970
+ owner: task.assigned_to || task.agent_id || "",
971
+ blocks: [],
972
+ blockedBy: [],
973
+ metadata: {
974
+ ...existingMeta || {},
975
+ todos_id: task.id,
976
+ priority: task.priority,
977
+ todos_updated_at: task.updated_at,
978
+ todos_version: task.version
979
+ }
980
+ };
981
+ }
982
+ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
983
+ const dir = getTaskListDir(taskListId);
984
+ if (!existsSync3(dir))
985
+ ensureDir2(dir);
986
+ const filter = {};
987
+ if (projectId)
988
+ filter["project_id"] = projectId;
989
+ const tasks = listTasks(filter);
990
+ const existingByTodosId = new Map;
991
+ const files = listJsonFiles(dir);
992
+ for (const f of files) {
993
+ const path = join3(dir, f);
994
+ const ct = readClaudeTask(dir, f);
995
+ if (ct?.metadata?.["todos_id"]) {
996
+ existingByTodosId.set(ct.metadata["todos_id"], { task: ct, mtimeMs: getFileMtimeMs(path) });
997
+ }
998
+ }
999
+ let hwm = readHighWaterMark(dir);
1000
+ let pushed = 0;
1001
+ const errors = [];
1002
+ const prefer = options.prefer || "remote";
1003
+ for (const task of tasks) {
1004
+ try {
1005
+ const existing = existingByTodosId.get(task.id);
1006
+ if (existing) {
1007
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
1008
+ const localUpdatedAt = parseTimestamp(task.updated_at);
1009
+ const remoteUpdatedAt = existing.mtimeMs;
1010
+ let recordConflict = false;
1011
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
1012
+ if (prefer === "remote") {
1013
+ const conflict = {
1014
+ agent: "claude",
1015
+ direction: "push",
1016
+ prefer,
1017
+ local_updated_at: task.updated_at,
1018
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
1019
+ detected_at: new Date().toISOString()
1020
+ };
1021
+ const newMeta = appendSyncConflict(task.metadata, conflict);
1022
+ updateTask(task.id, { version: task.version, metadata: newMeta });
1023
+ errors.push(`conflict push ${task.id}: remote newer`);
1024
+ continue;
1025
+ }
1026
+ recordConflict = true;
1027
+ }
1028
+ const updated = taskToClaudeTask(task, existing.task.id, existing.task.metadata);
1029
+ updated.blocks = existing.task.blocks;
1030
+ updated.blockedBy = existing.task.blockedBy;
1031
+ updated.activeForm = existing.task.activeForm;
1032
+ writeClaudeTask(dir, updated);
1033
+ if (recordConflict) {
1034
+ const latest = getTask(task.id);
1035
+ if (latest) {
1036
+ const conflict = {
1037
+ agent: "claude",
1038
+ direction: "push",
1039
+ prefer,
1040
+ local_updated_at: latest.updated_at,
1041
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
1042
+ detected_at: new Date().toISOString()
1043
+ };
1044
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
1045
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
1046
+ }
1047
+ }
1048
+ } else {
1049
+ const claudeId = String(hwm);
1050
+ hwm++;
1051
+ const ct = taskToClaudeTask(task, claudeId);
1052
+ writeClaudeTask(dir, ct);
1053
+ const current = getTask(task.id);
1054
+ if (current) {
1055
+ const newMeta = { ...current.metadata, claude_task_id: claudeId };
1056
+ updateTask(task.id, { version: current.version, metadata: newMeta });
1057
+ }
1058
+ }
1059
+ pushed++;
1060
+ } catch (e) {
1061
+ errors.push(`push ${task.id}: ${e instanceof Error ? e.message : String(e)}`);
1062
+ }
1063
+ }
1064
+ writeHighWaterMark(dir, hwm);
1065
+ return { pushed, pulled: 0, errors };
1066
+ }
1067
+ function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
1068
+ const dir = getTaskListDir(taskListId);
1069
+ if (!existsSync3(dir)) {
1070
+ return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
1071
+ }
1072
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
1073
+ let pulled = 0;
1074
+ const errors = [];
1075
+ const prefer = options.prefer || "remote";
1076
+ const allTasks = listTasks({});
1077
+ const byClaudeId = new Map;
1078
+ for (const t of allTasks) {
1079
+ const cid = t.metadata["claude_task_id"];
1080
+ if (cid)
1081
+ byClaudeId.set(String(cid), t);
1082
+ }
1083
+ const byTodosId = new Map;
1084
+ for (const t of allTasks) {
1085
+ byTodosId.set(t.id, t);
1086
+ }
1087
+ for (const f of files) {
1088
+ try {
1089
+ const filePath = join3(dir, f);
1090
+ const ct = readClaudeTask(dir, f);
1091
+ if (!ct)
1092
+ continue;
1093
+ if (ct.metadata?.["_internal"])
1094
+ continue;
1095
+ const todosId = ct.metadata?.["todos_id"];
1096
+ const existingByMapping = byClaudeId.get(ct.id);
1097
+ const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
1098
+ const existing = existingByMapping || existingByTodos;
1099
+ if (existing) {
1100
+ const lastSyncedAt = parseTimestamp(ct.metadata?.["todos_updated_at"]);
1101
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
1102
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
1103
+ let conflictMeta = null;
1104
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
1105
+ const conflict = {
1106
+ agent: "claude",
1107
+ direction: "pull",
1108
+ prefer,
1109
+ local_updated_at: existing.updated_at,
1110
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
1111
+ detected_at: new Date().toISOString()
1112
+ };
1113
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
1114
+ if (prefer === "local") {
1115
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
1116
+ errors.push(`conflict pull ${existing.id}: local newer`);
1117
+ continue;
1118
+ }
1119
+ }
1120
+ updateTask(existing.id, {
1121
+ version: existing.version,
1122
+ title: ct.subject,
1123
+ description: ct.description || undefined,
1124
+ status: toSqliteStatus(ct.status),
1125
+ assigned_to: ct.owner || undefined,
1126
+ metadata: { ...conflictMeta || existing.metadata, claude_task_id: ct.id, ...ct.metadata }
1127
+ });
1128
+ } else {
1129
+ createTask({
1130
+ title: ct.subject,
1131
+ description: ct.description || undefined,
1132
+ status: toSqliteStatus(ct.status),
1133
+ assigned_to: ct.owner || undefined,
1134
+ project_id: projectId,
1135
+ metadata: { ...ct.metadata, claude_task_id: ct.id },
1136
+ priority: ct.metadata?.["priority"] || "medium"
1137
+ });
1138
+ }
1139
+ pulled++;
1140
+ } catch (e) {
1141
+ errors.push(`pull ${f}: ${e instanceof Error ? e.message : String(e)}`);
1142
+ }
1143
+ }
1144
+ return { pushed: 0, pulled, errors };
1145
+ }
1146
+ function syncClaudeTaskList(taskListId, projectId, options = {}) {
1147
+ const pullResult = pullFromClaudeTaskList(taskListId, projectId, options);
1148
+ const pushResult = pushToClaudeTaskList(taskListId, projectId, options);
1149
+ return {
1150
+ pushed: pushResult.pushed,
1151
+ pulled: pullResult.pulled,
1152
+ errors: [...pullResult.errors, ...pushResult.errors]
1153
+ };
1154
+ }
1155
+
1156
+ // src/lib/agent-tasks.ts
1157
+ import { existsSync as existsSync5 } from "fs";
1158
+ import { join as join5 } from "path";
1159
+
1160
+ // src/lib/config.ts
1161
+ import { existsSync as existsSync4 } from "fs";
1162
+ import { join as join4 } from "path";
1163
+ var CONFIG_PATH = join4(HOME, ".todos", "config.json");
1164
+ var cached = null;
1165
+ function normalizeAgent(agent) {
1166
+ return agent.trim().toLowerCase();
1167
+ }
1168
+ function loadConfig() {
1169
+ if (cached)
1170
+ return cached;
1171
+ if (!existsSync4(CONFIG_PATH)) {
1172
+ cached = {};
1173
+ return cached;
1174
+ }
1175
+ const config = readJsonFile(CONFIG_PATH) || {};
1176
+ if (typeof config.sync_agents === "string") {
1177
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1178
+ }
1179
+ cached = config;
1180
+ return cached;
1181
+ }
1182
+ function getSyncAgentsFromConfig() {
1183
+ const config = loadConfig();
1184
+ const agents = config.sync_agents;
1185
+ if (Array.isArray(agents) && agents.length > 0)
1186
+ return agents.map(normalizeAgent);
1187
+ return null;
1188
+ }
1189
+ function getAgentTasksDir(agent) {
1190
+ const config = loadConfig();
1191
+ const key = normalizeAgent(agent);
1192
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
1193
+ }
1194
+
1195
+ // src/lib/agent-tasks.ts
1196
+ function agentBaseDir(agent) {
1197
+ const key = `TODOS_${agent.toUpperCase()}_TASKS_DIR`;
1198
+ return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join5(HOME, ".todos", "agents");
1199
+ }
1200
+ function getTaskListDir2(agent, taskListId) {
1201
+ return join5(agentBaseDir(agent), agent, taskListId);
1202
+ }
1203
+ function readAgentTask(dir, filename) {
1204
+ return readJsonFile(join5(dir, filename));
1205
+ }
1206
+ function writeAgentTask(dir, task) {
1207
+ writeJsonFile(join5(dir, `${task.id}.json`), task);
1208
+ }
1209
+ function taskToAgentTask(task, externalId, existingMeta) {
1210
+ return {
1211
+ id: externalId,
1212
+ title: task.title,
1213
+ description: task.description || "",
1214
+ status: task.status,
1215
+ priority: task.priority,
1216
+ assigned_to: task.assigned_to || task.agent_id || "",
1217
+ tags: task.tags || [],
1218
+ metadata: {
1219
+ ...existingMeta || {},
1220
+ ...task.metadata,
1221
+ todos_id: task.id,
1222
+ todos_updated_at: task.updated_at,
1223
+ todos_version: task.version
1224
+ }
1225
+ };
1226
+ }
1227
+ function metadataKey(agent) {
1228
+ return `${agent}_task_id`;
1229
+ }
1230
+ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
1231
+ const dir = getTaskListDir2(agent, taskListId);
1232
+ if (!existsSync5(dir))
1233
+ ensureDir2(dir);
1234
+ const filter = {};
1235
+ if (projectId)
1236
+ filter["project_id"] = projectId;
1237
+ const tasks = listTasks(filter);
1238
+ const existingByTodosId = new Map;
1239
+ const files = listJsonFiles(dir);
1240
+ for (const f of files) {
1241
+ const path = join5(dir, f);
1242
+ const at = readAgentTask(dir, f);
1243
+ if (at?.metadata?.["todos_id"]) {
1244
+ existingByTodosId.set(at.metadata["todos_id"], { task: at, mtimeMs: getFileMtimeMs(path) });
1245
+ }
1246
+ }
1247
+ let hwm = readHighWaterMark(dir);
1248
+ let pushed = 0;
1249
+ const errors = [];
1250
+ const metaKey = metadataKey(agent);
1251
+ const prefer = options.prefer || "remote";
1252
+ for (const task of tasks) {
1253
+ try {
1254
+ const existing = existingByTodosId.get(task.id);
1255
+ if (existing) {
1256
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
1257
+ const localUpdatedAt = parseTimestamp(task.updated_at);
1258
+ const remoteUpdatedAt = existing.mtimeMs;
1259
+ let recordConflict = false;
1260
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
1261
+ if (prefer === "remote") {
1262
+ const conflict = {
1263
+ agent,
1264
+ direction: "push",
1265
+ prefer,
1266
+ local_updated_at: task.updated_at,
1267
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
1268
+ detected_at: new Date().toISOString()
1269
+ };
1270
+ const newMeta = appendSyncConflict(task.metadata, conflict);
1271
+ updateTask(task.id, { version: task.version, metadata: newMeta });
1272
+ errors.push(`conflict push ${task.id}: remote newer`);
1273
+ continue;
1274
+ }
1275
+ recordConflict = true;
1276
+ }
1277
+ const updated = taskToAgentTask(task, existing.task.id, existing.task.metadata);
1278
+ writeAgentTask(dir, updated);
1279
+ if (recordConflict) {
1280
+ const latest = getTask(task.id);
1281
+ if (latest) {
1282
+ const conflict = {
1283
+ agent,
1284
+ direction: "push",
1285
+ prefer,
1286
+ local_updated_at: latest.updated_at,
1287
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
1288
+ detected_at: new Date().toISOString()
1289
+ };
1290
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
1291
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
1292
+ }
1293
+ }
1294
+ } else {
1295
+ const externalId = String(hwm);
1296
+ hwm++;
1297
+ const at = taskToAgentTask(task, externalId);
1298
+ writeAgentTask(dir, at);
1299
+ const current = getTask(task.id);
1300
+ if (current) {
1301
+ const newMeta = { ...current.metadata, [metaKey]: externalId };
1302
+ updateTask(task.id, { version: current.version, metadata: newMeta });
1303
+ }
1304
+ }
1305
+ pushed++;
1306
+ } catch (e) {
1307
+ errors.push(`push ${task.id}: ${e instanceof Error ? e.message : String(e)}`);
1308
+ }
1309
+ }
1310
+ writeHighWaterMark(dir, hwm);
1311
+ return { pushed, pulled: 0, errors };
1312
+ }
1313
+ function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
1314
+ const dir = getTaskListDir2(agent, taskListId);
1315
+ if (!existsSync5(dir)) {
1316
+ return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
1317
+ }
1318
+ const files = listJsonFiles(dir);
1319
+ let pulled = 0;
1320
+ const errors = [];
1321
+ const metaKey = metadataKey(agent);
1322
+ const prefer = options.prefer || "remote";
1323
+ const allTasks = listTasks({});
1324
+ const byExternalId = new Map;
1325
+ const byTodosId = new Map;
1326
+ for (const t of allTasks) {
1327
+ const extId = t.metadata[metaKey];
1328
+ if (extId)
1329
+ byExternalId.set(String(extId), t);
1330
+ byTodosId.set(t.id, t);
1331
+ }
1332
+ for (const f of files) {
1333
+ try {
1334
+ const filePath = join5(dir, f);
1335
+ const at = readAgentTask(dir, f);
1336
+ if (!at)
1337
+ continue;
1338
+ if (at.metadata?.["_internal"])
1339
+ continue;
1340
+ const todosId = at.metadata?.["todos_id"];
1341
+ const existingByMapping = byExternalId.get(at.id);
1342
+ const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
1343
+ const existing = existingByMapping || existingByTodos;
1344
+ if (existing) {
1345
+ const lastSyncedAt = parseTimestamp(at.metadata?.["todos_updated_at"]);
1346
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
1347
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
1348
+ let conflictMeta = null;
1349
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
1350
+ const conflict = {
1351
+ agent,
1352
+ direction: "pull",
1353
+ prefer,
1354
+ local_updated_at: existing.updated_at,
1355
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
1356
+ detected_at: new Date().toISOString()
1357
+ };
1358
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
1359
+ if (prefer === "local") {
1360
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
1361
+ errors.push(`conflict pull ${existing.id}: local newer`);
1362
+ continue;
1363
+ }
1364
+ }
1365
+ updateTask(existing.id, {
1366
+ version: existing.version,
1367
+ title: at.title,
1368
+ description: at.description || undefined,
1369
+ status: at.status,
1370
+ priority: at.priority,
1371
+ assigned_to: at.assigned_to || undefined,
1372
+ tags: at.tags || [],
1373
+ metadata: { ...conflictMeta || existing.metadata, ...at.metadata, [metaKey]: at.id }
1374
+ });
1375
+ } else {
1376
+ createTask({
1377
+ title: at.title,
1378
+ description: at.description || undefined,
1379
+ status: at.status,
1380
+ priority: at.priority || "medium",
1381
+ assigned_to: at.assigned_to || undefined,
1382
+ tags: at.tags || [],
1383
+ project_id: projectId,
1384
+ metadata: { ...at.metadata, [metaKey]: at.id }
1385
+ });
1386
+ }
1387
+ pulled++;
1388
+ } catch (e) {
1389
+ errors.push(`pull ${f}: ${e instanceof Error ? e.message : String(e)}`);
1390
+ }
1391
+ }
1392
+ return { pushed: 0, pulled, errors };
1393
+ }
1394
+ function syncAgentTaskList(agent, taskListId, projectId, options = {}) {
1395
+ const pullResult = pullFromAgentTaskList(agent, taskListId, projectId, options);
1396
+ const pushResult = pushToAgentTaskList(agent, taskListId, projectId, options);
1397
+ return {
1398
+ pushed: pushResult.pushed,
1399
+ pulled: pullResult.pulled,
1400
+ errors: [...pullResult.errors, ...pushResult.errors]
1401
+ };
1402
+ }
1403
+
1404
+ // src/lib/sync.ts
1405
+ function normalizeAgent2(agent) {
1406
+ return agent.trim().toLowerCase();
1407
+ }
1408
+ function isClaudeAgent(agent) {
1409
+ const a = normalizeAgent2(agent);
1410
+ return a === "claude" || a === "claude-code" || a === "claude_code";
1411
+ }
1412
+ function defaultSyncAgents() {
1413
+ const env = process.env["TODOS_SYNC_AGENTS"];
1414
+ if (env) {
1415
+ return env.split(",").map((a) => a.trim()).filter(Boolean);
1416
+ }
1417
+ const fromConfig = getSyncAgentsFromConfig();
1418
+ if (fromConfig && fromConfig.length > 0)
1419
+ return fromConfig;
1420
+ return ["claude", "codex", "gemini"];
1421
+ }
1422
+ function syncWithAgent(agent, taskListId, projectId, direction = "both", options = {}) {
1423
+ const normalized = normalizeAgent2(agent);
1424
+ if (isClaudeAgent(normalized)) {
1425
+ if (direction === "push")
1426
+ return pushToClaudeTaskList(taskListId, projectId, options);
1427
+ if (direction === "pull")
1428
+ return pullFromClaudeTaskList(taskListId, projectId, options);
1429
+ return syncClaudeTaskList(taskListId, projectId, options);
1430
+ }
1431
+ if (direction === "push")
1432
+ return pushToAgentTaskList(normalized, taskListId, projectId, options);
1433
+ if (direction === "pull")
1434
+ return pullFromAgentTaskList(normalized, taskListId, projectId, options);
1435
+ return syncAgentTaskList(normalized, taskListId, projectId, options);
1436
+ }
1437
+ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both", options = {}) {
1438
+ let pushed = 0;
1439
+ let pulled = 0;
1440
+ const errors = [];
1441
+ const normalized = agents.map(normalizeAgent2);
1442
+ if (direction === "pull" || direction === "both") {
1443
+ for (const agent of normalized) {
1444
+ const listId = taskListIdByAgent(agent);
1445
+ if (!listId) {
1446
+ errors.push(`sync ${agent}: missing task list id`);
1447
+ continue;
1448
+ }
1449
+ const result = syncWithAgent(agent, listId, projectId, "pull", options);
1450
+ pushed += result.pushed;
1451
+ pulled += result.pulled;
1452
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
1453
+ }
1454
+ }
1455
+ if (direction === "push" || direction === "both") {
1456
+ for (const agent of normalized) {
1457
+ const listId = taskListIdByAgent(agent);
1458
+ if (!listId) {
1459
+ errors.push(`sync ${agent}: missing task list id`);
1460
+ continue;
1461
+ }
1462
+ const result = syncWithAgent(agent, listId, projectId, "push", options);
1463
+ pushed += result.pushed;
1464
+ pulled += result.pulled;
1465
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
1466
+ }
1467
+ }
1468
+ return { pushed, pulled, errors };
1469
+ }
668
1470
  export {
669
1471
  updateTask,
670
1472
  updateSessionActivity,
671
1473
  updateProject,
1474
+ updatePlan,
672
1475
  unlockTask,
1476
+ syncWithAgents,
1477
+ syncWithAgent,
673
1478
  startTask,
1479
+ slugify,
674
1480
  searchTasks,
675
1481
  resolvePartialId,
676
1482
  resetDatabase,
677
1483
  removeDependency,
678
1484
  lockTask,
1485
+ loadConfig,
679
1486
  listTasks,
680
1487
  listSessions,
681
1488
  listProjects,
1489
+ listPlans,
682
1490
  listComments,
683
1491
  getTaskWithRelations,
684
1492
  getTaskDependents,
@@ -687,16 +1495,20 @@ export {
687
1495
  getSession,
688
1496
  getProjectByPath,
689
1497
  getProject,
1498
+ getPlan,
690
1499
  getDatabase,
691
1500
  getComment,
692
1501
  ensureProject,
693
1502
  deleteTask,
694
1503
  deleteSession,
695
1504
  deleteProject,
1505
+ deletePlan,
696
1506
  deleteComment,
1507
+ defaultSyncAgents,
697
1508
  createTask,
698
1509
  createSession,
699
1510
  createProject,
1511
+ createPlan,
700
1512
  completeTask,
701
1513
  closeDatabase,
702
1514
  addDependency,
@@ -706,6 +1518,8 @@ export {
706
1518
  TASK_STATUSES,
707
1519
  TASK_PRIORITIES,
708
1520
  ProjectNotFoundError,
1521
+ PlanNotFoundError,
1522
+ PLAN_STATUSES,
709
1523
  LockError,
710
1524
  DependencyCycleError
711
1525
  };