@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/README.md +33 -1
- package/dist/cli/index.js +1649 -482
- package/dist/index.d.ts +42 -0
- package/dist/index.js +845 -31
- package/dist/mcp/index.js +853 -75
- package/package.json +1 -1
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
|
|
13
|
-
if (
|
|
14
|
-
return
|
|
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
|
-
|
|
248
|
-
|
|
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(
|
|
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
|
|
348
|
-
conditions.push(`(${
|
|
349
|
-
params.push(...filter.tags
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
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
|
};
|