@hasna/todos 0.9.6 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2379,7 +2379,7 @@ var init_database = __esm(() => {
2379
2379
  });
2380
2380
 
2381
2381
  // src/types/index.ts
2382
- var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, TaskListNotFoundError, DependencyCycleError;
2382
+ var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError;
2383
2383
  var init_types = __esm(() => {
2384
2384
  VersionConflictError = class VersionConflictError extends Error {
2385
2385
  taskId;
@@ -2445,6 +2445,16 @@ var init_types = __esm(() => {
2445
2445
  this.name = "DependencyCycleError";
2446
2446
  }
2447
2447
  };
2448
+ CompletionGuardError = class CompletionGuardError extends Error {
2449
+ reason;
2450
+ retryAfterSeconds;
2451
+ constructor(reason, retryAfterSeconds) {
2452
+ super(reason);
2453
+ this.reason = reason;
2454
+ this.retryAfterSeconds = retryAfterSeconds;
2455
+ this.name = "CompletionGuardError";
2456
+ }
2457
+ };
2448
2458
  });
2449
2459
 
2450
2460
  // src/db/projects.ts
@@ -2546,6 +2556,173 @@ var init_projects = __esm(() => {
2546
2556
  init_database();
2547
2557
  });
2548
2558
 
2559
+ // src/lib/sync-utils.ts
2560
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
2561
+ import { join as join2 } from "path";
2562
+ function ensureDir2(dir) {
2563
+ if (!existsSync2(dir))
2564
+ mkdirSync2(dir, { recursive: true });
2565
+ }
2566
+ function listJsonFiles(dir) {
2567
+ if (!existsSync2(dir))
2568
+ return [];
2569
+ return readdirSync(dir).filter((f) => f.endsWith(".json"));
2570
+ }
2571
+ function readJsonFile(path) {
2572
+ try {
2573
+ return JSON.parse(readFileSync(path, "utf-8"));
2574
+ } catch {
2575
+ return null;
2576
+ }
2577
+ }
2578
+ function writeJsonFile(path, data) {
2579
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
2580
+ `);
2581
+ }
2582
+ function readHighWaterMark(dir) {
2583
+ const path = join2(dir, ".highwatermark");
2584
+ if (!existsSync2(path))
2585
+ return 1;
2586
+ const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
2587
+ return isNaN(val) ? 1 : val;
2588
+ }
2589
+ function writeHighWaterMark(dir, value) {
2590
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
2591
+ }
2592
+ function getFileMtimeMs(path) {
2593
+ try {
2594
+ return statSync(path).mtimeMs;
2595
+ } catch {
2596
+ return null;
2597
+ }
2598
+ }
2599
+ function parseTimestamp(value) {
2600
+ if (typeof value !== "string")
2601
+ return null;
2602
+ const parsed = Date.parse(value);
2603
+ return Number.isNaN(parsed) ? null : parsed;
2604
+ }
2605
+ function appendSyncConflict(metadata, conflict, limit = 5) {
2606
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
2607
+ const next = [conflict, ...current].slice(0, limit);
2608
+ return { ...metadata, sync_conflicts: next };
2609
+ }
2610
+ var HOME;
2611
+ var init_sync_utils = __esm(() => {
2612
+ HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
2613
+ });
2614
+
2615
+ // src/lib/config.ts
2616
+ import { existsSync as existsSync3 } from "fs";
2617
+ import { join as join3 } from "path";
2618
+ function normalizeAgent(agent) {
2619
+ return agent.trim().toLowerCase();
2620
+ }
2621
+ function loadConfig() {
2622
+ if (cached)
2623
+ return cached;
2624
+ if (!existsSync3(CONFIG_PATH)) {
2625
+ cached = {};
2626
+ return cached;
2627
+ }
2628
+ const config = readJsonFile(CONFIG_PATH) || {};
2629
+ if (typeof config.sync_agents === "string") {
2630
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
2631
+ }
2632
+ cached = config;
2633
+ return cached;
2634
+ }
2635
+ function getSyncAgentsFromConfig() {
2636
+ const config = loadConfig();
2637
+ const agents = config.sync_agents;
2638
+ if (Array.isArray(agents) && agents.length > 0)
2639
+ return agents.map(normalizeAgent);
2640
+ return null;
2641
+ }
2642
+ function getAgentTaskListId(agent) {
2643
+ const config = loadConfig();
2644
+ const key = normalizeAgent(agent);
2645
+ return config.agents?.[key]?.task_list_id || config.task_list_id || null;
2646
+ }
2647
+ function getAgentTasksDir(agent) {
2648
+ const config = loadConfig();
2649
+ const key = normalizeAgent(agent);
2650
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
2651
+ }
2652
+ function getTaskPrefixConfig() {
2653
+ const config = loadConfig();
2654
+ return config.task_prefix || null;
2655
+ }
2656
+ function getCompletionGuardConfig(projectPath) {
2657
+ const config = loadConfig();
2658
+ const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
2659
+ if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
2660
+ return { ...global, ...config.project_overrides[projectPath].completion_guard };
2661
+ }
2662
+ return global;
2663
+ }
2664
+ var CONFIG_PATH, cached = null, GUARD_DEFAULTS;
2665
+ var init_config = __esm(() => {
2666
+ init_sync_utils();
2667
+ CONFIG_PATH = join3(HOME, ".todos", "config.json");
2668
+ GUARD_DEFAULTS = {
2669
+ enabled: false,
2670
+ min_work_seconds: 30,
2671
+ max_completions_per_window: 5,
2672
+ window_minutes: 10,
2673
+ cooldown_seconds: 60
2674
+ };
2675
+ });
2676
+
2677
+ // src/lib/completion-guard.ts
2678
+ function checkCompletionGuard(task, agentId, db, configOverride) {
2679
+ let config;
2680
+ if (configOverride) {
2681
+ config = configOverride;
2682
+ } else {
2683
+ const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
2684
+ config = getCompletionGuardConfig(projectPath);
2685
+ }
2686
+ if (!config.enabled)
2687
+ return;
2688
+ if (task.status !== "in_progress") {
2689
+ throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
2690
+ }
2691
+ const agent = agentId || task.assigned_to || task.agent_id;
2692
+ if (config.min_work_seconds && task.locked_at) {
2693
+ const startedAt = new Date(task.locked_at).getTime();
2694
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
2695
+ if (elapsedSeconds < config.min_work_seconds) {
2696
+ const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
2697
+ throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
2698
+ }
2699
+ }
2700
+ if (agent && config.max_completions_per_window && config.window_minutes) {
2701
+ const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
2702
+ const result = db.query(`SELECT COUNT(*) as count FROM tasks
2703
+ WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
2704
+ if (result.count >= config.max_completions_per_window) {
2705
+ throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
2706
+ }
2707
+ }
2708
+ if (agent && config.cooldown_seconds) {
2709
+ const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
2710
+ WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
2711
+ if (result.last_completed) {
2712
+ const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
2713
+ if (elapsedSeconds < config.cooldown_seconds) {
2714
+ const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
2715
+ throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
2716
+ }
2717
+ }
2718
+ }
2719
+ }
2720
+ var init_completion_guard = __esm(() => {
2721
+ init_types();
2722
+ init_config();
2723
+ init_projects();
2724
+ });
2725
+
2549
2726
  // src/db/tasks.ts
2550
2727
  function rowToTask(row) {
2551
2728
  return {
@@ -2723,6 +2900,9 @@ function updateTask(id, input, db) {
2723
2900
  params.push(input.description);
2724
2901
  }
2725
2902
  if (input.status !== undefined) {
2903
+ if (input.status === "completed") {
2904
+ checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
2905
+ }
2726
2906
  sets.push("status = ?");
2727
2907
  params.push(input.status);
2728
2908
  if (input.status === "completed") {
@@ -2794,6 +2974,7 @@ function completeTask(id, agentId, db) {
2794
2974
  if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
2795
2975
  throw new LockError(id, task.locked_by);
2796
2976
  }
2977
+ checkCompletionGuard(task, agentId || null, d);
2797
2978
  const timestamp = now();
2798
2979
  d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
2799
2980
  WHERE id = ?`, [timestamp, timestamp, id]);
@@ -2876,6 +3057,7 @@ var init_tasks = __esm(() => {
2876
3057
  init_types();
2877
3058
  init_database();
2878
3059
  init_projects();
3060
+ init_completion_guard();
2879
3061
  });
2880
3062
 
2881
3063
  // src/db/agents.ts
@@ -3111,109 +3293,6 @@ var init_search = __esm(() => {
3111
3293
  init_database();
3112
3294
  });
3113
3295
 
3114
- // src/lib/sync-utils.ts
3115
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
3116
- import { join as join2 } from "path";
3117
- function ensureDir2(dir) {
3118
- if (!existsSync2(dir))
3119
- mkdirSync2(dir, { recursive: true });
3120
- }
3121
- function listJsonFiles(dir) {
3122
- if (!existsSync2(dir))
3123
- return [];
3124
- return readdirSync(dir).filter((f) => f.endsWith(".json"));
3125
- }
3126
- function readJsonFile(path) {
3127
- try {
3128
- return JSON.parse(readFileSync(path, "utf-8"));
3129
- } catch {
3130
- return null;
3131
- }
3132
- }
3133
- function writeJsonFile(path, data) {
3134
- writeFileSync(path, JSON.stringify(data, null, 2) + `
3135
- `);
3136
- }
3137
- function readHighWaterMark(dir) {
3138
- const path = join2(dir, ".highwatermark");
3139
- if (!existsSync2(path))
3140
- return 1;
3141
- const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
3142
- return isNaN(val) ? 1 : val;
3143
- }
3144
- function writeHighWaterMark(dir, value) {
3145
- writeFileSync(join2(dir, ".highwatermark"), String(value));
3146
- }
3147
- function getFileMtimeMs(path) {
3148
- try {
3149
- return statSync(path).mtimeMs;
3150
- } catch {
3151
- return null;
3152
- }
3153
- }
3154
- function parseTimestamp(value) {
3155
- if (typeof value !== "string")
3156
- return null;
3157
- const parsed = Date.parse(value);
3158
- return Number.isNaN(parsed) ? null : parsed;
3159
- }
3160
- function appendSyncConflict(metadata, conflict, limit = 5) {
3161
- const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
3162
- const next = [conflict, ...current].slice(0, limit);
3163
- return { ...metadata, sync_conflicts: next };
3164
- }
3165
- var HOME;
3166
- var init_sync_utils = __esm(() => {
3167
- HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
3168
- });
3169
-
3170
- // src/lib/config.ts
3171
- import { existsSync as existsSync3 } from "fs";
3172
- import { join as join3 } from "path";
3173
- function normalizeAgent(agent) {
3174
- return agent.trim().toLowerCase();
3175
- }
3176
- function loadConfig() {
3177
- if (cached)
3178
- return cached;
3179
- if (!existsSync3(CONFIG_PATH)) {
3180
- cached = {};
3181
- return cached;
3182
- }
3183
- const config = readJsonFile(CONFIG_PATH) || {};
3184
- if (typeof config.sync_agents === "string") {
3185
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
3186
- }
3187
- cached = config;
3188
- return cached;
3189
- }
3190
- function getSyncAgentsFromConfig() {
3191
- const config = loadConfig();
3192
- const agents = config.sync_agents;
3193
- if (Array.isArray(agents) && agents.length > 0)
3194
- return agents.map(normalizeAgent);
3195
- return null;
3196
- }
3197
- function getAgentTaskListId(agent) {
3198
- const config = loadConfig();
3199
- const key = normalizeAgent(agent);
3200
- return config.agents?.[key]?.task_list_id || config.task_list_id || null;
3201
- }
3202
- function getAgentTasksDir(agent) {
3203
- const config = loadConfig();
3204
- const key = normalizeAgent(agent);
3205
- return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
3206
- }
3207
- function getTaskPrefixConfig() {
3208
- const config = loadConfig();
3209
- return config.task_prefix || null;
3210
- }
3211
- var CONFIG_PATH, cached = null;
3212
- var init_config = __esm(() => {
3213
- init_sync_utils();
3214
- CONFIG_PATH = join3(HOME, ".todos", "config.json");
3215
- });
3216
-
3217
3296
  // src/lib/claude-tasks.ts
3218
3297
  import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
3219
3298
  import { join as join4 } from "path";
@@ -7729,6 +7808,10 @@ function formatError(error) {
7729
7808
  return `Lock error: ${error.message}`;
7730
7809
  if (error instanceof DependencyCycleError)
7731
7810
  return `Dependency cycle: ${error.message}`;
7811
+ if (error instanceof CompletionGuardError) {
7812
+ const retry = error.retryAfterSeconds ? ` (retry after ${error.retryAfterSeconds}s)` : "";
7813
+ return `Completion blocked: ${error.reason}${retry}`;
7814
+ }
7732
7815
  if (error instanceof Error)
7733
7816
  return error.message;
7734
7817
  return String(error);
package/dist/index.js CHANGED
@@ -417,6 +417,17 @@ class DependencyCycleError extends Error {
417
417
  }
418
418
  }
419
419
 
420
+ class CompletionGuardError extends Error {
421
+ reason;
422
+ retryAfterSeconds;
423
+ constructor(reason, retryAfterSeconds) {
424
+ super(reason);
425
+ this.reason = reason;
426
+ this.retryAfterSeconds = retryAfterSeconds;
427
+ this.name = "CompletionGuardError";
428
+ }
429
+ }
430
+
420
431
  // src/db/projects.ts
421
432
  function slugify(name) {
422
433
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -517,6 +528,159 @@ function ensureProject(name, path, db) {
517
528
  return createProject({ name, path }, d);
518
529
  }
519
530
 
531
+ // src/lib/config.ts
532
+ import { existsSync as existsSync3 } from "fs";
533
+ import { join as join3 } from "path";
534
+
535
+ // src/lib/sync-utils.ts
536
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
537
+ import { join as join2 } from "path";
538
+ var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
539
+ function ensureDir2(dir) {
540
+ if (!existsSync2(dir))
541
+ mkdirSync2(dir, { recursive: true });
542
+ }
543
+ function listJsonFiles(dir) {
544
+ if (!existsSync2(dir))
545
+ return [];
546
+ return readdirSync(dir).filter((f) => f.endsWith(".json"));
547
+ }
548
+ function readJsonFile(path) {
549
+ try {
550
+ return JSON.parse(readFileSync(path, "utf-8"));
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+ function writeJsonFile(path, data) {
556
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
557
+ `);
558
+ }
559
+ function readHighWaterMark(dir) {
560
+ const path = join2(dir, ".highwatermark");
561
+ if (!existsSync2(path))
562
+ return 1;
563
+ const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
564
+ return isNaN(val) ? 1 : val;
565
+ }
566
+ function writeHighWaterMark(dir, value) {
567
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
568
+ }
569
+ function getFileMtimeMs(path) {
570
+ try {
571
+ return statSync(path).mtimeMs;
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+ function parseTimestamp(value) {
577
+ if (typeof value !== "string")
578
+ return null;
579
+ const parsed = Date.parse(value);
580
+ return Number.isNaN(parsed) ? null : parsed;
581
+ }
582
+ function appendSyncConflict(metadata, conflict, limit = 5) {
583
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
584
+ const next = [conflict, ...current].slice(0, limit);
585
+ return { ...metadata, sync_conflicts: next };
586
+ }
587
+
588
+ // src/lib/config.ts
589
+ var CONFIG_PATH = join3(HOME, ".todos", "config.json");
590
+ var cached = null;
591
+ function normalizeAgent(agent) {
592
+ return agent.trim().toLowerCase();
593
+ }
594
+ function loadConfig() {
595
+ if (cached)
596
+ return cached;
597
+ if (!existsSync3(CONFIG_PATH)) {
598
+ cached = {};
599
+ return cached;
600
+ }
601
+ const config = readJsonFile(CONFIG_PATH) || {};
602
+ if (typeof config.sync_agents === "string") {
603
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
604
+ }
605
+ cached = config;
606
+ return cached;
607
+ }
608
+ function getSyncAgentsFromConfig() {
609
+ const config = loadConfig();
610
+ const agents = config.sync_agents;
611
+ if (Array.isArray(agents) && agents.length > 0)
612
+ return agents.map(normalizeAgent);
613
+ return null;
614
+ }
615
+ function getAgentTasksDir(agent) {
616
+ const config = loadConfig();
617
+ const key = normalizeAgent(agent);
618
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
619
+ }
620
+ function getTaskPrefixConfig() {
621
+ const config = loadConfig();
622
+ return config.task_prefix || null;
623
+ }
624
+ var GUARD_DEFAULTS = {
625
+ enabled: false,
626
+ min_work_seconds: 30,
627
+ max_completions_per_window: 5,
628
+ window_minutes: 10,
629
+ cooldown_seconds: 60
630
+ };
631
+ function getCompletionGuardConfig(projectPath) {
632
+ const config = loadConfig();
633
+ const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
634
+ if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
635
+ return { ...global, ...config.project_overrides[projectPath].completion_guard };
636
+ }
637
+ return global;
638
+ }
639
+
640
+ // src/lib/completion-guard.ts
641
+ function checkCompletionGuard(task, agentId, db, configOverride) {
642
+ let config;
643
+ if (configOverride) {
644
+ config = configOverride;
645
+ } else {
646
+ const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
647
+ config = getCompletionGuardConfig(projectPath);
648
+ }
649
+ if (!config.enabled)
650
+ return;
651
+ if (task.status !== "in_progress") {
652
+ throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
653
+ }
654
+ const agent = agentId || task.assigned_to || task.agent_id;
655
+ if (config.min_work_seconds && task.locked_at) {
656
+ const startedAt = new Date(task.locked_at).getTime();
657
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
658
+ if (elapsedSeconds < config.min_work_seconds) {
659
+ const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
660
+ throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
661
+ }
662
+ }
663
+ if (agent && config.max_completions_per_window && config.window_minutes) {
664
+ const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
665
+ const result = db.query(`SELECT COUNT(*) as count FROM tasks
666
+ WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
667
+ if (result.count >= config.max_completions_per_window) {
668
+ throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
669
+ }
670
+ }
671
+ if (agent && config.cooldown_seconds) {
672
+ const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
673
+ WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
674
+ if (result.last_completed) {
675
+ const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
676
+ if (elapsedSeconds < config.cooldown_seconds) {
677
+ const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
678
+ throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
679
+ }
680
+ }
681
+ }
682
+ }
683
+
520
684
  // src/db/tasks.ts
521
685
  function rowToTask(row) {
522
686
  return {
@@ -694,6 +858,9 @@ function updateTask(id, input, db) {
694
858
  params.push(input.description);
695
859
  }
696
860
  if (input.status !== undefined) {
861
+ if (input.status === "completed") {
862
+ checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
863
+ }
697
864
  sets.push("status = ?");
698
865
  params.push(input.status);
699
866
  if (input.status === "completed") {
@@ -765,6 +932,7 @@ function completeTask(id, agentId, db) {
765
932
  if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
766
933
  throw new LockError(id, task.locked_by);
767
934
  }
935
+ checkCompletionGuard(task, agentId || null, d);
768
936
  const timestamp = now();
769
937
  d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
770
938
  WHERE id = ?`, [timestamp, timestamp, id]);
@@ -1140,102 +1308,6 @@ function searchTasks(query, projectId, taskListId, db) {
1140
1308
  // src/lib/claude-tasks.ts
1141
1309
  import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
1142
1310
  import { join as join4 } from "path";
1143
-
1144
- // src/lib/config.ts
1145
- import { existsSync as existsSync3 } from "fs";
1146
- import { join as join3 } from "path";
1147
-
1148
- // src/lib/sync-utils.ts
1149
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
1150
- import { join as join2 } from "path";
1151
- var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
1152
- function ensureDir2(dir) {
1153
- if (!existsSync2(dir))
1154
- mkdirSync2(dir, { recursive: true });
1155
- }
1156
- function listJsonFiles(dir) {
1157
- if (!existsSync2(dir))
1158
- return [];
1159
- return readdirSync(dir).filter((f) => f.endsWith(".json"));
1160
- }
1161
- function readJsonFile(path) {
1162
- try {
1163
- return JSON.parse(readFileSync(path, "utf-8"));
1164
- } catch {
1165
- return null;
1166
- }
1167
- }
1168
- function writeJsonFile(path, data) {
1169
- writeFileSync(path, JSON.stringify(data, null, 2) + `
1170
- `);
1171
- }
1172
- function readHighWaterMark(dir) {
1173
- const path = join2(dir, ".highwatermark");
1174
- if (!existsSync2(path))
1175
- return 1;
1176
- const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
1177
- return isNaN(val) ? 1 : val;
1178
- }
1179
- function writeHighWaterMark(dir, value) {
1180
- writeFileSync(join2(dir, ".highwatermark"), String(value));
1181
- }
1182
- function getFileMtimeMs(path) {
1183
- try {
1184
- return statSync(path).mtimeMs;
1185
- } catch {
1186
- return null;
1187
- }
1188
- }
1189
- function parseTimestamp(value) {
1190
- if (typeof value !== "string")
1191
- return null;
1192
- const parsed = Date.parse(value);
1193
- return Number.isNaN(parsed) ? null : parsed;
1194
- }
1195
- function appendSyncConflict(metadata, conflict, limit = 5) {
1196
- const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
1197
- const next = [conflict, ...current].slice(0, limit);
1198
- return { ...metadata, sync_conflicts: next };
1199
- }
1200
-
1201
- // src/lib/config.ts
1202
- var CONFIG_PATH = join3(HOME, ".todos", "config.json");
1203
- var cached = null;
1204
- function normalizeAgent(agent) {
1205
- return agent.trim().toLowerCase();
1206
- }
1207
- function loadConfig() {
1208
- if (cached)
1209
- return cached;
1210
- if (!existsSync3(CONFIG_PATH)) {
1211
- cached = {};
1212
- return cached;
1213
- }
1214
- const config = readJsonFile(CONFIG_PATH) || {};
1215
- if (typeof config.sync_agents === "string") {
1216
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1217
- }
1218
- cached = config;
1219
- return cached;
1220
- }
1221
- function getSyncAgentsFromConfig() {
1222
- const config = loadConfig();
1223
- const agents = config.sync_agents;
1224
- if (Array.isArray(agents) && agents.length > 0)
1225
- return agents.map(normalizeAgent);
1226
- return null;
1227
- }
1228
- function getAgentTasksDir(agent) {
1229
- const config = loadConfig();
1230
- const key = normalizeAgent(agent);
1231
- return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
1232
- }
1233
- function getTaskPrefixConfig() {
1234
- const config = loadConfig();
1235
- return config.task_prefix || null;
1236
- }
1237
-
1238
- // src/lib/claude-tasks.ts
1239
1311
  function getTaskListDir(taskListId) {
1240
1312
  return join4(HOME, ".claude", "tasks", taskListId);
1241
1313
  }
@@ -1789,6 +1861,7 @@ export {
1789
1861
  getProject,
1790
1862
  getPlan,
1791
1863
  getDatabase,
1864
+ getCompletionGuardConfig,
1792
1865
  getComment,
1793
1866
  getAgentByName,
1794
1867
  getAgent,
@@ -1809,6 +1882,7 @@ export {
1809
1882
  createPlan,
1810
1883
  completeTask,
1811
1884
  closeDatabase,
1885
+ checkCompletionGuard,
1812
1886
  addDependency,
1813
1887
  addComment,
1814
1888
  VersionConflictError,
@@ -1821,5 +1895,6 @@ export {
1821
1895
  PLAN_STATUSES,
1822
1896
  LockError,
1823
1897
  DependencyCycleError,
1898
+ CompletionGuardError,
1824
1899
  AgentNotFoundError
1825
1900
  };
package/dist/mcp/index.js CHANGED
@@ -4049,6 +4049,17 @@ class DependencyCycleError extends Error {
4049
4049
  }
4050
4050
  }
4051
4051
 
4052
+ class CompletionGuardError extends Error {
4053
+ reason;
4054
+ retryAfterSeconds;
4055
+ constructor(reason, retryAfterSeconds) {
4056
+ super(reason);
4057
+ this.reason = reason;
4058
+ this.retryAfterSeconds = retryAfterSeconds;
4059
+ this.name = "CompletionGuardError";
4060
+ }
4061
+ }
4062
+
4052
4063
  // src/db/database.ts
4053
4064
  import { Database } from "bun:sqlite";
4054
4065
  import { existsSync, mkdirSync } from "fs";
@@ -4417,6 +4428,164 @@ function nextTaskShortId(projectId, db) {
4417
4428
  return `${updated.task_prefix}-${padded}`;
4418
4429
  }
4419
4430
 
4431
+ // src/lib/config.ts
4432
+ import { existsSync as existsSync3 } from "fs";
4433
+ import { join as join3 } from "path";
4434
+
4435
+ // src/lib/sync-utils.ts
4436
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4437
+ import { join as join2 } from "path";
4438
+ var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4439
+ function ensureDir2(dir) {
4440
+ if (!existsSync2(dir))
4441
+ mkdirSync2(dir, { recursive: true });
4442
+ }
4443
+ function listJsonFiles(dir) {
4444
+ if (!existsSync2(dir))
4445
+ return [];
4446
+ return readdirSync(dir).filter((f) => f.endsWith(".json"));
4447
+ }
4448
+ function readJsonFile(path) {
4449
+ try {
4450
+ return JSON.parse(readFileSync(path, "utf-8"));
4451
+ } catch {
4452
+ return null;
4453
+ }
4454
+ }
4455
+ function writeJsonFile(path, data) {
4456
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
4457
+ `);
4458
+ }
4459
+ function readHighWaterMark(dir) {
4460
+ const path = join2(dir, ".highwatermark");
4461
+ if (!existsSync2(path))
4462
+ return 1;
4463
+ const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
4464
+ return isNaN(val) ? 1 : val;
4465
+ }
4466
+ function writeHighWaterMark(dir, value) {
4467
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
4468
+ }
4469
+ function getFileMtimeMs(path) {
4470
+ try {
4471
+ return statSync(path).mtimeMs;
4472
+ } catch {
4473
+ return null;
4474
+ }
4475
+ }
4476
+ function parseTimestamp(value) {
4477
+ if (typeof value !== "string")
4478
+ return null;
4479
+ const parsed = Date.parse(value);
4480
+ return Number.isNaN(parsed) ? null : parsed;
4481
+ }
4482
+ function appendSyncConflict(metadata, conflict, limit = 5) {
4483
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
4484
+ const next = [conflict, ...current].slice(0, limit);
4485
+ return { ...metadata, sync_conflicts: next };
4486
+ }
4487
+
4488
+ // src/lib/config.ts
4489
+ var CONFIG_PATH = join3(HOME, ".todos", "config.json");
4490
+ var cached = null;
4491
+ function normalizeAgent(agent) {
4492
+ return agent.trim().toLowerCase();
4493
+ }
4494
+ function loadConfig() {
4495
+ if (cached)
4496
+ return cached;
4497
+ if (!existsSync3(CONFIG_PATH)) {
4498
+ cached = {};
4499
+ return cached;
4500
+ }
4501
+ const config = readJsonFile(CONFIG_PATH) || {};
4502
+ if (typeof config.sync_agents === "string") {
4503
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
4504
+ }
4505
+ cached = config;
4506
+ return cached;
4507
+ }
4508
+ function getSyncAgentsFromConfig() {
4509
+ const config = loadConfig();
4510
+ const agents = config.sync_agents;
4511
+ if (Array.isArray(agents) && agents.length > 0)
4512
+ return agents.map(normalizeAgent);
4513
+ return null;
4514
+ }
4515
+ function getAgentTaskListId(agent) {
4516
+ const config = loadConfig();
4517
+ const key = normalizeAgent(agent);
4518
+ return config.agents?.[key]?.task_list_id || config.task_list_id || null;
4519
+ }
4520
+ function getAgentTasksDir(agent) {
4521
+ const config = loadConfig();
4522
+ const key = normalizeAgent(agent);
4523
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
4524
+ }
4525
+ function getTaskPrefixConfig() {
4526
+ const config = loadConfig();
4527
+ return config.task_prefix || null;
4528
+ }
4529
+ var GUARD_DEFAULTS = {
4530
+ enabled: false,
4531
+ min_work_seconds: 30,
4532
+ max_completions_per_window: 5,
4533
+ window_minutes: 10,
4534
+ cooldown_seconds: 60
4535
+ };
4536
+ function getCompletionGuardConfig(projectPath) {
4537
+ const config = loadConfig();
4538
+ const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
4539
+ if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
4540
+ return { ...global, ...config.project_overrides[projectPath].completion_guard };
4541
+ }
4542
+ return global;
4543
+ }
4544
+
4545
+ // src/lib/completion-guard.ts
4546
+ function checkCompletionGuard(task, agentId, db, configOverride) {
4547
+ let config;
4548
+ if (configOverride) {
4549
+ config = configOverride;
4550
+ } else {
4551
+ const projectPath = task.project_id ? getProject(task.project_id, db)?.path : null;
4552
+ config = getCompletionGuardConfig(projectPath);
4553
+ }
4554
+ if (!config.enabled)
4555
+ return;
4556
+ if (task.status !== "in_progress") {
4557
+ throw new CompletionGuardError(`Task must be in 'in_progress' status before completing (current: '${task.status}'). Use start_task first.`);
4558
+ }
4559
+ const agent = agentId || task.assigned_to || task.agent_id;
4560
+ if (config.min_work_seconds && task.locked_at) {
4561
+ const startedAt = new Date(task.locked_at).getTime();
4562
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
4563
+ if (elapsedSeconds < config.min_work_seconds) {
4564
+ const remaining = Math.ceil(config.min_work_seconds - elapsedSeconds);
4565
+ throw new CompletionGuardError(`Too fast: task was started ${Math.floor(elapsedSeconds)}s ago. Minimum work duration is ${config.min_work_seconds}s. Wait ${remaining}s.`, remaining);
4566
+ }
4567
+ }
4568
+ if (agent && config.max_completions_per_window && config.window_minutes) {
4569
+ const windowStart = new Date(Date.now() - config.window_minutes * 60 * 1000).toISOString();
4570
+ const result = db.query(`SELECT COUNT(*) as count FROM tasks
4571
+ WHERE completed_at > ? AND (assigned_to = ? OR agent_id = ?)`).get(windowStart, agent, agent);
4572
+ if (result.count >= config.max_completions_per_window) {
4573
+ throw new CompletionGuardError(`Rate limit: ${result.count} tasks completed in the last ${config.window_minutes} minutes (max ${config.max_completions_per_window}). Slow down.`);
4574
+ }
4575
+ }
4576
+ if (agent && config.cooldown_seconds) {
4577
+ const result = db.query(`SELECT MAX(completed_at) as last_completed FROM tasks
4578
+ WHERE completed_at IS NOT NULL AND (assigned_to = ? OR agent_id = ?) AND id != ?`).get(agent, agent, task.id);
4579
+ if (result.last_completed) {
4580
+ const elapsedSeconds = (Date.now() - new Date(result.last_completed).getTime()) / 1000;
4581
+ if (elapsedSeconds < config.cooldown_seconds) {
4582
+ const remaining = Math.ceil(config.cooldown_seconds - elapsedSeconds);
4583
+ throw new CompletionGuardError(`Cooldown: last completion was ${Math.floor(elapsedSeconds)}s ago. Wait ${remaining}s between completions.`, remaining);
4584
+ }
4585
+ }
4586
+ }
4587
+ }
4588
+
4420
4589
  // src/db/tasks.ts
4421
4590
  function rowToTask(row) {
4422
4591
  return {
@@ -4594,6 +4763,9 @@ function updateTask(id, input, db) {
4594
4763
  params.push(input.description);
4595
4764
  }
4596
4765
  if (input.status !== undefined) {
4766
+ if (input.status === "completed") {
4767
+ checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
4768
+ }
4597
4769
  sets.push("status = ?");
4598
4770
  params.push(input.status);
4599
4771
  if (input.status === "completed") {
@@ -4665,6 +4837,7 @@ function completeTask(id, agentId, db) {
4665
4837
  if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
4666
4838
  throw new LockError(id, task.locked_by);
4667
4839
  }
4840
+ checkCompletionGuard(task, agentId || null, d);
4668
4841
  const timestamp = now();
4669
4842
  d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, version = version + 1, updated_at = ?
4670
4843
  WHERE id = ?`, [timestamp, timestamp, id]);
@@ -4960,107 +5133,6 @@ function searchTasks(query, projectId, taskListId, db) {
4960
5133
  // src/lib/claude-tasks.ts
4961
5134
  import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
4962
5135
  import { join as join4 } from "path";
4963
-
4964
- // src/lib/config.ts
4965
- import { existsSync as existsSync3 } from "fs";
4966
- import { join as join3 } from "path";
4967
-
4968
- // src/lib/sync-utils.ts
4969
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4970
- import { join as join2 } from "path";
4971
- var HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4972
- function ensureDir2(dir) {
4973
- if (!existsSync2(dir))
4974
- mkdirSync2(dir, { recursive: true });
4975
- }
4976
- function listJsonFiles(dir) {
4977
- if (!existsSync2(dir))
4978
- return [];
4979
- return readdirSync(dir).filter((f) => f.endsWith(".json"));
4980
- }
4981
- function readJsonFile(path) {
4982
- try {
4983
- return JSON.parse(readFileSync(path, "utf-8"));
4984
- } catch {
4985
- return null;
4986
- }
4987
- }
4988
- function writeJsonFile(path, data) {
4989
- writeFileSync(path, JSON.stringify(data, null, 2) + `
4990
- `);
4991
- }
4992
- function readHighWaterMark(dir) {
4993
- const path = join2(dir, ".highwatermark");
4994
- if (!existsSync2(path))
4995
- return 1;
4996
- const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
4997
- return isNaN(val) ? 1 : val;
4998
- }
4999
- function writeHighWaterMark(dir, value) {
5000
- writeFileSync(join2(dir, ".highwatermark"), String(value));
5001
- }
5002
- function getFileMtimeMs(path) {
5003
- try {
5004
- return statSync(path).mtimeMs;
5005
- } catch {
5006
- return null;
5007
- }
5008
- }
5009
- function parseTimestamp(value) {
5010
- if (typeof value !== "string")
5011
- return null;
5012
- const parsed = Date.parse(value);
5013
- return Number.isNaN(parsed) ? null : parsed;
5014
- }
5015
- function appendSyncConflict(metadata, conflict, limit = 5) {
5016
- const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
5017
- const next = [conflict, ...current].slice(0, limit);
5018
- return { ...metadata, sync_conflicts: next };
5019
- }
5020
-
5021
- // src/lib/config.ts
5022
- var CONFIG_PATH = join3(HOME, ".todos", "config.json");
5023
- var cached = null;
5024
- function normalizeAgent(agent) {
5025
- return agent.trim().toLowerCase();
5026
- }
5027
- function loadConfig() {
5028
- if (cached)
5029
- return cached;
5030
- if (!existsSync3(CONFIG_PATH)) {
5031
- cached = {};
5032
- return cached;
5033
- }
5034
- const config = readJsonFile(CONFIG_PATH) || {};
5035
- if (typeof config.sync_agents === "string") {
5036
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
5037
- }
5038
- cached = config;
5039
- return cached;
5040
- }
5041
- function getSyncAgentsFromConfig() {
5042
- const config = loadConfig();
5043
- const agents = config.sync_agents;
5044
- if (Array.isArray(agents) && agents.length > 0)
5045
- return agents.map(normalizeAgent);
5046
- return null;
5047
- }
5048
- function getAgentTaskListId(agent) {
5049
- const config = loadConfig();
5050
- const key = normalizeAgent(agent);
5051
- return config.agents?.[key]?.task_list_id || config.task_list_id || null;
5052
- }
5053
- function getAgentTasksDir(agent) {
5054
- const config = loadConfig();
5055
- const key = normalizeAgent(agent);
5056
- return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
5057
- }
5058
- function getTaskPrefixConfig() {
5059
- const config = loadConfig();
5060
- return config.task_prefix || null;
5061
- }
5062
-
5063
- // src/lib/claude-tasks.ts
5064
5136
  function getTaskListDir(taskListId) {
5065
5137
  return join4(HOME, ".claude", "tasks", taskListId);
5066
5138
  }
@@ -5593,6 +5665,10 @@ function formatError(error) {
5593
5665
  return `Lock error: ${error.message}`;
5594
5666
  if (error instanceof DependencyCycleError)
5595
5667
  return `Dependency cycle: ${error.message}`;
5668
+ if (error instanceof CompletionGuardError) {
5669
+ const retry = error.retryAfterSeconds ? ` (retry after ${error.retryAfterSeconds}s)` : "";
5670
+ return `Completion blocked: ${error.reason}${retry}`;
5671
+ }
5596
5672
  if (error instanceof Error)
5597
5673
  return error.message;
5598
5674
  return String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/todos",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",