@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 +187 -104
- package/dist/index.js +171 -96
- package/dist/mcp/index.js +177 -101
- package/package.json +1 -1
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);
|