@hasna/todos 0.11.72 → 0.11.73

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
@@ -3890,6 +3890,11 @@ function ensureSchema(db) {
3890
3890
  ensureColumn("tasks", "priority_score", "INTEGER");
3891
3891
  ensureColumn("tasks", "priority_reason", "TEXT");
3892
3892
  ensureColumn("tasks", "archived_at", "TEXT");
3893
+ ensureColumn("tasks", "runner_id", "TEXT");
3894
+ ensureColumn("tasks", "runner_started_at", "TEXT");
3895
+ ensureColumn("tasks", "runner_completed_at", "TEXT");
3896
+ ensureColumn("tasks", "current_step", "TEXT");
3897
+ ensureColumn("tasks", "total_steps", "INTEGER");
3893
3898
  ensureColumn("agents", "role", "TEXT DEFAULT 'agent'");
3894
3899
  ensureColumn("agents", "permissions", `TEXT DEFAULT '["*"]'`);
3895
3900
  ensureColumn("agents", "reports_to", "TEXT");
@@ -3897,6 +3902,10 @@ function ensureSchema(db) {
3897
3902
  ensureColumn("agents", "level", "TEXT");
3898
3903
  ensureColumn("agents", "org_id", "TEXT");
3899
3904
  ensureColumn("agents", "capabilities", "TEXT DEFAULT '[]'");
3905
+ ensureColumn("agents", "session_id", "TEXT");
3906
+ ensureColumn("agents", "working_dir", "TEXT");
3907
+ ensureColumn("agents", "active_project_id", "TEXT");
3908
+ ensureColumn("agents", "status", "TEXT NOT NULL DEFAULT 'active'");
3900
3909
  ensureColumn("projects", "org_id", "TEXT");
3901
3910
  ensureColumn("plans", "slug", "TEXT");
3902
3911
  ensureColumn("plans", "task_list_id", "TEXT");
@@ -4819,34 +4828,40 @@ function ensureDir(filePath) {
4819
4828
  mkdirSync(dir, { recursive: true });
4820
4829
  }
4821
4830
  }
4831
+ function openDatabase(path) {
4832
+ ensureDir(path);
4833
+ const db = new Database(path);
4834
+ db.run("PRAGMA journal_mode = WAL");
4835
+ db.run("PRAGMA busy_timeout = 5000");
4836
+ db.run("PRAGMA foreign_keys = ON");
4837
+ runMigrations(db);
4838
+ backfillTaskTags(db);
4839
+ backfillMachineId(db);
4840
+ return db;
4841
+ }
4822
4842
  function getDatabase(dbPath) {
4823
4843
  const path = dbPath || getDbPath();
4824
4844
  if (_db && _dbPath === path)
4825
4845
  return _db;
4826
- if (_db && _dbPath !== path) {
4827
- _db.close();
4828
- _db = null;
4829
- _dbPath = null;
4830
- }
4831
- ensureDir(path);
4832
- _db = new Database(path);
4846
+ _db = openDatabase(path);
4833
4847
  _dbPath = path;
4834
- _db.run("PRAGMA journal_mode = WAL");
4835
- _db.run("PRAGMA busy_timeout = 5000");
4836
- _db.run("PRAGMA foreign_keys = ON");
4837
- runMigrations(_db);
4838
- backfillTaskTags(_db);
4839
- backfillMachineId(_db);
4840
4848
  return _db;
4841
4849
  }
4842
4850
  function closeDatabase() {
4843
4851
  if (_db) {
4844
- _db.close();
4852
+ try {
4853
+ _db.close();
4854
+ } catch {}
4845
4855
  _db = null;
4846
4856
  _dbPath = null;
4847
4857
  }
4848
4858
  }
4849
4859
  function resetDatabase() {
4860
+ if (_db) {
4861
+ try {
4862
+ _db.close();
4863
+ } catch {}
4864
+ }
4850
4865
  _db = null;
4851
4866
  _dbPath = null;
4852
4867
  }
@@ -7930,1767 +7945,1858 @@ var init_checklists = __esm(() => {
7930
7945
  init_database();
7931
7946
  });
7932
7947
 
7933
- // src/db/task-crud.ts
7934
- function rowToTask(row) {
7948
+ // src/lib/recurrence.ts
7949
+ function parseRecurrenceRule(rule) {
7950
+ const normalized = rule.trim().toLowerCase();
7951
+ if (normalized === "every weekday" || normalized === "every weekdays") {
7952
+ return { type: "specific_days", days: [1, 2, 3, 4, 5] };
7953
+ }
7954
+ if (normalized === "every day" || normalized === "daily") {
7955
+ return { type: "interval", interval: 1, unit: "day" };
7956
+ }
7957
+ if (normalized === "every week" || normalized === "weekly") {
7958
+ return { type: "interval", interval: 1, unit: "week" };
7959
+ }
7960
+ if (normalized === "every month" || normalized === "monthly") {
7961
+ return { type: "interval", interval: 1, unit: "month" };
7962
+ }
7963
+ const intervalMatch = normalized.match(/^every\s+(\d+)\s+(day|week|month)s?$/);
7964
+ if (intervalMatch) {
7965
+ return {
7966
+ type: "interval",
7967
+ interval: parseInt(intervalMatch[1], 10),
7968
+ unit: intervalMatch[2]
7969
+ };
7970
+ }
7971
+ const daysMatch = normalized.match(/^every\s+(.+)$/);
7972
+ if (daysMatch) {
7973
+ const dayParts = daysMatch[1].split(/[,\s]+/).map((d) => d.trim()).filter(Boolean);
7974
+ const days = [];
7975
+ for (const part of dayParts) {
7976
+ const dayNum = DAY_NAMES[part];
7977
+ if (dayNum !== undefined) {
7978
+ days.push(dayNum);
7979
+ }
7980
+ }
7981
+ if (days.length > 0) {
7982
+ return { type: "specific_days", days: days.sort((a, b) => a - b) };
7983
+ }
7984
+ }
7985
+ throw new Error(`Invalid recurrence rule: "${rule}". Supported formats: "every day", "every weekday", "every week", "every 2 weeks", "every month", "every N days/weeks/months", "every monday", "every mon,wed,fri"`);
7986
+ }
7987
+ function isValidRecurrenceRule(rule) {
7988
+ try {
7989
+ parseRecurrenceRule(rule);
7990
+ return true;
7991
+ } catch {
7992
+ return false;
7993
+ }
7994
+ }
7995
+ function nextOccurrence(rule, from) {
7996
+ const parsed = parseRecurrenceRule(rule);
7997
+ const base = from || new Date;
7998
+ if (parsed.type === "interval") {
7999
+ const next = new Date(base);
8000
+ if (parsed.unit === "day") {
8001
+ next.setDate(next.getDate() + parsed.interval);
8002
+ } else if (parsed.unit === "week") {
8003
+ next.setDate(next.getDate() + parsed.interval * 7);
8004
+ } else if (parsed.unit === "month") {
8005
+ next.setMonth(next.getMonth() + parsed.interval);
8006
+ }
8007
+ return next.toISOString();
8008
+ }
8009
+ if (parsed.type === "specific_days") {
8010
+ const currentDay = base.getDay();
8011
+ const days = parsed.days;
8012
+ let daysToAdd = Infinity;
8013
+ for (const day of days) {
8014
+ let diff = day - currentDay;
8015
+ if (diff <= 0)
8016
+ diff += 7;
8017
+ if (diff < daysToAdd)
8018
+ daysToAdd = diff;
8019
+ }
8020
+ const next = new Date(base);
8021
+ next.setDate(next.getDate() + daysToAdd);
8022
+ return next.toISOString();
8023
+ }
8024
+ throw new Error(`Cannot calculate next occurrence for rule: "${rule}"`);
8025
+ }
8026
+ var DAY_NAMES;
8027
+ var init_recurrence = __esm(() => {
8028
+ DAY_NAMES = {
8029
+ sunday: 0,
8030
+ sun: 0,
8031
+ monday: 1,
8032
+ mon: 1,
8033
+ tuesday: 2,
8034
+ tue: 2,
8035
+ wednesday: 3,
8036
+ wed: 3,
8037
+ thursday: 4,
8038
+ thu: 4,
8039
+ friday: 5,
8040
+ fri: 5,
8041
+ saturday: 6,
8042
+ sat: 6
8043
+ };
8044
+ });
8045
+
8046
+ // src/db/templates.ts
8047
+ var exports_templates = {};
8048
+ __export(exports_templates, {
8049
+ updateTemplate: () => updateTemplate,
8050
+ tasksFromTemplate: () => tasksFromTemplate,
8051
+ taskFromTemplate: () => taskFromTemplate,
8052
+ resolveVariables: () => resolveVariables,
8053
+ previewTemplate: () => previewTemplate,
8054
+ listTemplates: () => listTemplates,
8055
+ listTemplateVersions: () => listTemplateVersions,
8056
+ importTemplate: () => importTemplate,
8057
+ getTemplateWithTasks: () => getTemplateWithTasks,
8058
+ getTemplateVersion: () => getTemplateVersion,
8059
+ getTemplateTasks: () => getTemplateTasks,
8060
+ getTemplate: () => getTemplate,
8061
+ exportTemplate: () => exportTemplate,
8062
+ evaluateCondition: () => evaluateCondition,
8063
+ deleteTemplate: () => deleteTemplate,
8064
+ createTemplate: () => createTemplate,
8065
+ addTemplateTasks: () => addTemplateTasks
8066
+ });
8067
+ function rowToTemplate(row) {
7935
8068
  return {
7936
8069
  ...row,
7937
8070
  tags: JSON.parse(row.tags || "[]"),
8071
+ variables: JSON.parse(row.variables || "[]"),
7938
8072
  metadata: JSON.parse(row.metadata || "{}"),
7939
- status: row.status,
7940
- priority: row.priority,
7941
- requires_approval: !!row.requires_approval
8073
+ priority: row.priority || "medium",
8074
+ version: row.version ?? 1
7942
8075
  };
7943
8076
  }
7944
- function insertTaskTags(taskId, tags, db) {
7945
- if (tags.length === 0)
7946
- return;
7947
- const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
7948
- for (const tag of tags) {
7949
- if (tag)
7950
- stmt.run(taskId, tag);
7951
- }
7952
- }
7953
- function replaceTaskTags(taskId, tags, db) {
7954
- db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
7955
- insertTaskTags(taskId, tags, db);
8077
+ function rowToTemplateTask(row) {
8078
+ return {
8079
+ ...row,
8080
+ tags: JSON.parse(row.tags || "[]"),
8081
+ depends_on_positions: JSON.parse(row.depends_on_positions || "[]"),
8082
+ metadata: JSON.parse(row.metadata || "{}"),
8083
+ priority: row.priority || "medium",
8084
+ condition: row.condition ?? null,
8085
+ include_template_id: row.include_template_id ?? null
8086
+ };
7956
8087
  }
7957
- function addMetadataConditions(metadata, conditions, params) {
7958
- if (!metadata)
7959
- return;
7960
- for (const [key, value] of Object.entries(metadata)) {
7961
- if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
7962
- throw new Error(`Invalid metadata filter key: ${key}`);
7963
- }
7964
- conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
7965
- params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
7966
- }
8088
+ function resolveTemplateId(id, d) {
8089
+ return resolvePartialId(d, "task_templates", id);
7967
8090
  }
7968
- function createTask(input, db) {
8091
+ function createTemplate(input, db) {
7969
8092
  const d = db || getDatabase();
7970
- const timestamp = now();
7971
- const tags = input.tags || [];
8093
+ const id = uuid();
7972
8094
  const machineId = currentStorageMachineId(d);
7973
- const assignedBy = input.assigned_by || input.agent_id;
7974
- const assignedFromProject = input.assigned_from_project || null;
7975
- let id = uuid();
7976
- for (let attempt = 0;attempt < 3; attempt++) {
7977
- try {
7978
- d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, cycle_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, sla_minutes, confidence, retry_count, max_retries, retry_after, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id, spawns_template_id, reason, spawned_from_session, assigned_by, assigned_from_project, task_type, machine_id)
7979
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
7980
- id,
7981
- null,
7982
- input.project_id || null,
7983
- input.parent_id || null,
7984
- input.plan_id || null,
7985
- input.task_list_id || null,
7986
- input.cycle_id || null,
7987
- input.title,
7988
- input.description || null,
7989
- input.status || "pending",
7990
- input.priority || "medium",
7991
- input.agent_id || null,
7992
- input.assigned_to || null,
7993
- input.session_id || null,
7994
- input.working_dir || null,
7995
- JSON.stringify(tags),
7996
- JSON.stringify(input.metadata || {}),
7997
- timestamp,
7998
- timestamp,
7999
- input.due_at || null,
8000
- input.estimated_minutes || null,
8001
- input.sla_minutes ?? null,
8002
- input.confidence ?? null,
8003
- input.retry_count ?? 0,
8004
- input.max_retries ?? 3,
8005
- input.retry_after ?? null,
8006
- input.requires_approval ? 1 : 0,
8007
- null,
8008
- null,
8009
- input.recurrence_rule || null,
8010
- input.recurrence_parent_id || null,
8011
- input.spawns_template_id || null,
8012
- input.reason || null,
8013
- input.spawned_from_session || null,
8014
- assignedBy || null,
8015
- assignedFromProject || null,
8016
- input.task_type || null,
8017
- machineId
8018
- ]);
8019
- break;
8020
- } catch (e) {
8021
- if (attempt < 2 && e?.message?.includes("UNIQUE constraint failed: tasks.id")) {
8022
- id = uuid();
8023
- continue;
8024
- }
8025
- throw e;
8026
- }
8027
- }
8028
- if (tags.length > 0) {
8029
- insertTaskTags(id, tags, d);
8095
+ d.run(`INSERT INTO task_templates (id, name, title_pattern, description, priority, tags, variables, project_id, plan_id, metadata, created_at, machine_id)
8096
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
8097
+ id,
8098
+ input.name,
8099
+ input.title_pattern,
8100
+ input.description || null,
8101
+ input.priority || "medium",
8102
+ JSON.stringify(input.tags || []),
8103
+ JSON.stringify(input.variables || []),
8104
+ input.project_id || null,
8105
+ input.plan_id || null,
8106
+ JSON.stringify(input.metadata || {}),
8107
+ now(),
8108
+ machineId
8109
+ ]);
8110
+ if (input.tasks && input.tasks.length > 0) {
8111
+ addTemplateTasks(id, input.tasks, d);
8030
8112
  }
8031
- const task = getTask(id, d);
8032
- const payload = taskEventData(task);
8033
- const databasePath = databasePathFromDatabase(d);
8034
- dispatchWebhook2("task.created", payload, d).catch(() => {});
8035
- emitLocalEventHooksQuiet({ type: "task.created", payload, databasePath });
8036
- emitSharedTaskEventQuiet({ type: "task.created", task, databasePath });
8037
- return task;
8113
+ return getTemplate(id, d);
8038
8114
  }
8039
- function getTask(id, db) {
8115
+ function getTemplate(id, db) {
8040
8116
  const d = db || getDatabase();
8041
- const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
8042
- if (!row)
8117
+ const resolved = resolveTemplateId(id, d);
8118
+ if (!resolved)
8043
8119
  return null;
8044
- return rowToTask(row);
8120
+ const row = d.query("SELECT * FROM task_templates WHERE id = ?").get(resolved);
8121
+ return row ? rowToTemplate(row) : null;
8045
8122
  }
8046
- function getTaskWithRelations(id, db) {
8123
+ function listTemplates(db) {
8047
8124
  const d = db || getDatabase();
8048
- const task = getTask(id, d);
8049
- if (!task)
8050
- return null;
8051
- const subtaskRows = d.query("SELECT * FROM tasks WHERE parent_id = ? ORDER BY created_at").all(id);
8052
- const subtasks = subtaskRows.map(rowToTask);
8053
- const depRows = d.query(`SELECT t.* FROM tasks t
8054
- JOIN task_dependencies td ON td.depends_on = t.id
8055
- WHERE td.task_id = ?`).all(id);
8056
- const dependencies = depRows.map(rowToTask);
8057
- const blockedByRows = d.query(`SELECT t.* FROM tasks t
8058
- JOIN task_dependencies td ON td.task_id = t.id
8059
- WHERE td.depends_on = ?`).all(id);
8060
- const blocked_by = blockedByRows.map(rowToTask);
8061
- const comments = d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(id);
8062
- const parent = task.parent_id ? getTask(task.parent_id, d) : null;
8063
- const checklist = getChecklist(id, d);
8064
- return {
8065
- ...task,
8066
- subtasks,
8067
- dependencies,
8068
- blocked_by,
8069
- comments,
8070
- parent,
8071
- checklist
8072
- };
8125
+ return d.query("SELECT * FROM task_templates ORDER BY name").all().map(rowToTemplate);
8073
8126
  }
8074
- function listTasks(filter = {}, db) {
8127
+ function deleteTemplate(id, db) {
8075
8128
  const d = db || getDatabase();
8076
- const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
8077
- clearExpiredLocks2(d);
8078
- const conditions = [];
8079
- const params = [];
8080
- if (filter.project_id) {
8081
- conditions.push("project_id = ?");
8082
- params.push(filter.project_id);
8083
- }
8084
- if (filter.ids && filter.ids.length > 0) {
8085
- conditions.push(`id IN (${filter.ids.map(() => "?").join(",")})`);
8086
- params.push(...filter.ids);
8087
- }
8088
- if (filter.parent_id !== undefined) {
8089
- if (filter.parent_id === null) {
8090
- conditions.push("parent_id IS NULL");
8091
- } else {
8092
- conditions.push("parent_id = ?");
8093
- params.push(filter.parent_id);
8094
- }
8095
- }
8096
- if (filter.status) {
8097
- if (Array.isArray(filter.status)) {
8098
- conditions.push(`status IN (${filter.status.map(() => "?").join(",")})`);
8099
- params.push(...filter.status);
8100
- } else {
8101
- conditions.push("status = ?");
8102
- params.push(filter.status);
8103
- }
8104
- }
8105
- if (filter.priority) {
8106
- if (Array.isArray(filter.priority)) {
8107
- conditions.push(`priority IN (${filter.priority.map(() => "?").join(",")})`);
8108
- params.push(...filter.priority);
8109
- } else {
8110
- conditions.push("priority = ?");
8111
- params.push(filter.priority);
8112
- }
8113
- }
8114
- if (filter.assigned_to) {
8115
- conditions.push("assigned_to = ?");
8116
- params.push(filter.assigned_to);
8117
- }
8118
- if (filter.agent_id) {
8119
- conditions.push("agent_id = ?");
8120
- params.push(filter.agent_id);
8129
+ const resolved = resolveTemplateId(id, d);
8130
+ if (!resolved)
8131
+ return false;
8132
+ const template = getTemplate(resolved, d);
8133
+ if (!template)
8134
+ return false;
8135
+ recordStorageTombstone({
8136
+ object_type: "templates",
8137
+ object_id: resolved,
8138
+ payload: template,
8139
+ version: template.version
8140
+ }, d);
8141
+ return d.run("DELETE FROM task_templates WHERE id = ?", [resolved]).changes > 0;
8142
+ }
8143
+ function updateTemplate(id, updates, db) {
8144
+ const d = db || getDatabase();
8145
+ const resolved = resolveTemplateId(id, d);
8146
+ if (!resolved)
8147
+ return null;
8148
+ const current = getTemplateWithTasks(resolved, d);
8149
+ if (current) {
8150
+ const snapshot = JSON.stringify({
8151
+ name: current.name,
8152
+ title_pattern: current.title_pattern,
8153
+ description: current.description,
8154
+ priority: current.priority,
8155
+ tags: current.tags,
8156
+ variables: current.variables,
8157
+ project_id: current.project_id,
8158
+ plan_id: current.plan_id,
8159
+ metadata: current.metadata,
8160
+ tasks: current.tasks
8161
+ });
8162
+ d.run(`INSERT INTO template_versions (id, template_id, version, snapshot, created_at) VALUES (?, ?, ?, ?, ?)`, [uuid(), resolved, current.version, snapshot, now()]);
8121
8163
  }
8122
- if (filter.session_id) {
8123
- conditions.push("session_id = ?");
8124
- params.push(filter.session_id);
8164
+ const sets = ["version = version + 1"];
8165
+ const values = [];
8166
+ if (updates.name !== undefined) {
8167
+ sets.push("name = ?");
8168
+ values.push(updates.name);
8125
8169
  }
8126
- if (filter.tags && filter.tags.length > 0) {
8127
- const placeholders = filter.tags.map(() => "?").join(",");
8128
- conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
8129
- params.push(...filter.tags);
8170
+ if (updates.title_pattern !== undefined) {
8171
+ sets.push("title_pattern = ?");
8172
+ values.push(updates.title_pattern);
8130
8173
  }
8131
- if (filter.plan_id) {
8132
- conditions.push("plan_id = ?");
8133
- params.push(filter.plan_id);
8174
+ if (updates.description !== undefined) {
8175
+ sets.push("description = ?");
8176
+ values.push(updates.description);
8134
8177
  }
8135
- if (filter.task_list_id) {
8136
- conditions.push("task_list_id = ?");
8137
- params.push(filter.task_list_id);
8178
+ if (updates.priority !== undefined) {
8179
+ sets.push("priority = ?");
8180
+ values.push(updates.priority);
8138
8181
  }
8139
- if (filter.has_recurrence === true) {
8140
- conditions.push("recurrence_rule IS NOT NULL");
8141
- } else if (filter.has_recurrence === false) {
8142
- conditions.push("recurrence_rule IS NULL");
8182
+ if (updates.tags !== undefined) {
8183
+ sets.push("tags = ?");
8184
+ values.push(JSON.stringify(updates.tags));
8143
8185
  }
8144
- if (filter.task_type) {
8145
- if (Array.isArray(filter.task_type)) {
8146
- conditions.push(`task_type IN (${filter.task_type.map(() => "?").join(",")})`);
8147
- params.push(...filter.task_type);
8148
- } else {
8149
- conditions.push("task_type = ?");
8150
- params.push(filter.task_type);
8151
- }
8186
+ if (updates.variables !== undefined) {
8187
+ sets.push("variables = ?");
8188
+ values.push(JSON.stringify(updates.variables));
8152
8189
  }
8153
- addMetadataConditions(filter.metadata, conditions, params);
8154
- const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
8155
- if (filter.cursor) {
8156
- try {
8157
- const decoded = JSON.parse(Buffer.from(filter.cursor, "base64").toString("utf8"));
8158
- conditions.push(`(${PRIORITY_RANK} > ? OR (${PRIORITY_RANK} = ? AND created_at < ?) OR (${PRIORITY_RANK} = ? AND created_at = ? AND id > ?))`);
8159
- params.push(decoded.p, decoded.p, decoded.c, decoded.p, decoded.c, decoded.i);
8160
- } catch {}
8190
+ if (updates.project_id !== undefined) {
8191
+ sets.push("project_id = ?");
8192
+ values.push(updates.project_id);
8161
8193
  }
8162
- if (!filter.include_archived) {
8163
- conditions.push("archived_at IS NULL");
8194
+ if (updates.plan_id !== undefined) {
8195
+ sets.push("plan_id = ?");
8196
+ values.push(updates.plan_id);
8164
8197
  }
8165
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
8166
- let limitClause = "";
8167
- if (filter.limit) {
8168
- limitClause = " LIMIT ?";
8169
- params.push(filter.limit);
8170
- if (!filter.cursor && filter.offset) {
8171
- limitClause += " OFFSET ?";
8172
- params.push(filter.offset);
8173
- }
8198
+ if (updates.metadata !== undefined) {
8199
+ sets.push("metadata = ?");
8200
+ values.push(JSON.stringify(updates.metadata));
8174
8201
  }
8175
- const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
8176
- return rows.map(rowToTask);
8177
- }
8178
- function getTaskByFingerprint(fingerprint, db) {
8179
- const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
8180
- return tasks[0] ?? null;
8202
+ values.push(resolved);
8203
+ d.run(`UPDATE task_templates SET ${sets.join(", ")} WHERE id = ?`, values);
8204
+ return getTemplate(resolved, d);
8181
8205
  }
8182
- function mergeTaskMetadata(current, next, fingerprint) {
8206
+ function taskFromTemplate(templateId, overrides = {}, db) {
8207
+ const t = getTemplate(templateId, db);
8208
+ if (!t)
8209
+ throw new Error(`Template not found: ${templateId}`);
8210
+ const cleanOverrides = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined));
8183
8211
  return {
8184
- ...current,
8185
- ...next ?? {},
8186
- fingerprint
8212
+ title: cleanOverrides.title || t.title_pattern,
8213
+ description: cleanOverrides.description ?? t.description ?? undefined,
8214
+ priority: cleanOverrides.priority ?? t.priority,
8215
+ tags: cleanOverrides.tags ?? t.tags,
8216
+ project_id: cleanOverrides.project_id ?? t.project_id ?? undefined,
8217
+ plan_id: cleanOverrides.plan_id ?? t.plan_id ?? undefined,
8218
+ metadata: cleanOverrides.metadata ?? t.metadata,
8219
+ ...cleanOverrides
8187
8220
  };
8188
8221
  }
8189
- function upsertTaskByFingerprint(input, db) {
8222
+ function addTemplateTasks(templateId, tasks, db) {
8190
8223
  const d = db || getDatabase();
8191
- const fingerprint = input.fingerprint.trim();
8192
- if (!fingerprint)
8193
- throw new Error("fingerprint is required");
8194
- const existing = getTaskByFingerprint(fingerprint, d);
8195
- const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
8196
- if (!existing) {
8197
- const task2 = createTask({ ...input, metadata }, d);
8198
- return { task: task2, created: true };
8224
+ const template = getTemplate(templateId, d);
8225
+ if (!template)
8226
+ throw new Error(`Template not found: ${templateId}`);
8227
+ d.run("DELETE FROM template_tasks WHERE template_id = ?", [templateId]);
8228
+ const results = [];
8229
+ for (let i = 0;i < tasks.length; i++) {
8230
+ const task = tasks[i];
8231
+ const id = uuid();
8232
+ d.run(`INSERT INTO template_tasks (id, template_id, position, title_pattern, description, priority, tags, task_type, condition, include_template_id, depends_on_positions, metadata, created_at)
8233
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
8234
+ id,
8235
+ templateId,
8236
+ i,
8237
+ task.title_pattern,
8238
+ task.description || null,
8239
+ task.priority || "medium",
8240
+ JSON.stringify(task.tags || []),
8241
+ task.task_type || null,
8242
+ task.condition || null,
8243
+ task.include_template_id || null,
8244
+ JSON.stringify(task.depends_on || []),
8245
+ JSON.stringify(task.metadata || {}),
8246
+ now()
8247
+ ]);
8248
+ const row = d.query("SELECT * FROM template_tasks WHERE id = ?").get(id);
8249
+ if (row)
8250
+ results.push(rowToTemplateTask(row));
8199
8251
  }
8200
- const task = updateTask(existing.id, {
8201
- version: existing.version,
8202
- title: input.title,
8203
- description: input.description,
8204
- status: input.status,
8205
- priority: input.priority,
8206
- project_id: input.project_id,
8207
- assigned_to: input.assigned_to,
8208
- working_dir: input.working_dir,
8209
- plan_id: input.plan_id,
8210
- task_list_id: input.task_list_id,
8211
- tags: input.tags,
8212
- metadata,
8213
- due_at: input.due_at,
8214
- estimated_minutes: input.estimated_minutes,
8215
- sla_minutes: input.sla_minutes,
8216
- confidence: input.confidence,
8217
- retry_count: input.retry_count,
8218
- max_retries: input.max_retries,
8219
- retry_after: input.retry_after,
8220
- requires_approval: input.requires_approval,
8221
- recurrence_rule: input.recurrence_rule,
8222
- task_type: input.task_type
8223
- }, d);
8224
- return { task, created: false };
8252
+ return results;
8225
8253
  }
8226
- function countTasks(filter = {}, db) {
8254
+ function getTemplateWithTasks(id, db) {
8227
8255
  const d = db || getDatabase();
8228
- const conditions = [];
8229
- const params = [];
8230
- if (filter.project_id) {
8231
- conditions.push("project_id = ?");
8232
- params.push(filter.project_id);
8256
+ const template = getTemplate(id, d);
8257
+ if (!template)
8258
+ return null;
8259
+ const rows = d.query("SELECT * FROM template_tasks WHERE template_id = ? ORDER BY position").all(template.id);
8260
+ const tasks = rows.map(rowToTemplateTask);
8261
+ return { ...template, tasks };
8262
+ }
8263
+ function getTemplateTasks(templateId, db) {
8264
+ const d = db || getDatabase();
8265
+ const resolved = resolveTemplateId(templateId, d);
8266
+ if (!resolved)
8267
+ return [];
8268
+ const rows = d.query("SELECT * FROM template_tasks WHERE template_id = ? ORDER BY position").all(resolved);
8269
+ return rows.map(rowToTemplateTask);
8270
+ }
8271
+ function evaluateCondition(condition, variables) {
8272
+ if (!condition || condition.trim() === "")
8273
+ return true;
8274
+ const trimmed = condition.trim();
8275
+ const eqMatch = trimmed.match(/^\{([^}]+)\}\s*==\s*(.+)$/);
8276
+ if (eqMatch) {
8277
+ const varName = eqMatch[1];
8278
+ const expected = eqMatch[2].trim();
8279
+ return (variables[varName] ?? "") === expected;
8233
8280
  }
8234
- if (filter.ids && filter.ids.length > 0) {
8235
- conditions.push(`id IN (${filter.ids.map(() => "?").join(",")})`);
8236
- params.push(...filter.ids);
8281
+ const neqMatch = trimmed.match(/^\{([^}]+)\}\s*!=\s*(.+)$/);
8282
+ if (neqMatch) {
8283
+ const varName = neqMatch[1];
8284
+ const expected = neqMatch[2].trim();
8285
+ return (variables[varName] ?? "") !== expected;
8237
8286
  }
8238
- if (filter.parent_id !== undefined) {
8239
- if (filter.parent_id === null) {
8240
- conditions.push("parent_id IS NULL");
8241
- } else {
8242
- conditions.push("parent_id = ?");
8243
- params.push(filter.parent_id);
8244
- }
8287
+ const falsyMatch = trimmed.match(/^!\{([^}]+)\}$/);
8288
+ if (falsyMatch) {
8289
+ const varName = falsyMatch[1];
8290
+ const val = variables[varName];
8291
+ return !val || val === "" || val === "false";
8245
8292
  }
8246
- if (filter.status) {
8247
- if (Array.isArray(filter.status)) {
8248
- conditions.push(`status IN (${filter.status.map(() => "?").join(",")})`);
8249
- params.push(...filter.status);
8250
- } else {
8251
- conditions.push("status = ?");
8252
- params.push(filter.status);
8253
- }
8293
+ const truthyMatch = trimmed.match(/^\{([^}]+)\}$/);
8294
+ if (truthyMatch) {
8295
+ const varName = truthyMatch[1];
8296
+ const val = variables[varName];
8297
+ return !!val && val !== "" && val !== "false";
8254
8298
  }
8255
- if (filter.priority) {
8256
- if (Array.isArray(filter.priority)) {
8257
- conditions.push(`priority IN (${filter.priority.map(() => "?").join(",")})`);
8258
- params.push(...filter.priority);
8259
- } else {
8260
- conditions.push("priority = ?");
8261
- params.push(filter.priority);
8299
+ return true;
8300
+ }
8301
+ function exportTemplate(id, db) {
8302
+ const d = db || getDatabase();
8303
+ const template = getTemplateWithTasks(id, d);
8304
+ if (!template)
8305
+ throw new Error(`Template not found: ${id}`);
8306
+ return {
8307
+ name: template.name,
8308
+ title_pattern: template.title_pattern,
8309
+ description: template.description,
8310
+ priority: template.priority,
8311
+ tags: template.tags,
8312
+ variables: template.variables,
8313
+ project_id: template.project_id,
8314
+ plan_id: template.plan_id,
8315
+ metadata: template.metadata,
8316
+ tasks: template.tasks.map((t) => ({
8317
+ position: t.position,
8318
+ title_pattern: t.title_pattern,
8319
+ description: t.description,
8320
+ priority: t.priority,
8321
+ tags: t.tags,
8322
+ task_type: t.task_type,
8323
+ condition: t.condition,
8324
+ include_template_id: t.include_template_id,
8325
+ depends_on_positions: t.depends_on_positions,
8326
+ metadata: t.metadata
8327
+ }))
8328
+ };
8329
+ }
8330
+ function importTemplate(json, db) {
8331
+ const d = db || getDatabase();
8332
+ const taskInputs = (json.tasks || []).map((t) => ({
8333
+ title_pattern: t.title_pattern,
8334
+ description: t.description ?? undefined,
8335
+ priority: t.priority,
8336
+ tags: t.tags,
8337
+ task_type: t.task_type ?? undefined,
8338
+ condition: t.condition ?? undefined,
8339
+ include_template_id: t.include_template_id ?? undefined,
8340
+ depends_on: t.depends_on_positions,
8341
+ metadata: t.metadata
8342
+ }));
8343
+ return createTemplate({
8344
+ name: json.name,
8345
+ title_pattern: json.title_pattern,
8346
+ description: json.description ?? undefined,
8347
+ priority: json.priority,
8348
+ tags: json.tags,
8349
+ variables: json.variables,
8350
+ project_id: json.project_id ?? undefined,
8351
+ plan_id: json.plan_id ?? undefined,
8352
+ metadata: json.metadata,
8353
+ tasks: taskInputs
8354
+ }, d);
8355
+ }
8356
+ function getTemplateVersion(id, version, db) {
8357
+ const d = db || getDatabase();
8358
+ const resolved = resolveTemplateId(id, d);
8359
+ if (!resolved)
8360
+ return null;
8361
+ const row = d.query("SELECT * FROM template_versions WHERE template_id = ? AND version = ?").get(resolved, version);
8362
+ return row || null;
8363
+ }
8364
+ function listTemplateVersions(id, db) {
8365
+ const d = db || getDatabase();
8366
+ const resolved = resolveTemplateId(id, d);
8367
+ if (!resolved)
8368
+ return [];
8369
+ return d.query("SELECT * FROM template_versions WHERE template_id = ? ORDER BY version DESC").all(resolved);
8370
+ }
8371
+ function resolveVariables(templateVars, provided) {
8372
+ const merged = { ...provided };
8373
+ for (const v of templateVars) {
8374
+ if (merged[v.name] === undefined && v.default !== undefined) {
8375
+ merged[v.name] = v.default;
8262
8376
  }
8263
8377
  }
8264
- if (filter.assigned_to) {
8265
- conditions.push("assigned_to = ?");
8266
- params.push(filter.assigned_to);
8267
- }
8268
- if (filter.agent_id) {
8269
- conditions.push("agent_id = ?");
8270
- params.push(filter.agent_id);
8271
- }
8272
- if (filter.session_id) {
8273
- conditions.push("session_id = ?");
8274
- params.push(filter.session_id);
8275
- }
8276
- if (filter.tags && filter.tags.length > 0) {
8277
- const placeholders = filter.tags.map(() => "?").join(",");
8278
- conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
8279
- params.push(...filter.tags);
8280
- }
8281
- if (filter.plan_id) {
8282
- conditions.push("plan_id = ?");
8283
- params.push(filter.plan_id);
8378
+ const missing = [];
8379
+ for (const v of templateVars) {
8380
+ if (v.required && merged[v.name] === undefined) {
8381
+ missing.push(v.name);
8382
+ }
8284
8383
  }
8285
- if (filter.task_list_id) {
8286
- conditions.push("task_list_id = ?");
8287
- params.push(filter.task_list_id);
8384
+ if (missing.length > 0) {
8385
+ throw new Error(`Missing required template variable(s): ${missing.join(", ")}`);
8288
8386
  }
8289
- addMetadataConditions(filter.metadata, conditions, params);
8290
- if (!filter.include_archived) {
8291
- conditions.push("archived_at IS NULL");
8387
+ return merged;
8388
+ }
8389
+ function substituteVars(text, variables) {
8390
+ let result = text;
8391
+ for (const [key, val] of Object.entries(variables)) {
8392
+ result = result.replace(new RegExp(`\\{${key}\\}`, "g"), val);
8292
8393
  }
8293
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
8294
- const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
8295
- return row.count;
8394
+ return result;
8296
8395
  }
8297
- function updateTask(id, input, db) {
8396
+ function tasksFromTemplate(templateId, projectId, variables, taskListId, db, _visitedTemplateIds) {
8298
8397
  const d = db || getDatabase();
8299
- const task = getTask(id, d);
8300
- if (!task)
8301
- throw new TaskNotFoundError(id);
8302
- if (task.version !== input.version) {
8303
- throw new VersionConflictError(id, input.version, task.version);
8304
- }
8305
- const timestamp = now();
8306
- const completionTimestamp = input.completed_at ?? timestamp;
8307
- const sets = ["version = version + 1", "updated_at = ?"];
8308
- const params = [timestamp];
8309
- if (input.title !== undefined) {
8310
- sets.push("title = ?");
8311
- params.push(input.title);
8398
+ const template = getTemplateWithTasks(templateId, d);
8399
+ if (!template)
8400
+ throw new Error(`Template not found: ${templateId}`);
8401
+ const visited = _visitedTemplateIds || new Set;
8402
+ if (visited.has(template.id)) {
8403
+ throw new Error(`Circular template reference detected: ${template.id}`);
8312
8404
  }
8313
- if (input.description !== undefined) {
8314
- sets.push("description = ?");
8315
- params.push(input.description);
8405
+ visited.add(template.id);
8406
+ const resolved = resolveVariables(template.variables, variables);
8407
+ if (template.tasks.length === 0) {
8408
+ const input = taskFromTemplate(templateId, { project_id: projectId, task_list_id: taskListId }, d);
8409
+ const task = createTask(input, d);
8410
+ return [task];
8316
8411
  }
8317
- if (input.status !== undefined) {
8318
- if (input.status === "completed") {
8319
- checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
8412
+ const createdTasks = [];
8413
+ const positionToId = new Map;
8414
+ const skippedPositions = new Set;
8415
+ for (const tt of template.tasks) {
8416
+ if (tt.include_template_id) {
8417
+ const includedTasks = tasksFromTemplate(tt.include_template_id, projectId, resolved, taskListId, d, visited);
8418
+ createdTasks.push(...includedTasks);
8419
+ if (includedTasks.length > 0) {
8420
+ positionToId.set(tt.position, includedTasks[0].id);
8421
+ } else {
8422
+ skippedPositions.add(tt.position);
8423
+ }
8424
+ continue;
8320
8425
  }
8321
- sets.push("status = ?");
8322
- params.push(input.status);
8323
- if (input.status === "completed") {
8324
- sets.push("completed_at = ?");
8325
- params.push(completionTimestamp);
8426
+ if (tt.condition && !evaluateCondition(tt.condition, resolved)) {
8427
+ skippedPositions.add(tt.position);
8428
+ continue;
8326
8429
  }
8430
+ let title = tt.title_pattern;
8431
+ let desc = tt.description;
8432
+ title = substituteVars(title, resolved);
8433
+ if (desc)
8434
+ desc = substituteVars(desc, resolved);
8435
+ const task = createTask({
8436
+ title,
8437
+ description: desc ?? undefined,
8438
+ priority: tt.priority,
8439
+ tags: tt.tags,
8440
+ task_type: tt.task_type ?? undefined,
8441
+ project_id: projectId,
8442
+ task_list_id: taskListId,
8443
+ metadata: tt.metadata
8444
+ }, d);
8445
+ createdTasks.push(task);
8446
+ positionToId.set(tt.position, task.id);
8327
8447
  }
8328
- if (input.priority !== undefined) {
8329
- sets.push("priority = ?");
8330
- params.push(input.priority);
8331
- }
8332
- if (input.project_id !== undefined) {
8333
- sets.push("project_id = ?");
8334
- params.push(input.project_id);
8335
- }
8336
- if (input.assigned_to !== undefined) {
8337
- sets.push("assigned_to = ?");
8338
- params.push(input.assigned_to);
8339
- }
8340
- if (input.working_dir !== undefined) {
8341
- sets.push("working_dir = ?");
8342
- params.push(input.working_dir);
8343
- }
8344
- if (input.tags !== undefined) {
8345
- sets.push("tags = ?");
8346
- params.push(JSON.stringify(input.tags));
8347
- }
8348
- if (input.metadata !== undefined) {
8349
- sets.push("metadata = ?");
8350
- params.push(JSON.stringify(input.metadata));
8351
- }
8352
- if (input.plan_id !== undefined) {
8353
- sets.push("plan_id = ?");
8354
- params.push(input.plan_id);
8355
- }
8356
- if (input.task_list_id !== undefined) {
8357
- sets.push("task_list_id = ?");
8358
- params.push(input.task_list_id);
8359
- }
8360
- if (input.due_at !== undefined) {
8361
- sets.push("due_at = ?");
8362
- params.push(input.due_at);
8363
- }
8364
- if (input.estimated_minutes !== undefined) {
8365
- sets.push("estimated_minutes = ?");
8366
- params.push(input.estimated_minutes);
8367
- }
8368
- if (input.sla_minutes !== undefined) {
8369
- sets.push("sla_minutes = ?");
8370
- params.push(input.sla_minutes);
8371
- }
8372
- if (input.actual_minutes !== undefined) {
8373
- sets.push("actual_minutes = ?");
8374
- params.push(input.actual_minutes);
8375
- }
8376
- if (input.completed_at !== undefined && input.status !== "completed") {
8377
- sets.push("completed_at = ?");
8378
- params.push(input.completed_at);
8379
- }
8380
- if (input.confidence !== undefined) {
8381
- sets.push("confidence = ?");
8382
- params.push(input.confidence);
8383
- }
8384
- if (input.retry_count !== undefined) {
8385
- sets.push("retry_count = ?");
8386
- params.push(input.retry_count);
8387
- }
8388
- if (input.max_retries !== undefined) {
8389
- sets.push("max_retries = ?");
8390
- params.push(input.max_retries);
8391
- }
8392
- if (input.retry_after !== undefined) {
8393
- sets.push("retry_after = ?");
8394
- params.push(input.retry_after);
8395
- }
8396
- if (input.requires_approval !== undefined) {
8397
- sets.push("requires_approval = ?");
8398
- params.push(input.requires_approval ? 1 : 0);
8399
- }
8400
- if (input.approved_by !== undefined) {
8401
- sets.push("approved_by = ?");
8402
- params.push(input.approved_by);
8403
- sets.push("approved_at = ?");
8404
- params.push(now());
8405
- }
8406
- if (input.recurrence_rule !== undefined) {
8407
- sets.push("recurrence_rule = ?");
8408
- params.push(input.recurrence_rule);
8409
- }
8410
- if (input.task_type !== undefined) {
8411
- sets.push("task_type = ?");
8412
- params.push(input.task_type ?? null);
8413
- }
8414
- params.push(id, input.version);
8415
- const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
8416
- if (result.changes === 0) {
8417
- const current = getTask(id, d);
8418
- throw new VersionConflictError(id, input.version, current?.version ?? -1);
8419
- }
8420
- if (input.tags !== undefined) {
8421
- replaceTaskTags(id, input.tags, d);
8422
- }
8423
- const agentId = task.assigned_to || task.agent_id || null;
8424
- if (input.status !== undefined && input.status !== task.status)
8425
- logTaskChange(id, "update", "status", task.status, input.status, agentId, d);
8426
- if (input.priority !== undefined && input.priority !== task.priority)
8427
- logTaskChange(id, "update", "priority", task.priority, input.priority, agentId, d);
8428
- if (input.title !== undefined && input.title !== task.title)
8429
- logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
8430
- if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
8431
- logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
8432
- if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
8433
- logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
8434
- if (input.approved_by !== undefined)
8435
- logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
8436
- const updatedTask = {
8437
- ...task,
8438
- ...Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined)),
8439
- tags: input.tags ?? task.tags,
8440
- metadata: input.metadata ?? task.metadata,
8441
- version: task.version + 1,
8442
- updated_at: timestamp,
8443
- completed_at: input.status === "completed" ? completionTimestamp : input.completed_at !== undefined ? input.completed_at : task.completed_at,
8444
- sla_minutes: input.sla_minutes !== undefined ? input.sla_minutes : task.sla_minutes,
8445
- actual_minutes: input.actual_minutes ?? task.actual_minutes,
8446
- confidence: input.confidence !== undefined ? input.confidence : task.confidence,
8447
- retry_count: input.retry_count ?? task.retry_count,
8448
- max_retries: input.max_retries ?? task.max_retries,
8449
- retry_after: input.retry_after !== undefined ? input.retry_after : task.retry_after,
8450
- requires_approval: input.requires_approval !== undefined ? input.requires_approval : task.requires_approval,
8451
- approved_by: input.approved_by ?? task.approved_by,
8452
- approved_at: input.approved_by ? timestamp : task.approved_at
8453
- };
8454
- const databasePath = databasePathFromDatabase(d);
8455
- if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to) {
8456
- const payload = taskEventData(updatedTask, { assigned_to: input.assigned_to, old_assigned_to: task.assigned_to });
8457
- dispatchWebhook2("task.assigned", payload, d).catch(() => {});
8458
- emitLocalEventHooksQuiet({ type: "task.assigned", payload, databasePath });
8459
- emitSharedTaskEventQuiet({ type: "task.assigned", task: updatedTask, data: { old_assigned_to: task.assigned_to }, databasePath });
8460
- }
8461
- if (input.status !== undefined && input.status !== task.status) {
8462
- const payload = taskEventData(updatedTask, { old_status: task.status, new_status: input.status });
8463
- dispatchWebhook2("task.status_changed", payload, d).catch(() => {});
8464
- emitLocalEventHooksQuiet({ type: "task.status_changed", payload, databasePath });
8465
- emitSharedTaskEventQuiet({ type: "task.status_changed", task: updatedTask, data: { old_status: task.status, new_status: input.status }, databasePath });
8466
- }
8467
- if (input.approved_by !== undefined) {
8468
- emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title }, databasePath });
8448
+ for (const tt of template.tasks) {
8449
+ if (skippedPositions.has(tt.position))
8450
+ continue;
8451
+ if (tt.include_template_id)
8452
+ continue;
8453
+ const deps = tt.depends_on_positions;
8454
+ for (const depPos of deps) {
8455
+ if (skippedPositions.has(depPos))
8456
+ continue;
8457
+ const taskId = positionToId.get(tt.position);
8458
+ const depId = positionToId.get(depPos);
8459
+ if (taskId && depId) {
8460
+ addDependency(taskId, depId, d);
8461
+ }
8462
+ }
8469
8463
  }
8470
- const updatePayload = taskEventData(updatedTask);
8471
- dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
8472
- emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload, databasePath });
8473
- emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
8474
- return updatedTask;
8464
+ return createdTasks;
8475
8465
  }
8476
- function deleteTask(id, db) {
8466
+ function previewTemplate(templateId, variables, db) {
8477
8467
  const d = db || getDatabase();
8478
- const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
8479
- if (!row)
8480
- return false;
8481
- recordStorageTombstone({
8482
- object_type: "tasks",
8483
- object_id: id,
8484
- payload: rowToTask(row),
8485
- version: row.version
8486
- }, d);
8487
- const result = d.run("DELETE FROM tasks WHERE id = ?", [id]);
8488
- return result.changes > 0;
8468
+ const template = getTemplateWithTasks(templateId, d);
8469
+ if (!template)
8470
+ throw new Error(`Template not found: ${templateId}`);
8471
+ const resolved = resolveVariables(template.variables, variables);
8472
+ const tasks = [];
8473
+ if (template.tasks.length === 0) {
8474
+ tasks.push({
8475
+ position: 0,
8476
+ title: substituteVars(template.title_pattern, resolved),
8477
+ description: template.description ? substituteVars(template.description, resolved) : null,
8478
+ priority: template.priority,
8479
+ tags: template.tags,
8480
+ task_type: null,
8481
+ depends_on_positions: []
8482
+ });
8483
+ } else {
8484
+ for (const tt of template.tasks) {
8485
+ if (tt.condition && !evaluateCondition(tt.condition, resolved))
8486
+ continue;
8487
+ tasks.push({
8488
+ position: tt.position,
8489
+ title: substituteVars(tt.title_pattern, resolved),
8490
+ description: tt.description ? substituteVars(tt.description, resolved) : null,
8491
+ priority: tt.priority,
8492
+ tags: tt.tags,
8493
+ task_type: tt.task_type,
8494
+ depends_on_positions: tt.depends_on_positions
8495
+ });
8496
+ }
8497
+ }
8498
+ return {
8499
+ template_id: template.id,
8500
+ template_name: template.name,
8501
+ description: template.description,
8502
+ variables: template.variables,
8503
+ resolved_variables: resolved,
8504
+ tasks
8505
+ };
8489
8506
  }
8490
- var init_task_crud = __esm(() => {
8491
- init_types();
8507
+ var init_templates = __esm(() => {
8492
8508
  init_database();
8493
- init_completion_guard();
8494
- init_event_emission_safety();
8495
- init_event_hooks();
8496
- init_shared_events();
8497
- init_audit();
8498
- init_webhooks();
8499
- init_checklists();
8509
+ init_tasks();
8500
8510
  init_storage_tombstones();
8501
8511
  });
8502
8512
 
8503
- // src/lib/recurrence.ts
8504
- function parseRecurrenceRule(rule) {
8505
- const normalized = rule.trim().toLowerCase();
8506
- if (normalized === "every weekday" || normalized === "every weekdays") {
8507
- return { type: "specific_days", days: [1, 2, 3, 4, 5] };
8508
- }
8509
- if (normalized === "every day" || normalized === "daily") {
8510
- return { type: "interval", interval: 1, unit: "day" };
8511
- }
8512
- if (normalized === "every week" || normalized === "weekly") {
8513
- return { type: "interval", interval: 1, unit: "week" };
8514
- }
8515
- if (normalized === "every month" || normalized === "monthly") {
8516
- return { type: "interval", interval: 1, unit: "month" };
8517
- }
8518
- const intervalMatch = normalized.match(/^every\s+(\d+)\s+(day|week|month)s?$/);
8519
- if (intervalMatch) {
8520
- return {
8521
- type: "interval",
8522
- interval: parseInt(intervalMatch[1], 10),
8523
- unit: intervalMatch[2]
8524
- };
8525
- }
8526
- const daysMatch = normalized.match(/^every\s+(.+)$/);
8527
- if (daysMatch) {
8528
- const dayParts = daysMatch[1].split(/[,\s]+/).map((d) => d.trim()).filter(Boolean);
8529
- const days = [];
8530
- for (const part of dayParts) {
8531
- const dayNum = DAY_NAMES[part];
8532
- if (dayNum !== undefined) {
8533
- days.push(dayNum);
8534
- }
8535
- }
8536
- if (days.length > 0) {
8537
- return { type: "specific_days", days: days.sort((a, b) => a - b) };
8538
- }
8513
+ // src/db/task-graph.ts
8514
+ function addDependency(taskId, dependsOn, db) {
8515
+ const d = db || getDatabase();
8516
+ if (!getTask(taskId, d))
8517
+ throw new TaskNotFoundError(taskId);
8518
+ if (!getTask(dependsOn, d))
8519
+ throw new TaskNotFoundError(dependsOn);
8520
+ if (wouldCreateCycle(taskId, dependsOn, d)) {
8521
+ throw new DependencyCycleError(taskId, dependsOn);
8539
8522
  }
8540
- throw new Error(`Invalid recurrence rule: "${rule}". Supported formats: "every day", "every weekday", "every week", "every 2 weeks", "every month", "every N days/weeks/months", "every monday", "every mon,wed,fri"`);
8523
+ d.run("INSERT OR IGNORE INTO task_dependencies (task_id, depends_on) VALUES (?, ?)", [taskId, dependsOn]);
8541
8524
  }
8542
- function isValidRecurrenceRule(rule) {
8543
- try {
8544
- parseRecurrenceRule(rule);
8545
- return true;
8546
- } catch {
8547
- return false;
8548
- }
8525
+ function removeDependency(taskId, dependsOn, db) {
8526
+ const d = db || getDatabase();
8527
+ const result = d.run("DELETE FROM task_dependencies WHERE task_id = ? AND depends_on = ?", [taskId, dependsOn]);
8528
+ return result.changes > 0;
8549
8529
  }
8550
- function nextOccurrence(rule, from) {
8551
- const parsed = parseRecurrenceRule(rule);
8552
- const base = from || new Date;
8553
- if (parsed.type === "interval") {
8554
- const next = new Date(base);
8555
- if (parsed.unit === "day") {
8556
- next.setDate(next.getDate() + parsed.interval);
8557
- } else if (parsed.unit === "week") {
8558
- next.setDate(next.getDate() + parsed.interval * 7);
8559
- } else if (parsed.unit === "month") {
8560
- next.setMonth(next.getMonth() + parsed.interval);
8561
- }
8562
- return next.toISOString();
8563
- }
8564
- if (parsed.type === "specific_days") {
8565
- const currentDay = base.getDay();
8566
- const days = parsed.days;
8567
- let daysToAdd = Infinity;
8568
- for (const day of days) {
8569
- let diff = day - currentDay;
8570
- if (diff <= 0)
8571
- diff += 7;
8572
- if (diff < daysToAdd)
8573
- daysToAdd = diff;
8574
- }
8575
- const next = new Date(base);
8576
- next.setDate(next.getDate() + daysToAdd);
8577
- return next.toISOString();
8578
- }
8579
- throw new Error(`Cannot calculate next occurrence for rule: "${rule}"`);
8530
+ function getTaskDependencies(taskId, db) {
8531
+ const d = db || getDatabase();
8532
+ return d.query("SELECT * FROM task_dependencies WHERE task_id = ?").all(taskId);
8580
8533
  }
8581
- var DAY_NAMES;
8582
- var init_recurrence = __esm(() => {
8583
- DAY_NAMES = {
8584
- sunday: 0,
8585
- sun: 0,
8586
- monday: 1,
8587
- mon: 1,
8588
- tuesday: 2,
8589
- tue: 2,
8590
- wednesday: 3,
8591
- wed: 3,
8592
- thursday: 4,
8593
- thu: 4,
8594
- friday: 5,
8595
- fri: 5,
8596
- saturday: 6,
8597
- sat: 6
8598
- };
8599
- });
8600
-
8601
- // src/db/templates.ts
8602
- var exports_templates = {};
8603
- __export(exports_templates, {
8604
- updateTemplate: () => updateTemplate,
8605
- tasksFromTemplate: () => tasksFromTemplate,
8606
- taskFromTemplate: () => taskFromTemplate,
8607
- resolveVariables: () => resolveVariables,
8608
- previewTemplate: () => previewTemplate,
8609
- listTemplates: () => listTemplates,
8610
- listTemplateVersions: () => listTemplateVersions,
8611
- importTemplate: () => importTemplate,
8612
- getTemplateWithTasks: () => getTemplateWithTasks,
8613
- getTemplateVersion: () => getTemplateVersion,
8614
- getTemplateTasks: () => getTemplateTasks,
8615
- getTemplate: () => getTemplate,
8616
- exportTemplate: () => exportTemplate,
8617
- evaluateCondition: () => evaluateCondition,
8618
- deleteTemplate: () => deleteTemplate,
8619
- createTemplate: () => createTemplate,
8620
- addTemplateTasks: () => addTemplateTasks
8621
- });
8622
- function rowToTemplate(row) {
8623
- return {
8624
- ...row,
8625
- tags: JSON.parse(row.tags || "[]"),
8626
- variables: JSON.parse(row.variables || "[]"),
8627
- metadata: JSON.parse(row.metadata || "{}"),
8628
- priority: row.priority || "medium",
8629
- version: row.version ?? 1
8630
- };
8534
+ function getTaskDependents(taskId, db) {
8535
+ const d = db || getDatabase();
8536
+ return d.query("SELECT * FROM task_dependencies WHERE depends_on = ?").all(taskId);
8631
8537
  }
8632
- function rowToTemplateTask(row) {
8633
- return {
8634
- ...row,
8635
- tags: JSON.parse(row.tags || "[]"),
8636
- depends_on_positions: JSON.parse(row.depends_on_positions || "[]"),
8637
- metadata: JSON.parse(row.metadata || "{}"),
8638
- priority: row.priority || "medium",
8639
- condition: row.condition ?? null,
8640
- include_template_id: row.include_template_id ?? null
8538
+ function cloneTask(taskId, overrides, db) {
8539
+ const d = db || getDatabase();
8540
+ const source = getTask(taskId, d);
8541
+ if (!source)
8542
+ throw new TaskNotFoundError(taskId);
8543
+ const input = {
8544
+ title: overrides?.title ?? source.title,
8545
+ description: overrides?.description ?? source.description ?? undefined,
8546
+ priority: overrides?.priority ?? source.priority,
8547
+ project_id: overrides?.project_id ?? source.project_id ?? undefined,
8548
+ parent_id: overrides?.parent_id ?? source.parent_id ?? undefined,
8549
+ plan_id: overrides?.plan_id ?? source.plan_id ?? undefined,
8550
+ task_list_id: overrides?.task_list_id ?? source.task_list_id ?? undefined,
8551
+ status: overrides?.status ?? "pending",
8552
+ agent_id: overrides?.agent_id ?? source.agent_id ?? undefined,
8553
+ assigned_to: overrides?.assigned_to ?? source.assigned_to ?? undefined,
8554
+ tags: overrides?.tags ?? source.tags,
8555
+ metadata: overrides?.metadata ?? source.metadata,
8556
+ estimated_minutes: overrides?.estimated_minutes ?? source.estimated_minutes ?? undefined,
8557
+ recurrence_rule: overrides?.recurrence_rule ?? source.recurrence_rule ?? undefined
8641
8558
  };
8559
+ return createTask(input, d);
8642
8560
  }
8643
- function resolveTemplateId(id, d) {
8644
- return resolvePartialId(d, "task_templates", id);
8645
- }
8646
- function createTemplate(input, db) {
8561
+ function getTaskGraph(taskId, direction = "both", db) {
8647
8562
  const d = db || getDatabase();
8648
- const id = uuid();
8649
- const machineId = currentStorageMachineId(d);
8650
- d.run(`INSERT INTO task_templates (id, name, title_pattern, description, priority, tags, variables, project_id, plan_id, metadata, created_at, machine_id)
8651
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
8652
- id,
8653
- input.name,
8654
- input.title_pattern,
8655
- input.description || null,
8656
- input.priority || "medium",
8657
- JSON.stringify(input.tags || []),
8658
- JSON.stringify(input.variables || []),
8659
- input.project_id || null,
8660
- input.plan_id || null,
8661
- JSON.stringify(input.metadata || {}),
8662
- now(),
8663
- machineId
8664
- ]);
8665
- if (input.tasks && input.tasks.length > 0) {
8666
- addTemplateTasks(id, input.tasks, d);
8563
+ const task = getTask(taskId, d);
8564
+ if (!task)
8565
+ throw new TaskNotFoundError(taskId);
8566
+ function toNode(t) {
8567
+ const deps = getTaskDependencies(t.id, d);
8568
+ const hasUnfinishedDeps = deps.some((dep) => {
8569
+ const depTask = getTask(dep.depends_on, d);
8570
+ return depTask && depTask.status !== "completed";
8571
+ });
8572
+ return { id: t.id, short_id: t.short_id, title: t.title, status: t.status, priority: t.priority, is_blocked: hasUnfinishedDeps };
8573
+ }
8574
+ function buildUp(id, visited) {
8575
+ if (visited.has(id))
8576
+ return [];
8577
+ visited.add(id);
8578
+ const deps = d.query("SELECT depends_on FROM task_dependencies WHERE task_id = ?").all(id);
8579
+ return deps.map((dep) => {
8580
+ const depTask = getTask(dep.depends_on, d);
8581
+ if (!depTask)
8582
+ return null;
8583
+ return { task: toNode(depTask), depends_on: buildUp(dep.depends_on, visited), blocks: [] };
8584
+ }).filter(Boolean);
8585
+ }
8586
+ function buildDown(id, visited) {
8587
+ if (visited.has(id))
8588
+ return [];
8589
+ visited.add(id);
8590
+ const dependents = d.query("SELECT task_id FROM task_dependencies WHERE depends_on = ?").all(id);
8591
+ return dependents.map((dep) => {
8592
+ const depTask = getTask(dep.task_id, d);
8593
+ if (!depTask)
8594
+ return null;
8595
+ return { task: toNode(depTask), depends_on: [], blocks: buildDown(dep.task_id, visited) };
8596
+ }).filter(Boolean);
8667
8597
  }
8668
- return getTemplate(id, d);
8598
+ const rootNode = toNode(task);
8599
+ const depends_on = direction === "up" || direction === "both" ? buildUp(taskId, new Set) : [];
8600
+ const blocks = direction === "down" || direction === "both" ? buildDown(taskId, new Set) : [];
8601
+ return { task: rootNode, depends_on, blocks };
8669
8602
  }
8670
- function getTemplate(id, db) {
8603
+ function moveTask(taskId, target, db) {
8671
8604
  const d = db || getDatabase();
8672
- const resolved = resolveTemplateId(id, d);
8673
- if (!resolved)
8605
+ const task = getTask(taskId, d);
8606
+ if (!task)
8607
+ throw new TaskNotFoundError(taskId);
8608
+ const sets = ["updated_at = ?", "version = version + 1"];
8609
+ const params = [now()];
8610
+ if (target.task_list_id !== undefined) {
8611
+ sets.push("task_list_id = ?");
8612
+ params.push(target.task_list_id);
8613
+ }
8614
+ if (target.project_id !== undefined) {
8615
+ sets.push("project_id = ?");
8616
+ params.push(target.project_id);
8617
+ }
8618
+ if (target.plan_id !== undefined) {
8619
+ sets.push("plan_id = ?");
8620
+ params.push(target.plan_id);
8621
+ }
8622
+ params.push(taskId);
8623
+ d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`, params);
8624
+ return getTask(taskId, d);
8625
+ }
8626
+ function wouldCreateCycle(taskId, dependsOn, db) {
8627
+ const visited = new Set;
8628
+ const queue = [dependsOn];
8629
+ while (queue.length > 0) {
8630
+ const current = queue.shift();
8631
+ if (current === taskId)
8632
+ return true;
8633
+ if (visited.has(current))
8634
+ continue;
8635
+ visited.add(current);
8636
+ const deps = db.query("SELECT depends_on FROM task_dependencies WHERE task_id = ?").all(current);
8637
+ for (const dep of deps) {
8638
+ queue.push(dep.depends_on);
8639
+ }
8640
+ }
8641
+ return false;
8642
+ }
8643
+ var init_task_graph = __esm(() => {
8644
+ init_types();
8645
+ init_database();
8646
+ init_task_crud();
8647
+ });
8648
+
8649
+ // src/db/task-lifecycle.ts
8650
+ var exports_task_lifecycle = {};
8651
+ __export(exports_task_lifecycle, {
8652
+ unlockTask: () => unlockTask,
8653
+ stealTask: () => stealTask,
8654
+ startTask: () => startTask,
8655
+ spawnNextRecurrence: () => spawnNextRecurrence,
8656
+ lockTask: () => lockTask,
8657
+ getTasksChangedSince: () => getTasksChangedSince,
8658
+ getTaskLockStatus: () => getTaskLockStatus,
8659
+ getStaleTasks: () => getStaleTasks,
8660
+ getNextTask: () => getNextTask,
8661
+ getBlockingDeps: () => getBlockingDeps,
8662
+ getActiveWork: () => getActiveWork,
8663
+ failTask: () => failTask,
8664
+ completeTask: () => completeTask,
8665
+ claimOrSteal: () => claimOrSteal,
8666
+ claimNextTask: () => claimNextTask
8667
+ });
8668
+ function lockExpiresAt(lockedAt) {
8669
+ if (!lockedAt)
8674
8670
  return null;
8675
- const row = d.query("SELECT * FROM task_templates WHERE id = ?").get(resolved);
8676
- return row ? rowToTemplate(row) : null;
8671
+ return new Date(new Date(lockedAt).getTime() + LOCK_EXPIRY_MINUTES * 60 * 1000).toISOString();
8677
8672
  }
8678
- function listTemplates(db) {
8679
- const d = db || getDatabase();
8680
- return d.query("SELECT * FROM task_templates ORDER BY name").all().map(rowToTemplate);
8673
+ function assertStartable(task, agentId) {
8674
+ if (task.status === "pending")
8675
+ return;
8676
+ if (task.status === "in_progress")
8677
+ return;
8678
+ throw new Error(`Task is ${task.status} and cannot be started by ${agentId}`);
8681
8679
  }
8682
- function deleteTemplate(id, db) {
8680
+ function getBlockingDeps(id, db) {
8683
8681
  const d = db || getDatabase();
8684
- const resolved = resolveTemplateId(id, d);
8685
- if (!resolved)
8686
- return false;
8687
- const template = getTemplate(resolved, d);
8688
- if (!template)
8689
- return false;
8690
- recordStorageTombstone({
8691
- object_type: "templates",
8692
- object_id: resolved,
8693
- payload: template,
8694
- version: template.version
8695
- }, d);
8696
- return d.run("DELETE FROM task_templates WHERE id = ?", [resolved]).changes > 0;
8682
+ const deps = getTaskDependencies(id, d);
8683
+ if (deps.length === 0)
8684
+ return [];
8685
+ const blocking = [];
8686
+ for (const dep of deps) {
8687
+ const task = getTask(dep.depends_on, d);
8688
+ if (task && task.status !== "completed")
8689
+ blocking.push(task);
8690
+ }
8691
+ return blocking;
8697
8692
  }
8698
- function updateTemplate(id, updates, db) {
8693
+ function startTask(id, agentId, db) {
8699
8694
  const d = db || getDatabase();
8700
- const resolved = resolveTemplateId(id, d);
8701
- if (!resolved)
8702
- return null;
8703
- const current = getTemplateWithTasks(resolved, d);
8704
- if (current) {
8705
- const snapshot = JSON.stringify({
8706
- name: current.name,
8707
- title_pattern: current.title_pattern,
8708
- description: current.description,
8709
- priority: current.priority,
8710
- tags: current.tags,
8711
- variables: current.variables,
8712
- project_id: current.project_id,
8713
- plan_id: current.plan_id,
8714
- metadata: current.metadata,
8715
- tasks: current.tasks
8695
+ const databasePath = databasePathFromDatabase(d);
8696
+ const task = getTask(id, d);
8697
+ if (!task)
8698
+ throw new TaskNotFoundError(id);
8699
+ assertStartable(task, agentId);
8700
+ const blocking = getBlockingDeps(id, d);
8701
+ if (blocking.length > 0) {
8702
+ const blockerIds = blocking.map((b) => b.id.slice(0, 8)).join(", ");
8703
+ emitLocalEventHooksQuiet({
8704
+ type: "task.blocked",
8705
+ payload: {
8706
+ id,
8707
+ agent_id: agentId,
8708
+ title: task.title,
8709
+ blockers: blocking.map((b) => ({ id: b.id, short_id: b.short_id, title: b.title, status: b.status }))
8710
+ },
8711
+ databasePath
8716
8712
  });
8717
- d.run(`INSERT INTO template_versions (id, template_id, version, snapshot, created_at) VALUES (?, ?, ?, ?, ?)`, [uuid(), resolved, current.version, snapshot, now()]);
8713
+ throw new Error(`Task is blocked by ${blocking.length} unfinished dependency(ies): ${blockerIds}`);
8718
8714
  }
8719
- const sets = ["version = version + 1"];
8720
- const values = [];
8721
- if (updates.name !== undefined) {
8722
- sets.push("name = ?");
8723
- values.push(updates.name);
8715
+ const cutoff = lockExpiryCutoff();
8716
+ const timestamp = now();
8717
+ const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, started_at = COALESCE(started_at, ?), version = version + 1, updated_at = ?
8718
+ WHERE id = ? AND status IN ('pending', 'in_progress') AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, timestamp, id, agentId, cutoff]);
8719
+ if (result.changes === 0) {
8720
+ const current = getTask(id, d);
8721
+ if (!current)
8722
+ throw new TaskNotFoundError(id);
8723
+ assertStartable(current, agentId);
8724
+ if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
8725
+ throw new LockError(id, current.locked_by);
8726
+ }
8727
+ throw new Error(`Task ${id} could not be started because it changed during claim`);
8724
8728
  }
8725
- if (updates.title_pattern !== undefined) {
8726
- sets.push("title_pattern = ?");
8727
- values.push(updates.title_pattern);
8729
+ logTaskChange(id, "start", "status", "pending", "in_progress", agentId, d);
8730
+ const startedTask = { ...task, status: "in_progress", assigned_to: agentId, locked_by: agentId, locked_at: timestamp, started_at: task.started_at || timestamp, version: task.version + 1, updated_at: timestamp };
8731
+ const payload = taskEventData(startedTask, { agent_id: agentId });
8732
+ dispatchWebhook2("task.started", payload, d).catch(() => {});
8733
+ emitLocalEventHooksQuiet({ type: "task.started", payload, databasePath });
8734
+ emitSharedTaskEventQuiet({ type: "task.started", task: startedTask, data: { agent_id: agentId }, databasePath });
8735
+ return startedTask;
8736
+ }
8737
+ function completeTask(id, agentId, db, options) {
8738
+ const d = db || getDatabase();
8739
+ const databasePath = databasePathFromDatabase(d);
8740
+ const task = getTask(id, d);
8741
+ if (!task)
8742
+ throw new TaskNotFoundError(id);
8743
+ if (task.status === "completed") {
8744
+ return task;
8728
8745
  }
8729
- if (updates.description !== undefined) {
8730
- sets.push("description = ?");
8731
- values.push(updates.description);
8746
+ if (task.status === "cancelled") {
8747
+ throw new Error(`Task ${id} is cancelled and cannot be completed`);
8732
8748
  }
8733
- if (updates.priority !== undefined) {
8734
- sets.push("priority = ?");
8735
- values.push(updates.priority);
8749
+ if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
8750
+ throw new LockError(id, task.locked_by);
8736
8751
  }
8737
- if (updates.tags !== undefined) {
8738
- sets.push("tags = ?");
8739
- values.push(JSON.stringify(updates.tags));
8752
+ checkCompletionGuard(task, agentId || null, d);
8753
+ const evidence = options ? { files_changed: options.files_changed, test_results: options.test_results, commit_hash: options.commit_hash, notes: options.notes, attachment_ids: options.attachment_ids } : undefined;
8754
+ const hasEvidence = evidence && (evidence.files_changed || evidence.test_results || evidence.commit_hash || evidence.notes || evidence.attachment_ids);
8755
+ const completionMeta = {};
8756
+ if (hasEvidence)
8757
+ completionMeta._evidence = evidence;
8758
+ if (options?.confidence !== undefined) {
8759
+ completionMeta._completion = { confidence: options.confidence };
8740
8760
  }
8741
- if (updates.variables !== undefined) {
8742
- sets.push("variables = ?");
8743
- values.push(JSON.stringify(updates.variables));
8761
+ const hasMeta = Object.keys(completionMeta).length > 0;
8762
+ const timestamp = options?.completed_at || now();
8763
+ const confidence = options?.confidence !== undefined ? options.confidence : task.confidence;
8764
+ const versionBeforeStatus = task.version + (hasMeta ? 1 : 0);
8765
+ const finalVersion = versionBeforeStatus + 1;
8766
+ const tx = d.transaction(() => {
8767
+ if (hasMeta) {
8768
+ const meta2 = { ...task.metadata, ...completionMeta };
8769
+ const metaResult = d.run("UPDATE tasks SET metadata = ?, version = version + 1, updated_at = ? WHERE id = ? AND version = ?", [JSON.stringify(meta2), timestamp, id, task.version]);
8770
+ if (metaResult.changes === 0) {
8771
+ const current = getTask(id, d);
8772
+ throw new VersionConflictError(id, task.version, current?.version ?? -1);
8773
+ }
8774
+ }
8775
+ const statusResult = d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, confidence = ?, version = version + 1, updated_at = ?
8776
+ WHERE id = ? AND version = ?`, [timestamp, confidence, timestamp, id, versionBeforeStatus]);
8777
+ if (statusResult.changes === 0) {
8778
+ const current = getTask(id, d);
8779
+ throw new VersionConflictError(id, versionBeforeStatus, current?.version ?? -1);
8780
+ }
8781
+ });
8782
+ tx();
8783
+ logTaskChange(id, "complete", "status", task.status, "completed", agentId || null, d);
8784
+ const completedTaskForEvent = {
8785
+ ...task,
8786
+ status: "completed",
8787
+ locked_by: null,
8788
+ locked_at: null,
8789
+ completed_at: timestamp,
8790
+ confidence,
8791
+ version: finalVersion,
8792
+ updated_at: timestamp,
8793
+ metadata: hasMeta ? { ...task.metadata, ...completionMeta } : task.metadata
8794
+ };
8795
+ const completionPayload = taskEventData(completedTaskForEvent, { agent_id: agentId, completed_at: timestamp });
8796
+ dispatchWebhook2("task.completed", completionPayload, d).catch(() => {});
8797
+ emitLocalEventHooksQuiet({ type: "task.completed", payload: completionPayload, databasePath });
8798
+ emitSharedTaskEventQuiet({ type: "task.completed", task: completedTaskForEvent, data: { agent_id: agentId, completed_at: timestamp }, databasePath });
8799
+ let spawnedTask = null;
8800
+ if (task.recurrence_rule && !options?.skip_recurrence) {
8801
+ try {
8802
+ spawnedTask = spawnNextRecurrence(task, d, timestamp);
8803
+ } catch (e) {
8804
+ spawnedTask = null;
8805
+ console.warn(`[tasks] failed to spawn next recurrence for ${id}: ${e instanceof Error ? e.message : String(e)}`);
8806
+ }
8744
8807
  }
8745
- if (updates.project_id !== undefined) {
8746
- sets.push("project_id = ?");
8747
- values.push(updates.project_id);
8808
+ let spawnedFromTemplate = null;
8809
+ if (task.spawns_template_id) {
8810
+ const spawnDepth = task.metadata?._spawn_depth || 0;
8811
+ if (spawnDepth >= MAX_SPAWN_DEPTH) {
8812
+ console.warn(`[tasks] Task ${id} exceeded max spawn depth (${MAX_SPAWN_DEPTH}), skipping template spawn`);
8813
+ } else {
8814
+ try {
8815
+ const input = taskFromTemplate(task.spawns_template_id, {
8816
+ project_id: task.project_id ?? undefined,
8817
+ plan_id: task.plan_id ?? undefined,
8818
+ task_list_id: task.task_list_id ?? undefined,
8819
+ assigned_to: task.assigned_to ?? undefined
8820
+ }, d);
8821
+ input.metadata = { ...input.metadata || {}, _spawn_depth: spawnDepth + 1 };
8822
+ spawnedFromTemplate = createTask(input, d);
8823
+ } catch {}
8824
+ }
8748
8825
  }
8749
- if (updates.plan_id !== undefined) {
8750
- sets.push("plan_id = ?");
8751
- values.push(updates.plan_id);
8826
+ const meta = hasMeta ? { ...task.metadata, ...completionMeta } : task.metadata;
8827
+ if (spawnedTask) {
8828
+ meta._next_recurrence = { id: spawnedTask.id, short_id: spawnedTask.short_id, due_at: spawnedTask.due_at };
8752
8829
  }
8753
- if (updates.metadata !== undefined) {
8754
- sets.push("metadata = ?");
8755
- values.push(JSON.stringify(updates.metadata));
8830
+ if (spawnedFromTemplate) {
8831
+ meta._spawned_task = { id: spawnedFromTemplate.id, short_id: spawnedFromTemplate.short_id, title: spawnedFromTemplate.title };
8756
8832
  }
8757
- values.push(resolved);
8758
- d.run(`UPDATE task_templates SET ${sets.join(", ")} WHERE id = ?`, values);
8759
- return getTemplate(resolved, d);
8760
- }
8761
- function taskFromTemplate(templateId, overrides = {}, db) {
8762
- const t = getTemplate(templateId, db);
8763
- if (!t)
8764
- throw new Error(`Template not found: ${templateId}`);
8765
- const cleanOverrides = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined));
8766
- return {
8767
- title: cleanOverrides.title || t.title_pattern,
8768
- description: cleanOverrides.description ?? t.description ?? undefined,
8769
- priority: cleanOverrides.priority ?? t.priority,
8770
- tags: cleanOverrides.tags ?? t.tags,
8771
- project_id: cleanOverrides.project_id ?? t.project_id ?? undefined,
8772
- plan_id: cleanOverrides.plan_id ?? t.plan_id ?? undefined,
8773
- metadata: cleanOverrides.metadata ?? t.metadata,
8774
- ...cleanOverrides
8775
- };
8776
- }
8777
- function addTemplateTasks(templateId, tasks, db) {
8778
- const d = db || getDatabase();
8779
- const template = getTemplate(templateId, d);
8780
- if (!template)
8781
- throw new Error(`Template not found: ${templateId}`);
8782
- d.run("DELETE FROM template_tasks WHERE template_id = ?", [templateId]);
8783
- const results = [];
8784
- for (let i = 0;i < tasks.length; i++) {
8785
- const task = tasks[i];
8786
- const id = uuid();
8787
- d.run(`INSERT INTO template_tasks (id, template_id, position, title_pattern, description, priority, tags, task_type, condition, include_template_id, depends_on_positions, metadata, created_at)
8788
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
8789
- id,
8790
- templateId,
8791
- i,
8792
- task.title_pattern,
8793
- task.description || null,
8794
- task.priority || "medium",
8795
- JSON.stringify(task.tags || []),
8796
- task.task_type || null,
8797
- task.condition || null,
8798
- task.include_template_id || null,
8799
- JSON.stringify(task.depends_on || []),
8800
- JSON.stringify(task.metadata || {}),
8801
- now()
8802
- ]);
8803
- const row = d.query("SELECT * FROM template_tasks WHERE id = ?").get(id);
8804
- if (row)
8805
- results.push(rowToTemplateTask(row));
8833
+ const unblockedDeps = d.query(`SELECT DISTINCT t.id, t.short_id, t.title FROM tasks t
8834
+ JOIN task_dependencies td ON td.task_id = t.id
8835
+ WHERE td.depends_on = ? AND t.status = 'pending'
8836
+ AND NOT EXISTS (
8837
+ SELECT 1 FROM task_dependencies td2
8838
+ JOIN tasks dep2 ON dep2.id = td2.depends_on
8839
+ WHERE td2.task_id = t.id AND dep2.status NOT IN ('completed', 'cancelled') AND dep2.id != ?
8840
+ )`).all(id, id);
8841
+ if (unblockedDeps.length > 0) {
8842
+ meta._unblocked = unblockedDeps.map((d2) => ({ id: d2.id, short_id: d2.short_id, title: d2.title }));
8843
+ for (const dep of unblockedDeps) {
8844
+ const depTask = getTask(dep.id, d);
8845
+ const payload = depTask ? taskEventData(depTask, { unblocked_by: id }) : { id: dep.id, unblocked_by: id, title: dep.title };
8846
+ dispatchWebhook2("task.unblocked", payload, d).catch(() => {});
8847
+ emitLocalEventHooksQuiet({ type: "task.unblocked", payload, databasePath });
8848
+ if (depTask)
8849
+ emitSharedTaskEventQuiet({ type: "task.unblocked", task: depTask, data: { unblocked_by: id }, databasePath });
8850
+ }
8806
8851
  }
8807
- return results;
8808
- }
8809
- function getTemplateWithTasks(id, db) {
8810
- const d = db || getDatabase();
8811
- const template = getTemplate(id, d);
8812
- if (!template)
8813
- return null;
8814
- const rows = d.query("SELECT * FROM template_tasks WHERE template_id = ? ORDER BY position").all(template.id);
8815
- const tasks = rows.map(rowToTemplateTask);
8816
- return { ...template, tasks };
8852
+ return { ...task, status: "completed", locked_by: null, locked_at: null, completed_at: timestamp, confidence, version: finalVersion, updated_at: timestamp, metadata: meta };
8817
8853
  }
8818
- function getTemplateTasks(templateId, db) {
8854
+ function lockTask(id, agentId, db) {
8819
8855
  const d = db || getDatabase();
8820
- const resolved = resolveTemplateId(templateId, d);
8821
- if (!resolved)
8822
- return [];
8823
- const rows = d.query("SELECT * FROM template_tasks WHERE template_id = ? ORDER BY position").all(resolved);
8824
- return rows.map(rowToTemplateTask);
8825
- }
8826
- function evaluateCondition(condition, variables) {
8827
- if (!condition || condition.trim() === "")
8828
- return true;
8829
- const trimmed = condition.trim();
8830
- const eqMatch = trimmed.match(/^\{([^}]+)\}\s*==\s*(.+)$/);
8831
- if (eqMatch) {
8832
- const varName = eqMatch[1];
8833
- const expected = eqMatch[2].trim();
8834
- return (variables[varName] ?? "") === expected;
8856
+ const task = getTask(id, d);
8857
+ if (!task)
8858
+ throw new TaskNotFoundError(id);
8859
+ if (task.status === "completed" || task.status === "cancelled") {
8860
+ return {
8861
+ success: false,
8862
+ error: `Task is ${task.status} and cannot be locked`
8863
+ };
8835
8864
  }
8836
- const neqMatch = trimmed.match(/^\{([^}]+)\}\s*!=\s*(.+)$/);
8837
- if (neqMatch) {
8838
- const varName = neqMatch[1];
8839
- const expected = neqMatch[2].trim();
8840
- return (variables[varName] ?? "") !== expected;
8865
+ if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
8866
+ const timestamp2 = now();
8867
+ d.run(`UPDATE tasks SET locked_at = ?, updated_at = ?, version = version + 1 WHERE id = ? AND locked_by = ?`, [timestamp2, timestamp2, id, agentId]);
8868
+ logTaskChange(id, "lock_renew", "locked_by", agentId, agentId, agentId, d);
8869
+ return { success: true, locked_by: agentId, locked_at: timestamp2, expires_at: lockExpiresAt(timestamp2) };
8841
8870
  }
8842
- const falsyMatch = trimmed.match(/^!\{([^}]+)\}$/);
8843
- if (falsyMatch) {
8844
- const varName = falsyMatch[1];
8845
- const val = variables[varName];
8846
- return !val || val === "" || val === "false";
8871
+ const cutoff = lockExpiryCutoff();
8872
+ const timestamp = now();
8873
+ const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
8874
+ WHERE id = ? AND status NOT IN ('completed', 'cancelled') AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
8875
+ if (result.changes === 0) {
8876
+ const current = getTask(id, d);
8877
+ if (!current)
8878
+ throw new TaskNotFoundError(id);
8879
+ if (current.status === "completed" || current.status === "cancelled") {
8880
+ return {
8881
+ success: false,
8882
+ error: `Task is ${current.status} and cannot be locked`
8883
+ };
8884
+ }
8885
+ if (current.locked_by && !isLockExpired(current.locked_at)) {
8886
+ return {
8887
+ success: false,
8888
+ locked_by: current.locked_by,
8889
+ locked_at: current.locked_at,
8890
+ error: `Task is locked by ${current.locked_by}`
8891
+ };
8892
+ }
8893
+ return {
8894
+ success: false,
8895
+ error: `Task ${id} could not be locked because it changed during lock acquisition`
8896
+ };
8847
8897
  }
8848
- const truthyMatch = trimmed.match(/^\{([^}]+)\}$/);
8849
- if (truthyMatch) {
8850
- const varName = truthyMatch[1];
8851
- const val = variables[varName];
8852
- return !!val && val !== "" && val !== "false";
8898
+ logTaskChange(id, "lock", "locked_by", task.locked_by, agentId, agentId, d);
8899
+ return { success: true, locked_by: agentId, locked_at: timestamp, expires_at: lockExpiresAt(timestamp) };
8900
+ }
8901
+ function unlockTask(id, agentId, db) {
8902
+ const d = db || getDatabase();
8903
+ const task = getTask(id, d);
8904
+ if (!task)
8905
+ throw new TaskNotFoundError(id);
8906
+ if (agentId && task.locked_by && task.locked_by !== agentId) {
8907
+ throw new LockError(id, task.locked_by);
8853
8908
  }
8909
+ const timestamp = now();
8910
+ d.run(`UPDATE tasks SET locked_by = NULL, locked_at = NULL, version = version + 1, updated_at = ?
8911
+ WHERE id = ?`, [timestamp, id]);
8854
8912
  return true;
8855
8913
  }
8856
- function exportTemplate(id, db) {
8914
+ function getTaskLockStatus(id, db) {
8857
8915
  const d = db || getDatabase();
8858
- const template = getTemplateWithTasks(id, d);
8859
- if (!template)
8860
- throw new Error(`Template not found: ${id}`);
8916
+ const task = getTask(id, d);
8917
+ if (!task)
8918
+ throw new TaskNotFoundError(id);
8919
+ const expired = isLockExpired(task.locked_at);
8861
8920
  return {
8862
- name: template.name,
8863
- title_pattern: template.title_pattern,
8864
- description: template.description,
8865
- priority: template.priority,
8866
- tags: template.tags,
8867
- variables: template.variables,
8868
- project_id: template.project_id,
8869
- plan_id: template.plan_id,
8870
- metadata: template.metadata,
8871
- tasks: template.tasks.map((t) => ({
8872
- position: t.position,
8873
- title_pattern: t.title_pattern,
8874
- description: t.description,
8875
- priority: t.priority,
8876
- tags: t.tags,
8877
- task_type: t.task_type,
8878
- condition: t.condition,
8879
- include_template_id: t.include_template_id,
8880
- depends_on_positions: t.depends_on_positions,
8881
- metadata: t.metadata
8882
- }))
8921
+ task_id: id,
8922
+ locked: !!task.locked_by && !expired,
8923
+ locked_by: task.locked_by,
8924
+ locked_at: task.locked_at,
8925
+ expires_at: lockExpiresAt(task.locked_at),
8926
+ expired
8883
8927
  };
8884
8928
  }
8885
- function importTemplate(json, db) {
8886
- const d = db || getDatabase();
8887
- const taskInputs = (json.tasks || []).map((t) => ({
8888
- title_pattern: t.title_pattern,
8889
- description: t.description ?? undefined,
8890
- priority: t.priority,
8891
- tags: t.tags,
8892
- task_type: t.task_type ?? undefined,
8893
- condition: t.condition ?? undefined,
8894
- include_template_id: t.include_template_id ?? undefined,
8895
- depends_on: t.depends_on_positions,
8896
- metadata: t.metadata
8897
- }));
8898
- return createTemplate({
8899
- name: json.name,
8900
- title_pattern: json.title_pattern,
8901
- description: json.description ?? undefined,
8902
- priority: json.priority,
8903
- tags: json.tags,
8904
- variables: json.variables,
8905
- project_id: json.project_id ?? undefined,
8906
- plan_id: json.plan_id ?? undefined,
8907
- metadata: json.metadata,
8908
- tasks: taskInputs
8909
- }, d);
8910
- }
8911
- function getTemplateVersion(id, version, db) {
8912
- const d = db || getDatabase();
8913
- const resolved = resolveTemplateId(id, d);
8914
- if (!resolved)
8915
- return null;
8916
- const row = d.query("SELECT * FROM template_versions WHERE template_id = ? AND version = ?").get(resolved, version);
8917
- return row || null;
8918
- }
8919
- function listTemplateVersions(id, db) {
8929
+ function claimNextTask(agentId, filters, db) {
8920
8930
  const d = db || getDatabase();
8921
- const resolved = resolveTemplateId(id, d);
8922
- if (!resolved)
8923
- return [];
8924
- return d.query("SELECT * FROM template_versions WHERE template_id = ? ORDER BY version DESC").all(resolved);
8931
+ const MAX_ATTEMPTS = 25;
8932
+ const tried = new Set;
8933
+ for (let attempt = 0;attempt < MAX_ATTEMPTS; attempt++) {
8934
+ const outcome = d.transaction(() => {
8935
+ const task = getNextTask(agentId, filters, d);
8936
+ if (!task)
8937
+ return { done: true, task: null };
8938
+ if (tried.has(task.id))
8939
+ return { done: true, task: null };
8940
+ tried.add(task.id);
8941
+ try {
8942
+ return { done: true, task: startTask(task.id, agentId, d) };
8943
+ } catch {
8944
+ return { done: false, task: null };
8945
+ }
8946
+ })();
8947
+ if (outcome.done)
8948
+ return outcome.task;
8949
+ }
8950
+ return null;
8925
8951
  }
8926
- function resolveVariables(templateVars, provided) {
8927
- const merged = { ...provided };
8928
- for (const v of templateVars) {
8929
- if (merged[v.name] === undefined && v.default !== undefined) {
8930
- merged[v.name] = v.default;
8931
- }
8952
+ function getNextTask(agentId, filters, db) {
8953
+ const d = db || getDatabase();
8954
+ clearExpiredLocks(d);
8955
+ const conditions = ["status = 'pending'", "(locked_by IS NULL OR locked_at < ?)"];
8956
+ const params = [lockExpiryCutoff()];
8957
+ if (filters?.project_id) {
8958
+ conditions.push("project_id = ?");
8959
+ params.push(filters.project_id);
8932
8960
  }
8933
- const missing = [];
8934
- for (const v of templateVars) {
8935
- if (v.required && merged[v.name] === undefined) {
8936
- missing.push(v.name);
8937
- }
8961
+ if (filters?.task_list_id) {
8962
+ conditions.push("task_list_id = ?");
8963
+ params.push(filters.task_list_id);
8938
8964
  }
8939
- if (missing.length > 0) {
8940
- throw new Error(`Missing required template variable(s): ${missing.join(", ")}`);
8965
+ if (filters?.plan_id) {
8966
+ conditions.push("plan_id = ?");
8967
+ params.push(filters.plan_id);
8941
8968
  }
8942
- return merged;
8969
+ if (filters?.tags && filters.tags.length > 0) {
8970
+ const placeholders = filters.tags.map(() => "?").join(",");
8971
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
8972
+ params.push(...filters.tags);
8973
+ }
8974
+ conditions.push("id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')");
8975
+ const where = conditions.join(" AND ");
8976
+ let recentProjectIds = [];
8977
+ if (agentId) {
8978
+ const recentRows = d.query(`SELECT DISTINCT project_id FROM tasks WHERE assigned_to = ? AND status = 'completed' AND project_id IS NOT NULL ORDER BY completed_at DESC LIMIT 3`).all(agentId);
8979
+ recentProjectIds = recentRows.map((r) => r.project_id);
8980
+ }
8981
+ let sql = `SELECT * FROM tasks WHERE ${where} ORDER BY `;
8982
+ if (agentId) {
8983
+ sql += `CASE WHEN assigned_to = ? THEN 0 WHEN assigned_to IS NULL THEN 1 ELSE 2 END, `;
8984
+ params.push(agentId);
8985
+ }
8986
+ if (recentProjectIds.length > 0) {
8987
+ const placeholders = recentProjectIds.map(() => "?").join(",");
8988
+ sql += `CASE WHEN project_id IN (${placeholders}) THEN 0 ELSE 1 END, `;
8989
+ params.push(...recentProjectIds);
8990
+ }
8991
+ sql += `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, created_at ASC LIMIT 1`;
8992
+ const row = d.query(sql).get(...params);
8993
+ return row ? rowToTask(row) : null;
8943
8994
  }
8944
- function substituteVars(text, variables) {
8945
- let result = text;
8946
- for (const [key, val] of Object.entries(variables)) {
8947
- result = result.replace(new RegExp(`\\{${key}\\}`, "g"), val);
8995
+ function getActiveWork(filters, db) {
8996
+ const d = db || getDatabase();
8997
+ clearExpiredLocks(d);
8998
+ const conditions = ["status = 'in_progress'"];
8999
+ const params = [];
9000
+ if (filters?.project_id) {
9001
+ conditions.push("project_id = ?");
9002
+ params.push(filters.project_id);
8948
9003
  }
8949
- return result;
9004
+ if (filters?.task_list_id) {
9005
+ conditions.push("task_list_id = ?");
9006
+ params.push(filters.task_list_id);
9007
+ }
9008
+ const where = conditions.join(" AND ");
9009
+ const rows = d.query(`SELECT id, short_id, title, priority, assigned_to, locked_by, locked_at, updated_at FROM tasks WHERE ${where} ORDER BY
9010
+ CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
9011
+ updated_at DESC`).all(...params);
9012
+ return rows;
8950
9013
  }
8951
- function tasksFromTemplate(templateId, projectId, variables, taskListId, db, _visitedTemplateIds) {
9014
+ function getTasksChangedSince(since, filters, db) {
8952
9015
  const d = db || getDatabase();
8953
- const template = getTemplateWithTasks(templateId, d);
8954
- if (!template)
8955
- throw new Error(`Template not found: ${templateId}`);
8956
- const visited = _visitedTemplateIds || new Set;
8957
- if (visited.has(template.id)) {
8958
- throw new Error(`Circular template reference detected: ${template.id}`);
9016
+ const conditions = ["updated_at > ?"];
9017
+ const params = [since];
9018
+ if (filters?.project_id) {
9019
+ conditions.push("project_id = ?");
9020
+ params.push(filters.project_id);
8959
9021
  }
8960
- visited.add(template.id);
8961
- const resolved = resolveVariables(template.variables, variables);
8962
- if (template.tasks.length === 0) {
8963
- const input = taskFromTemplate(templateId, { project_id: projectId, task_list_id: taskListId }, d);
8964
- const task = createTask(input, d);
8965
- return [task];
9022
+ if (filters?.task_list_id) {
9023
+ conditions.push("task_list_id = ?");
9024
+ params.push(filters.task_list_id);
8966
9025
  }
8967
- const createdTasks = [];
8968
- const positionToId = new Map;
8969
- const skippedPositions = new Set;
8970
- for (const tt of template.tasks) {
8971
- if (tt.include_template_id) {
8972
- const includedTasks = tasksFromTemplate(tt.include_template_id, projectId, resolved, taskListId, d, visited);
8973
- createdTasks.push(...includedTasks);
8974
- if (includedTasks.length > 0) {
8975
- positionToId.set(tt.position, includedTasks[0].id);
8976
- } else {
8977
- skippedPositions.add(tt.position);
8978
- }
8979
- continue;
9026
+ const where = conditions.join(" AND ");
9027
+ const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at DESC`).all(...params);
9028
+ return rows.map(rowToTask);
9029
+ }
9030
+ function failTask(id, agentId, reason, options, db) {
9031
+ const d = db || getDatabase();
9032
+ const databasePath = databasePathFromDatabase(d);
9033
+ const task = getTask(id, d);
9034
+ if (!task)
9035
+ throw new TaskNotFoundError(id);
9036
+ const meta = {
9037
+ ...task.metadata,
9038
+ _failure: {
9039
+ reason: reason || "Unknown failure",
9040
+ error_code: options?.error_code || null,
9041
+ failed_by: agentId || null,
9042
+ failed_at: now(),
9043
+ retry_requested: options?.retry || false
8980
9044
  }
8981
- if (tt.condition && !evaluateCondition(tt.condition, resolved)) {
8982
- skippedPositions.add(tt.position);
8983
- continue;
9045
+ };
9046
+ const timestamp = now();
9047
+ const failTx = d.transaction(() => {
9048
+ const res = d.run(`UPDATE tasks SET status = 'failed', locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
9049
+ WHERE id = ? AND version = ?`, [JSON.stringify(meta), timestamp, id, task.version]);
9050
+ if (res.changes === 0) {
9051
+ const current = getTask(id, d);
9052
+ throw new VersionConflictError(id, task.version, current?.version ?? -1);
8984
9053
  }
8985
- let title = tt.title_pattern;
8986
- let desc = tt.description;
8987
- title = substituteVars(title, resolved);
8988
- if (desc)
8989
- desc = substituteVars(desc, resolved);
8990
- const task = createTask({
8991
- title,
8992
- description: desc ?? undefined,
8993
- priority: tt.priority,
8994
- tags: tt.tags,
8995
- task_type: tt.task_type ?? undefined,
8996
- project_id: projectId,
8997
- task_list_id: taskListId,
8998
- metadata: tt.metadata
8999
- }, d);
9000
- createdTasks.push(task);
9001
- positionToId.set(tt.position, task.id);
9002
- }
9003
- for (const tt of template.tasks) {
9004
- if (skippedPositions.has(tt.position))
9005
- continue;
9006
- if (tt.include_template_id)
9007
- continue;
9008
- const deps = tt.depends_on_positions;
9009
- for (const depPos of deps) {
9010
- if (skippedPositions.has(depPos))
9011
- continue;
9012
- const taskId = positionToId.get(tt.position);
9013
- const depId = positionToId.get(depPos);
9014
- if (taskId && depId) {
9015
- addDependency(taskId, depId, d);
9054
+ });
9055
+ failTx();
9056
+ const failedTask = {
9057
+ ...task,
9058
+ status: "failed",
9059
+ locked_by: null,
9060
+ locked_at: null,
9061
+ metadata: meta,
9062
+ version: task.version + 1,
9063
+ updated_at: timestamp
9064
+ };
9065
+ logTaskChange(id, "fail", "status", task.status, "failed", agentId || null, d);
9066
+ const failurePayload = taskEventData(failedTask, { reason, error_code: options?.error_code, agent_id: agentId });
9067
+ dispatchWebhook2("task.failed", failurePayload, d).catch(() => {});
9068
+ emitLocalEventHooksQuiet({ type: "task.failed", payload: failurePayload, databasePath });
9069
+ emitSharedTaskEventQuiet({ type: "task.failed", task: failedTask, data: { reason, error_code: options?.error_code, agent_id: agentId }, severity: "warning", databasePath });
9070
+ let retryTask;
9071
+ if (options?.retry) {
9072
+ const retryCount = (task.retry_count || 0) + 1;
9073
+ const maxRetries = task.max_retries || 3;
9074
+ if (retryCount > maxRetries) {
9075
+ d.run("UPDATE tasks SET metadata = ? WHERE id = ?", [
9076
+ JSON.stringify({ ...meta, _retry_exhausted: { retry_count: retryCount - 1, max_retries: maxRetries } }),
9077
+ id
9078
+ ]);
9079
+ } else {
9080
+ const backoffMinutes = Math.pow(5, retryCount - 1);
9081
+ const retryAfter = options.retry_after || new Date(Date.now() + backoffMinutes * 60 * 1000).toISOString();
9082
+ let title = task.title;
9083
+ if (task.short_id && title.startsWith(task.short_id + ": ")) {
9084
+ title = title.slice(task.short_id.length + 2);
9016
9085
  }
9086
+ retryTask = createTask({
9087
+ title,
9088
+ description: task.description ?? undefined,
9089
+ priority: task.priority,
9090
+ project_id: task.project_id ?? undefined,
9091
+ task_list_id: task.task_list_id ?? undefined,
9092
+ plan_id: task.plan_id ?? undefined,
9093
+ assigned_to: task.assigned_to ?? undefined,
9094
+ tags: task.tags,
9095
+ metadata: { ...task.metadata, _retry: { original_id: task.id, retry_count: retryCount, max_retries: maxRetries, retry_after: retryAfter, failure_reason: reason } },
9096
+ estimated_minutes: task.estimated_minutes ?? undefined,
9097
+ recurrence_rule: task.recurrence_rule ?? undefined,
9098
+ due_at: retryAfter
9099
+ }, d);
9100
+ d.run("UPDATE tasks SET retry_count = ?, max_retries = ?, retry_after = ? WHERE id = ?", [retryCount, maxRetries, retryAfter, retryTask.id]);
9017
9101
  }
9018
9102
  }
9019
- return createdTasks;
9103
+ return { task: failedTask, retryTask };
9020
9104
  }
9021
- function previewTemplate(templateId, variables, db) {
9105
+ function getStaleTasks(staleQuery = 30, filters, db) {
9022
9106
  const d = db || getDatabase();
9023
- const template = getTemplateWithTasks(templateId, d);
9024
- if (!template)
9025
- throw new Error(`Template not found: ${templateId}`);
9026
- const resolved = resolveVariables(template.variables, variables);
9027
- const tasks = [];
9028
- if (template.tasks.length === 0) {
9029
- tasks.push({
9030
- position: 0,
9031
- title: substituteVars(template.title_pattern, resolved),
9032
- description: template.description ? substituteVars(template.description, resolved) : null,
9033
- priority: template.priority,
9034
- tags: template.tags,
9035
- task_type: null,
9036
- depends_on_positions: []
9037
- });
9038
- } else {
9039
- for (const tt of template.tasks) {
9040
- if (tt.condition && !evaluateCondition(tt.condition, resolved))
9041
- continue;
9042
- tasks.push({
9043
- position: tt.position,
9044
- title: substituteVars(tt.title_pattern, resolved),
9045
- description: tt.description ? substituteVars(tt.description, resolved) : null,
9046
- priority: tt.priority,
9047
- tags: tt.tags,
9048
- task_type: tt.task_type,
9049
- depends_on_positions: tt.depends_on_positions
9050
- });
9107
+ const staleMinutes = typeof staleQuery === "number" ? staleQuery : staleQuery.minutes ?? (staleQuery.hours !== undefined ? staleQuery.hours * 60 : 30);
9108
+ const effectiveFilters = typeof staleQuery === "number" ? filters : { project_id: staleQuery.project_id, task_list_id: staleQuery.task_list_id };
9109
+ const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
9110
+ const conditions = [
9111
+ "status = 'in_progress'",
9112
+ "(updated_at < ? OR (locked_at IS NOT NULL AND locked_at < ?))"
9113
+ ];
9114
+ const params = [cutoff, cutoff];
9115
+ if (effectiveFilters?.project_id) {
9116
+ conditions.push("project_id = ?");
9117
+ params.push(effectiveFilters.project_id);
9118
+ }
9119
+ if (effectiveFilters?.task_list_id) {
9120
+ conditions.push("task_list_id = ?");
9121
+ params.push(effectiveFilters.task_list_id);
9122
+ }
9123
+ const where = conditions.join(" AND ");
9124
+ const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at ASC`).all(...params);
9125
+ return rows.map(rowToTask);
9126
+ }
9127
+ function stealTask(agentId, opts, db) {
9128
+ const d = db || getDatabase();
9129
+ const databasePath = databasePathFromDatabase(d);
9130
+ const staleMinutes = opts?.stale_minutes ?? 30;
9131
+ const staleTasks = getStaleTasks(staleMinutes, { project_id: opts?.project_id, task_list_id: opts?.task_list_id }, d);
9132
+ if (staleTasks.length === 0)
9133
+ return null;
9134
+ const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
9135
+ staleTasks.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
9136
+ const target = staleTasks[0];
9137
+ const timestamp = now();
9138
+ const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
9139
+ const result = d.run(`UPDATE tasks SET assigned_to = ?, locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
9140
+ WHERE id = ? AND status = 'in_progress' AND (updated_at < ? OR (locked_at IS NOT NULL AND locked_at < ?))`, [agentId, agentId, timestamp, timestamp, target.id, cutoff, cutoff]);
9141
+ if (result.changes === 0)
9142
+ return null;
9143
+ logTaskChange(target.id, "steal", "assigned_to", target.assigned_to, agentId, agentId, d);
9144
+ logTaskChange(target.id, "steal", "locked_by", target.locked_by, agentId, agentId, d);
9145
+ const stolenTask = { ...target, assigned_to: agentId, locked_by: agentId, locked_at: timestamp, updated_at: timestamp, version: target.version + 1 };
9146
+ const payload = taskEventData(stolenTask, { agent_id: agentId, stolen_from: target.assigned_to });
9147
+ dispatchWebhook2("task.assigned", payload, d).catch(() => {});
9148
+ emitLocalEventHooksQuiet({ type: "task.assigned", payload, databasePath });
9149
+ emitSharedTaskEventQuiet({ type: "task.assigned", task: stolenTask, data: { agent_id: agentId, stolen_from: target.assigned_to }, databasePath });
9150
+ return stolenTask;
9151
+ }
9152
+ function claimOrSteal(agentId, filters, db) {
9153
+ const d = db || getDatabase();
9154
+ const tx = d.transaction(() => {
9155
+ const next = getNextTask(agentId, filters, d);
9156
+ if (next) {
9157
+ const started = startTask(next.id, agentId, d);
9158
+ return { task: started, stolen: false };
9051
9159
  }
9160
+ const stolen = stealTask(agentId, { stale_minutes: filters?.stale_minutes, project_id: filters?.project_id, task_list_id: filters?.task_list_id }, d);
9161
+ if (stolen)
9162
+ return { task: stolen, stolen: true };
9163
+ return null;
9164
+ });
9165
+ return tx();
9166
+ }
9167
+ function spawnNextRecurrence(completedTask, db, completedAt) {
9168
+ const recurrenceBase = completedTask.due_at ? new Date(completedTask.due_at) : new Date(completedAt);
9169
+ const dueAt = nextOccurrence(completedTask.recurrence_rule, recurrenceBase);
9170
+ let title = completedTask.title;
9171
+ if (completedTask.short_id && title.startsWith(completedTask.short_id + ": ")) {
9172
+ title = title.slice(completedTask.short_id.length + 2);
9052
9173
  }
9053
- return {
9054
- template_id: template.id,
9055
- template_name: template.name,
9056
- description: template.description,
9057
- variables: template.variables,
9058
- resolved_variables: resolved,
9059
- tasks
9060
- };
9174
+ const recurrenceParentId = completedTask.recurrence_parent_id || completedTask.id;
9175
+ return createTask({
9176
+ title,
9177
+ description: completedTask.description ?? undefined,
9178
+ priority: completedTask.priority,
9179
+ project_id: completedTask.project_id ?? undefined,
9180
+ task_list_id: completedTask.task_list_id ?? undefined,
9181
+ plan_id: completedTask.plan_id ?? undefined,
9182
+ assigned_to: completedTask.assigned_to ?? undefined,
9183
+ tags: completedTask.tags,
9184
+ metadata: completedTask.metadata,
9185
+ estimated_minutes: completedTask.estimated_minutes ?? undefined,
9186
+ sla_minutes: completedTask.sla_minutes ?? undefined,
9187
+ recurrence_rule: completedTask.recurrence_rule,
9188
+ recurrence_parent_id: recurrenceParentId,
9189
+ due_at: dueAt
9190
+ }, db);
9061
9191
  }
9062
- var init_templates = __esm(() => {
9192
+ var MAX_SPAWN_DEPTH = 10;
9193
+ var init_task_lifecycle = __esm(() => {
9194
+ init_types();
9063
9195
  init_database();
9064
- init_tasks();
9065
- init_storage_tombstones();
9196
+ init_completion_guard();
9197
+ init_event_emission_safety();
9198
+ init_event_hooks();
9199
+ init_shared_events();
9200
+ init_audit();
9201
+ init_recurrence();
9202
+ init_webhooks();
9203
+ init_templates();
9204
+ init_task_crud();
9205
+ init_task_graph();
9066
9206
  });
9067
9207
 
9068
- // src/db/task-graph.ts
9069
- function addDependency(taskId, dependsOn, db) {
9070
- const d = db || getDatabase();
9071
- if (!getTask(taskId, d))
9072
- throw new TaskNotFoundError(taskId);
9073
- if (!getTask(dependsOn, d))
9074
- throw new TaskNotFoundError(dependsOn);
9075
- if (wouldCreateCycle(taskId, dependsOn, d)) {
9076
- throw new DependencyCycleError(taskId, dependsOn);
9208
+ // src/db/task-crud.ts
9209
+ function rowToTask(row) {
9210
+ return {
9211
+ ...row,
9212
+ tags: JSON.parse(row.tags || "[]"),
9213
+ metadata: JSON.parse(row.metadata || "{}"),
9214
+ status: row.status,
9215
+ priority: row.priority,
9216
+ requires_approval: !!row.requires_approval
9217
+ };
9218
+ }
9219
+ function insertTaskTags(taskId, tags, db) {
9220
+ if (tags.length === 0)
9221
+ return;
9222
+ const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
9223
+ for (const tag of tags) {
9224
+ if (tag)
9225
+ stmt.run(taskId, tag);
9077
9226
  }
9078
- d.run("INSERT OR IGNORE INTO task_dependencies (task_id, depends_on) VALUES (?, ?)", [taskId, dependsOn]);
9079
9227
  }
9080
- function removeDependency(taskId, dependsOn, db) {
9081
- const d = db || getDatabase();
9082
- const result = d.run("DELETE FROM task_dependencies WHERE task_id = ? AND depends_on = ?", [taskId, dependsOn]);
9083
- return result.changes > 0;
9228
+ function replaceTaskTags(taskId, tags, db) {
9229
+ db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
9230
+ insertTaskTags(taskId, tags, db);
9084
9231
  }
9085
- function getTaskDependencies(taskId, db) {
9232
+ function addMetadataConditions(metadata, conditions, params) {
9233
+ if (!metadata)
9234
+ return;
9235
+ for (const [key, value] of Object.entries(metadata)) {
9236
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
9237
+ throw new Error(`Invalid metadata filter key: ${key}`);
9238
+ }
9239
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
9240
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
9241
+ }
9242
+ }
9243
+ function createTask(input, db) {
9086
9244
  const d = db || getDatabase();
9087
- return d.query("SELECT * FROM task_dependencies WHERE task_id = ?").all(taskId);
9245
+ const timestamp = now();
9246
+ const tags = input.tags || [];
9247
+ const machineId = currentStorageMachineId(d);
9248
+ const assignedBy = input.assigned_by || input.agent_id;
9249
+ const assignedFromProject = input.assigned_from_project || null;
9250
+ let id = uuid();
9251
+ for (let attempt = 0;attempt < 3; attempt++) {
9252
+ try {
9253
+ d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, cycle_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, sla_minutes, confidence, retry_count, max_retries, retry_after, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id, spawns_template_id, reason, spawned_from_session, assigned_by, assigned_from_project, task_type, machine_id)
9254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
9255
+ id,
9256
+ null,
9257
+ input.project_id || null,
9258
+ input.parent_id || null,
9259
+ input.plan_id || null,
9260
+ input.task_list_id || null,
9261
+ input.cycle_id || null,
9262
+ input.title,
9263
+ input.description || null,
9264
+ input.status || "pending",
9265
+ input.priority || "medium",
9266
+ input.agent_id || null,
9267
+ input.assigned_to || null,
9268
+ input.session_id || null,
9269
+ input.working_dir || null,
9270
+ JSON.stringify(tags),
9271
+ JSON.stringify(input.metadata || {}),
9272
+ timestamp,
9273
+ timestamp,
9274
+ input.due_at || null,
9275
+ input.estimated_minutes || null,
9276
+ input.sla_minutes ?? null,
9277
+ input.confidence ?? null,
9278
+ input.retry_count ?? 0,
9279
+ input.max_retries ?? 3,
9280
+ input.retry_after ?? null,
9281
+ input.requires_approval ? 1 : 0,
9282
+ null,
9283
+ null,
9284
+ input.recurrence_rule || null,
9285
+ input.recurrence_parent_id || null,
9286
+ input.spawns_template_id || null,
9287
+ input.reason || null,
9288
+ input.spawned_from_session || null,
9289
+ assignedBy || null,
9290
+ assignedFromProject || null,
9291
+ input.task_type || null,
9292
+ machineId
9293
+ ]);
9294
+ break;
9295
+ } catch (e) {
9296
+ if (attempt < 2 && e?.message?.includes("UNIQUE constraint failed: tasks.id")) {
9297
+ id = uuid();
9298
+ continue;
9299
+ }
9300
+ throw e;
9301
+ }
9302
+ }
9303
+ if (tags.length > 0) {
9304
+ insertTaskTags(id, tags, d);
9305
+ }
9306
+ const task = getTask(id, d);
9307
+ const payload = taskEventData(task);
9308
+ const databasePath = databasePathFromDatabase(d);
9309
+ dispatchWebhook2("task.created", payload, d).catch(() => {});
9310
+ emitLocalEventHooksQuiet({ type: "task.created", payload, databasePath });
9311
+ emitSharedTaskEventQuiet({ type: "task.created", task, databasePath });
9312
+ return task;
9088
9313
  }
9089
- function getTaskDependents(taskId, db) {
9314
+ function getTask(id, db) {
9090
9315
  const d = db || getDatabase();
9091
- return d.query("SELECT * FROM task_dependencies WHERE depends_on = ?").all(taskId);
9316
+ const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
9317
+ if (!row)
9318
+ return null;
9319
+ return rowToTask(row);
9092
9320
  }
9093
- function cloneTask(taskId, overrides, db) {
9321
+ function getTaskWithRelations(id, db) {
9094
9322
  const d = db || getDatabase();
9095
- const source = getTask(taskId, d);
9096
- if (!source)
9097
- throw new TaskNotFoundError(taskId);
9098
- const input = {
9099
- title: overrides?.title ?? source.title,
9100
- description: overrides?.description ?? source.description ?? undefined,
9101
- priority: overrides?.priority ?? source.priority,
9102
- project_id: overrides?.project_id ?? source.project_id ?? undefined,
9103
- parent_id: overrides?.parent_id ?? source.parent_id ?? undefined,
9104
- plan_id: overrides?.plan_id ?? source.plan_id ?? undefined,
9105
- task_list_id: overrides?.task_list_id ?? source.task_list_id ?? undefined,
9106
- status: overrides?.status ?? "pending",
9107
- agent_id: overrides?.agent_id ?? source.agent_id ?? undefined,
9108
- assigned_to: overrides?.assigned_to ?? source.assigned_to ?? undefined,
9109
- tags: overrides?.tags ?? source.tags,
9110
- metadata: overrides?.metadata ?? source.metadata,
9111
- estimated_minutes: overrides?.estimated_minutes ?? source.estimated_minutes ?? undefined,
9112
- recurrence_rule: overrides?.recurrence_rule ?? source.recurrence_rule ?? undefined
9323
+ const task = getTask(id, d);
9324
+ if (!task)
9325
+ return null;
9326
+ const subtaskRows = d.query("SELECT * FROM tasks WHERE parent_id = ? ORDER BY created_at").all(id);
9327
+ const subtasks = subtaskRows.map(rowToTask);
9328
+ const depRows = d.query(`SELECT t.* FROM tasks t
9329
+ JOIN task_dependencies td ON td.depends_on = t.id
9330
+ WHERE td.task_id = ?`).all(id);
9331
+ const dependencies = depRows.map(rowToTask);
9332
+ const blockedByRows = d.query(`SELECT t.* FROM tasks t
9333
+ JOIN task_dependencies td ON td.task_id = t.id
9334
+ WHERE td.depends_on = ?`).all(id);
9335
+ const blocked_by = blockedByRows.map(rowToTask);
9336
+ const comments = d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(id);
9337
+ const parent = task.parent_id ? getTask(task.parent_id, d) : null;
9338
+ const checklist = getChecklist(id, d);
9339
+ return {
9340
+ ...task,
9341
+ subtasks,
9342
+ dependencies,
9343
+ blocked_by,
9344
+ comments,
9345
+ parent,
9346
+ checklist
9113
9347
  };
9114
- return createTask(input, d);
9115
9348
  }
9116
- function getTaskGraph(taskId, direction = "both", db) {
9349
+ function listTasks(filter = {}, db) {
9117
9350
  const d = db || getDatabase();
9118
- const task = getTask(taskId, d);
9119
- if (!task)
9120
- throw new TaskNotFoundError(taskId);
9121
- function toNode(t) {
9122
- const deps = getTaskDependencies(t.id, d);
9123
- const hasUnfinishedDeps = deps.some((dep) => {
9124
- const depTask = getTask(dep.depends_on, d);
9125
- return depTask && depTask.status !== "completed";
9126
- });
9127
- return { id: t.id, short_id: t.short_id, title: t.title, status: t.status, priority: t.priority, is_blocked: hasUnfinishedDeps };
9351
+ const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
9352
+ clearExpiredLocks2(d);
9353
+ const conditions = [];
9354
+ const params = [];
9355
+ if (filter.project_id) {
9356
+ conditions.push("project_id = ?");
9357
+ params.push(filter.project_id);
9128
9358
  }
9129
- function buildUp(id, visited) {
9130
- if (visited.has(id))
9131
- return [];
9132
- visited.add(id);
9133
- const deps = d.query("SELECT depends_on FROM task_dependencies WHERE task_id = ?").all(id);
9134
- return deps.map((dep) => {
9135
- const depTask = getTask(dep.depends_on, d);
9136
- if (!depTask)
9137
- return null;
9138
- return { task: toNode(depTask), depends_on: buildUp(dep.depends_on, visited), blocks: [] };
9139
- }).filter(Boolean);
9359
+ if (filter.ids && filter.ids.length > 0) {
9360
+ conditions.push(`id IN (${filter.ids.map(() => "?").join(",")})`);
9361
+ params.push(...filter.ids);
9140
9362
  }
9141
- function buildDown(id, visited) {
9142
- if (visited.has(id))
9143
- return [];
9144
- visited.add(id);
9145
- const dependents = d.query("SELECT task_id FROM task_dependencies WHERE depends_on = ?").all(id);
9146
- return dependents.map((dep) => {
9147
- const depTask = getTask(dep.task_id, d);
9148
- if (!depTask)
9149
- return null;
9150
- return { task: toNode(depTask), depends_on: [], blocks: buildDown(dep.task_id, visited) };
9151
- }).filter(Boolean);
9363
+ if (filter.parent_id !== undefined) {
9364
+ if (filter.parent_id === null) {
9365
+ conditions.push("parent_id IS NULL");
9366
+ } else {
9367
+ conditions.push("parent_id = ?");
9368
+ params.push(filter.parent_id);
9369
+ }
9152
9370
  }
9153
- const rootNode = toNode(task);
9154
- const depends_on = direction === "up" || direction === "both" ? buildUp(taskId, new Set) : [];
9155
- const blocks = direction === "down" || direction === "both" ? buildDown(taskId, new Set) : [];
9156
- return { task: rootNode, depends_on, blocks };
9157
- }
9158
- function moveTask(taskId, target, db) {
9159
- const d = db || getDatabase();
9160
- const task = getTask(taskId, d);
9161
- if (!task)
9162
- throw new TaskNotFoundError(taskId);
9163
- const sets = ["updated_at = ?", "version = version + 1"];
9164
- const params = [now()];
9165
- if (target.task_list_id !== undefined) {
9166
- sets.push("task_list_id = ?");
9167
- params.push(target.task_list_id);
9371
+ if (filter.status) {
9372
+ if (Array.isArray(filter.status)) {
9373
+ conditions.push(`status IN (${filter.status.map(() => "?").join(",")})`);
9374
+ params.push(...filter.status);
9375
+ } else {
9376
+ conditions.push("status = ?");
9377
+ params.push(filter.status);
9378
+ }
9168
9379
  }
9169
- if (target.project_id !== undefined) {
9170
- sets.push("project_id = ?");
9171
- params.push(target.project_id);
9380
+ if (filter.priority) {
9381
+ if (Array.isArray(filter.priority)) {
9382
+ conditions.push(`priority IN (${filter.priority.map(() => "?").join(",")})`);
9383
+ params.push(...filter.priority);
9384
+ } else {
9385
+ conditions.push("priority = ?");
9386
+ params.push(filter.priority);
9387
+ }
9172
9388
  }
9173
- if (target.plan_id !== undefined) {
9174
- sets.push("plan_id = ?");
9175
- params.push(target.plan_id);
9389
+ if (filter.assigned_to) {
9390
+ conditions.push("assigned_to = ?");
9391
+ params.push(filter.assigned_to);
9176
9392
  }
9177
- params.push(taskId);
9178
- d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`, params);
9179
- return getTask(taskId, d);
9180
- }
9181
- function wouldCreateCycle(taskId, dependsOn, db) {
9182
- const visited = new Set;
9183
- const queue = [dependsOn];
9184
- while (queue.length > 0) {
9185
- const current = queue.shift();
9186
- if (current === taskId)
9187
- return true;
9188
- if (visited.has(current))
9189
- continue;
9190
- visited.add(current);
9191
- const deps = db.query("SELECT depends_on FROM task_dependencies WHERE task_id = ?").all(current);
9192
- for (const dep of deps) {
9193
- queue.push(dep.depends_on);
9393
+ if (filter.agent_id) {
9394
+ conditions.push("agent_id = ?");
9395
+ params.push(filter.agent_id);
9396
+ }
9397
+ if (filter.session_id) {
9398
+ conditions.push("session_id = ?");
9399
+ params.push(filter.session_id);
9400
+ }
9401
+ if (filter.tags && filter.tags.length > 0) {
9402
+ const placeholders = filter.tags.map(() => "?").join(",");
9403
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
9404
+ params.push(...filter.tags);
9405
+ }
9406
+ if (filter.plan_id) {
9407
+ conditions.push("plan_id = ?");
9408
+ params.push(filter.plan_id);
9409
+ }
9410
+ if (filter.task_list_id) {
9411
+ conditions.push("task_list_id = ?");
9412
+ params.push(filter.task_list_id);
9413
+ }
9414
+ if (filter.has_recurrence === true) {
9415
+ conditions.push("recurrence_rule IS NOT NULL");
9416
+ } else if (filter.has_recurrence === false) {
9417
+ conditions.push("recurrence_rule IS NULL");
9418
+ }
9419
+ if (filter.task_type) {
9420
+ if (Array.isArray(filter.task_type)) {
9421
+ conditions.push(`task_type IN (${filter.task_type.map(() => "?").join(",")})`);
9422
+ params.push(...filter.task_type);
9423
+ } else {
9424
+ conditions.push("task_type = ?");
9425
+ params.push(filter.task_type);
9194
9426
  }
9195
9427
  }
9196
- return false;
9428
+ addMetadataConditions(filter.metadata, conditions, params);
9429
+ const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
9430
+ if (filter.cursor) {
9431
+ try {
9432
+ const decoded = JSON.parse(Buffer.from(filter.cursor, "base64").toString("utf8"));
9433
+ conditions.push(`(${PRIORITY_RANK} > ? OR (${PRIORITY_RANK} = ? AND created_at < ?) OR (${PRIORITY_RANK} = ? AND created_at = ? AND id > ?))`);
9434
+ params.push(decoded.p, decoded.p, decoded.c, decoded.p, decoded.c, decoded.i);
9435
+ } catch {}
9436
+ }
9437
+ if (!filter.include_archived) {
9438
+ conditions.push("archived_at IS NULL");
9439
+ }
9440
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
9441
+ let limitClause = "";
9442
+ if (filter.limit) {
9443
+ limitClause = " LIMIT ?";
9444
+ params.push(filter.limit);
9445
+ if (!filter.cursor && filter.offset) {
9446
+ limitClause += " OFFSET ?";
9447
+ params.push(filter.offset);
9448
+ }
9449
+ }
9450
+ const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC, id ASC${limitClause}`).all(...params);
9451
+ return rows.map(rowToTask);
9197
9452
  }
9198
- var init_task_graph = __esm(() => {
9199
- init_types();
9200
- init_database();
9201
- init_task_crud();
9202
- });
9203
-
9204
- // src/db/task-lifecycle.ts
9205
- function lockExpiresAt(lockedAt) {
9206
- if (!lockedAt)
9207
- return null;
9208
- return new Date(new Date(lockedAt).getTime() + LOCK_EXPIRY_MINUTES * 60 * 1000).toISOString();
9453
+ function getTaskByFingerprint(fingerprint, db) {
9454
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1, include_archived: true }, db);
9455
+ return tasks[0] ?? null;
9209
9456
  }
9210
- function assertStartable(task, agentId) {
9211
- if (task.status === "pending")
9212
- return;
9213
- if (task.status === "in_progress")
9214
- return;
9215
- throw new Error(`Task is ${task.status} and cannot be started by ${agentId}`);
9457
+ function mergeTaskMetadata(current, next, fingerprint) {
9458
+ return {
9459
+ ...current,
9460
+ ...next ?? {},
9461
+ fingerprint
9462
+ };
9216
9463
  }
9217
- function getBlockingDeps(id, db) {
9464
+ function upsertTaskByFingerprint(input, db) {
9218
9465
  const d = db || getDatabase();
9219
- const deps = getTaskDependencies(id, d);
9220
- if (deps.length === 0)
9221
- return [];
9222
- const blocking = [];
9223
- for (const dep of deps) {
9224
- const task = getTask(dep.depends_on, d);
9225
- if (task && task.status !== "completed")
9226
- blocking.push(task);
9227
- }
9228
- return blocking;
9466
+ const fingerprint = input.fingerprint.trim();
9467
+ if (!fingerprint)
9468
+ throw new Error("fingerprint is required");
9469
+ const tx = d.transaction(() => {
9470
+ const existing = getTaskByFingerprint(fingerprint, d);
9471
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
9472
+ if (!existing) {
9473
+ const task2 = createTask({ ...input, metadata }, d);
9474
+ return { task: task2, created: true };
9475
+ }
9476
+ const task = updateTask(existing.id, {
9477
+ version: existing.version,
9478
+ title: input.title,
9479
+ description: input.description,
9480
+ status: input.status,
9481
+ priority: input.priority,
9482
+ project_id: input.project_id,
9483
+ assigned_to: input.assigned_to,
9484
+ working_dir: input.working_dir,
9485
+ plan_id: input.plan_id,
9486
+ task_list_id: input.task_list_id,
9487
+ tags: input.tags,
9488
+ metadata,
9489
+ due_at: input.due_at,
9490
+ estimated_minutes: input.estimated_minutes,
9491
+ sla_minutes: input.sla_minutes,
9492
+ confidence: input.confidence,
9493
+ retry_count: input.retry_count,
9494
+ max_retries: input.max_retries,
9495
+ retry_after: input.retry_after,
9496
+ requires_approval: input.requires_approval,
9497
+ recurrence_rule: input.recurrence_rule,
9498
+ task_type: input.task_type
9499
+ }, d);
9500
+ return { task, created: false };
9501
+ });
9502
+ return tx();
9229
9503
  }
9230
- function startTask(id, agentId, db) {
9504
+ function countTasks(filter = {}, db) {
9231
9505
  const d = db || getDatabase();
9232
- const databasePath = databasePathFromDatabase(d);
9233
- const task = getTask(id, d);
9234
- if (!task)
9235
- throw new TaskNotFoundError(id);
9236
- assertStartable(task, agentId);
9237
- const blocking = getBlockingDeps(id, d);
9238
- if (blocking.length > 0) {
9239
- const blockerIds = blocking.map((b) => b.id.slice(0, 8)).join(", ");
9240
- emitLocalEventHooksQuiet({
9241
- type: "task.blocked",
9242
- payload: {
9243
- id,
9244
- agent_id: agentId,
9245
- title: task.title,
9246
- blockers: blocking.map((b) => ({ id: b.id, short_id: b.short_id, title: b.title, status: b.status }))
9247
- },
9248
- databasePath
9249
- });
9250
- throw new Error(`Task is blocked by ${blocking.length} unfinished dependency(ies): ${blockerIds}`);
9251
- }
9252
- const cutoff = lockExpiryCutoff();
9253
- const timestamp = now();
9254
- const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, started_at = COALESCE(started_at, ?), version = version + 1, updated_at = ?
9255
- WHERE id = ? AND status IN ('pending', 'in_progress') AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, timestamp, id, agentId, cutoff]);
9256
- if (result.changes === 0) {
9257
- const current = getTask(id, d);
9258
- if (!current)
9259
- throw new TaskNotFoundError(id);
9260
- assertStartable(current, agentId);
9261
- if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
9262
- throw new LockError(id, current.locked_by);
9263
- }
9264
- throw new Error(`Task ${id} could not be started because it changed during claim`);
9506
+ const conditions = [];
9507
+ const params = [];
9508
+ if (filter.project_id) {
9509
+ conditions.push("project_id = ?");
9510
+ params.push(filter.project_id);
9265
9511
  }
9266
- logTaskChange(id, "start", "status", "pending", "in_progress", agentId, d);
9267
- const startedTask = { ...task, status: "in_progress", assigned_to: agentId, locked_by: agentId, locked_at: timestamp, started_at: task.started_at || timestamp, version: task.version + 1, updated_at: timestamp };
9268
- const payload = taskEventData(startedTask, { agent_id: agentId });
9269
- dispatchWebhook2("task.started", payload, d).catch(() => {});
9270
- emitLocalEventHooksQuiet({ type: "task.started", payload, databasePath });
9271
- emitSharedTaskEventQuiet({ type: "task.started", task: startedTask, data: { agent_id: agentId }, databasePath });
9272
- return startedTask;
9273
- }
9274
- function completeTask(id, agentId, db, options) {
9275
- const d = db || getDatabase();
9276
- const databasePath = databasePathFromDatabase(d);
9277
- const task = getTask(id, d);
9278
- if (!task)
9279
- throw new TaskNotFoundError(id);
9280
- if (agentId && task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
9281
- throw new LockError(id, task.locked_by);
9512
+ if (filter.ids && filter.ids.length > 0) {
9513
+ conditions.push(`id IN (${filter.ids.map(() => "?").join(",")})`);
9514
+ params.push(...filter.ids);
9282
9515
  }
9283
- checkCompletionGuard(task, agentId || null, d);
9284
- const evidence = options ? { files_changed: options.files_changed, test_results: options.test_results, commit_hash: options.commit_hash, notes: options.notes, attachment_ids: options.attachment_ids } : undefined;
9285
- const hasEvidence = evidence && (evidence.files_changed || evidence.test_results || evidence.commit_hash || evidence.notes || evidence.attachment_ids);
9286
- const completionMeta = {};
9287
- if (hasEvidence)
9288
- completionMeta._evidence = evidence;
9289
- if (options?.confidence !== undefined) {
9290
- completionMeta._completion = { confidence: options.confidence };
9516
+ if (filter.parent_id !== undefined) {
9517
+ if (filter.parent_id === null) {
9518
+ conditions.push("parent_id IS NULL");
9519
+ } else {
9520
+ conditions.push("parent_id = ?");
9521
+ params.push(filter.parent_id);
9522
+ }
9291
9523
  }
9292
- const hasMeta = Object.keys(completionMeta).length > 0;
9293
- const timestamp = options?.completed_at || now();
9294
- const confidence = options?.confidence !== undefined ? options.confidence : null;
9295
- const tx = d.transaction(() => {
9296
- if (hasMeta) {
9297
- const meta2 = { ...task.metadata, ...completionMeta };
9298
- const metaResult = d.run("UPDATE tasks SET metadata = ?, version = version + 1, updated_at = ? WHERE id = ? AND version = ?", [JSON.stringify(meta2), timestamp, id, task.version]);
9299
- if (metaResult.changes === 0) {
9300
- const current = getTask(id, d);
9301
- throw new VersionConflictError(id, task.version, current?.version ?? -1);
9302
- }
9524
+ if (filter.status) {
9525
+ if (Array.isArray(filter.status)) {
9526
+ conditions.push(`status IN (${filter.status.map(() => "?").join(",")})`);
9527
+ params.push(...filter.status);
9528
+ } else {
9529
+ conditions.push("status = ?");
9530
+ params.push(filter.status);
9303
9531
  }
9304
- d.run(`UPDATE tasks SET status = 'completed', locked_by = NULL, locked_at = NULL, completed_at = ?, confidence = ?, version = version + 1, updated_at = ?
9305
- WHERE id = ?`, [timestamp, confidence, timestamp, id]);
9306
- });
9307
- tx();
9308
- logTaskChange(id, "complete", "status", task.status, "completed", agentId || null, d);
9309
- const completedTaskForEvent = {
9310
- ...task,
9311
- status: "completed",
9312
- locked_by: null,
9313
- locked_at: null,
9314
- completed_at: timestamp,
9315
- confidence,
9316
- version: task.version + 1,
9317
- updated_at: timestamp,
9318
- metadata: hasMeta ? { ...task.metadata, ...completionMeta } : task.metadata
9319
- };
9320
- const completionPayload = taskEventData(completedTaskForEvent, { agent_id: agentId, completed_at: timestamp });
9321
- dispatchWebhook2("task.completed", completionPayload, d).catch(() => {});
9322
- emitLocalEventHooksQuiet({ type: "task.completed", payload: completionPayload, databasePath });
9323
- emitSharedTaskEventQuiet({ type: "task.completed", task: completedTaskForEvent, data: { agent_id: agentId, completed_at: timestamp }, databasePath });
9324
- let spawnedTask = null;
9325
- if (task.recurrence_rule && !options?.skip_recurrence) {
9326
- spawnedTask = spawnNextRecurrence(task, d, timestamp);
9327
9532
  }
9328
- let spawnedFromTemplate = null;
9329
- if (task.spawns_template_id) {
9330
- const spawnDepth = task.metadata?._spawn_depth || 0;
9331
- if (spawnDepth >= MAX_SPAWN_DEPTH) {
9332
- console.warn(`[tasks] Task ${id} exceeded max spawn depth (${MAX_SPAWN_DEPTH}), skipping template spawn`);
9533
+ if (filter.priority) {
9534
+ if (Array.isArray(filter.priority)) {
9535
+ conditions.push(`priority IN (${filter.priority.map(() => "?").join(",")})`);
9536
+ params.push(...filter.priority);
9333
9537
  } else {
9334
- try {
9335
- const input = taskFromTemplate(task.spawns_template_id, {
9336
- project_id: task.project_id ?? undefined,
9337
- plan_id: task.plan_id ?? undefined,
9338
- task_list_id: task.task_list_id ?? undefined,
9339
- assigned_to: task.assigned_to ?? undefined
9340
- }, d);
9341
- input.metadata = { ...input.metadata || {}, _spawn_depth: spawnDepth + 1 };
9342
- spawnedFromTemplate = createTask(input, d);
9343
- } catch {}
9538
+ conditions.push("priority = ?");
9539
+ params.push(filter.priority);
9344
9540
  }
9345
9541
  }
9346
- const meta = hasMeta ? { ...task.metadata, ...completionMeta } : task.metadata;
9347
- if (spawnedTask) {
9348
- meta._next_recurrence = { id: spawnedTask.id, short_id: spawnedTask.short_id, due_at: spawnedTask.due_at };
9542
+ if (filter.assigned_to) {
9543
+ conditions.push("assigned_to = ?");
9544
+ params.push(filter.assigned_to);
9349
9545
  }
9350
- if (spawnedFromTemplate) {
9351
- meta._spawned_task = { id: spawnedFromTemplate.id, short_id: spawnedFromTemplate.short_id, title: spawnedFromTemplate.title };
9546
+ if (filter.agent_id) {
9547
+ conditions.push("agent_id = ?");
9548
+ params.push(filter.agent_id);
9352
9549
  }
9353
- const unblockedDeps = d.query(`SELECT DISTINCT t.id, t.short_id, t.title FROM tasks t
9354
- JOIN task_dependencies td ON td.task_id = t.id
9355
- WHERE td.depends_on = ? AND t.status = 'pending'
9356
- AND NOT EXISTS (
9357
- SELECT 1 FROM task_dependencies td2
9358
- JOIN tasks dep2 ON dep2.id = td2.depends_on
9359
- WHERE td2.task_id = t.id AND dep2.status NOT IN ('completed', 'cancelled') AND dep2.id != ?
9360
- )`).all(id, id);
9361
- if (unblockedDeps.length > 0) {
9362
- meta._unblocked = unblockedDeps.map((d2) => ({ id: d2.id, short_id: d2.short_id, title: d2.title }));
9363
- for (const dep of unblockedDeps) {
9364
- const depTask = getTask(dep.id, d);
9365
- const payload = depTask ? taskEventData(depTask, { unblocked_by: id }) : { id: dep.id, unblocked_by: id, title: dep.title };
9366
- dispatchWebhook2("task.unblocked", payload, d).catch(() => {});
9367
- emitLocalEventHooksQuiet({ type: "task.unblocked", payload, databasePath });
9368
- if (depTask)
9369
- emitSharedTaskEventQuiet({ type: "task.unblocked", task: depTask, data: { unblocked_by: id }, databasePath });
9550
+ if (filter.session_id) {
9551
+ conditions.push("session_id = ?");
9552
+ params.push(filter.session_id);
9553
+ }
9554
+ if (filter.tags && filter.tags.length > 0) {
9555
+ const placeholders = filter.tags.map(() => "?").join(",");
9556
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
9557
+ params.push(...filter.tags);
9558
+ }
9559
+ if (filter.plan_id) {
9560
+ conditions.push("plan_id = ?");
9561
+ params.push(filter.plan_id);
9562
+ }
9563
+ if (filter.task_list_id) {
9564
+ conditions.push("task_list_id = ?");
9565
+ params.push(filter.task_list_id);
9566
+ }
9567
+ if (filter.has_recurrence === true) {
9568
+ conditions.push("recurrence_rule IS NOT NULL");
9569
+ } else if (filter.has_recurrence === false) {
9570
+ conditions.push("recurrence_rule IS NULL");
9571
+ }
9572
+ if (filter.task_type) {
9573
+ if (Array.isArray(filter.task_type)) {
9574
+ conditions.push(`task_type IN (${filter.task_type.map(() => "?").join(",")})`);
9575
+ params.push(...filter.task_type);
9576
+ } else {
9577
+ conditions.push("task_type = ?");
9578
+ params.push(filter.task_type);
9370
9579
  }
9371
9580
  }
9372
- return { ...task, status: "completed", locked_by: null, locked_at: null, completed_at: timestamp, confidence, version: task.version + 1, updated_at: timestamp, metadata: meta };
9581
+ addMetadataConditions(filter.metadata, conditions, params);
9582
+ if (!filter.include_archived) {
9583
+ conditions.push("archived_at IS NULL");
9584
+ }
9585
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
9586
+ const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
9587
+ return row.count;
9373
9588
  }
9374
- function lockTask(id, agentId, db) {
9589
+ function updateTask(id, input, db) {
9375
9590
  const d = db || getDatabase();
9376
9591
  const task = getTask(id, d);
9377
9592
  if (!task)
9378
9593
  throw new TaskNotFoundError(id);
9379
- if (task.status === "completed" || task.status === "cancelled") {
9380
- return {
9381
- success: false,
9382
- error: `Task is ${task.status} and cannot be locked`
9383
- };
9384
- }
9385
- if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
9386
- const timestamp2 = now();
9387
- d.run(`UPDATE tasks SET locked_at = ?, updated_at = ?, version = version + 1 WHERE id = ? AND locked_by = ?`, [timestamp2, timestamp2, id, agentId]);
9388
- logTaskChange(id, "lock_renew", "locked_by", agentId, agentId, agentId, d);
9389
- return { success: true, locked_by: agentId, locked_at: timestamp2, expires_at: lockExpiresAt(timestamp2) };
9594
+ if (task.version !== input.version) {
9595
+ throw new VersionConflictError(id, input.version, task.version);
9390
9596
  }
9391
- const cutoff = lockExpiryCutoff();
9392
9597
  const timestamp = now();
9393
- const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
9394
- WHERE id = ? AND status NOT IN ('completed', 'cancelled') AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
9395
- if (result.changes === 0) {
9396
- const current = getTask(id, d);
9397
- if (!current)
9398
- throw new TaskNotFoundError(id);
9399
- if (current.status === "completed" || current.status === "cancelled") {
9400
- return {
9401
- success: false,
9402
- error: `Task is ${current.status} and cannot be locked`
9403
- };
9598
+ const completionTimestamp = input.completed_at ?? timestamp;
9599
+ const sets = ["version = version + 1", "updated_at = ?"];
9600
+ const params = [timestamp];
9601
+ if (input.title !== undefined) {
9602
+ sets.push("title = ?");
9603
+ params.push(input.title);
9604
+ }
9605
+ if (input.description !== undefined) {
9606
+ sets.push("description = ?");
9607
+ params.push(input.description);
9608
+ }
9609
+ if (input.status !== undefined) {
9610
+ if (input.status === "completed") {
9611
+ checkCompletionGuard(task, task.assigned_to || task.agent_id || null, d);
9404
9612
  }
9405
- if (current.locked_by && !isLockExpired(current.locked_at)) {
9406
- return {
9407
- success: false,
9408
- locked_by: current.locked_by,
9409
- locked_at: current.locked_at,
9410
- error: `Task is locked by ${current.locked_by}`
9411
- };
9613
+ sets.push("status = ?");
9614
+ params.push(input.status);
9615
+ if (input.status === "completed") {
9616
+ sets.push("completed_at = ?");
9617
+ params.push(completionTimestamp);
9618
+ sets.push("locked_by = NULL");
9619
+ sets.push("locked_at = NULL");
9620
+ } else if (task.status === "completed" && input.completed_at === undefined) {
9621
+ sets.push("completed_at = NULL");
9412
9622
  }
9413
- return {
9414
- success: false,
9415
- error: `Task ${id} could not be locked because it changed during lock acquisition`
9416
- };
9417
9623
  }
9418
- logTaskChange(id, "lock", "locked_by", task.locked_by, agentId, agentId, d);
9419
- return { success: true, locked_by: agentId, locked_at: timestamp, expires_at: lockExpiresAt(timestamp) };
9420
- }
9421
- function unlockTask(id, agentId, db) {
9422
- const d = db || getDatabase();
9423
- const task = getTask(id, d);
9424
- if (!task)
9425
- throw new TaskNotFoundError(id);
9426
- if (agentId && task.locked_by && task.locked_by !== agentId) {
9427
- throw new LockError(id, task.locked_by);
9624
+ if (input.priority !== undefined) {
9625
+ sets.push("priority = ?");
9626
+ params.push(input.priority);
9627
+ }
9628
+ if (input.project_id !== undefined) {
9629
+ sets.push("project_id = ?");
9630
+ params.push(input.project_id);
9631
+ }
9632
+ if (input.assigned_to !== undefined) {
9633
+ sets.push("assigned_to = ?");
9634
+ params.push(input.assigned_to);
9635
+ }
9636
+ if (input.working_dir !== undefined) {
9637
+ sets.push("working_dir = ?");
9638
+ params.push(input.working_dir);
9639
+ }
9640
+ if (input.tags !== undefined) {
9641
+ sets.push("tags = ?");
9642
+ params.push(JSON.stringify(input.tags));
9643
+ }
9644
+ if (input.metadata !== undefined) {
9645
+ sets.push("metadata = ?");
9646
+ params.push(JSON.stringify(input.metadata));
9647
+ }
9648
+ if (input.plan_id !== undefined) {
9649
+ sets.push("plan_id = ?");
9650
+ params.push(input.plan_id);
9651
+ }
9652
+ if (input.task_list_id !== undefined) {
9653
+ sets.push("task_list_id = ?");
9654
+ params.push(input.task_list_id);
9655
+ }
9656
+ if (input.due_at !== undefined) {
9657
+ sets.push("due_at = ?");
9658
+ params.push(input.due_at);
9659
+ }
9660
+ if (input.estimated_minutes !== undefined) {
9661
+ sets.push("estimated_minutes = ?");
9662
+ params.push(input.estimated_minutes);
9663
+ }
9664
+ if (input.sla_minutes !== undefined) {
9665
+ sets.push("sla_minutes = ?");
9666
+ params.push(input.sla_minutes);
9428
9667
  }
9429
- const timestamp = now();
9430
- d.run(`UPDATE tasks SET locked_by = NULL, locked_at = NULL, version = version + 1, updated_at = ?
9431
- WHERE id = ?`, [timestamp, id]);
9432
- return true;
9433
- }
9434
- function getTaskLockStatus(id, db) {
9435
- const d = db || getDatabase();
9436
- const task = getTask(id, d);
9437
- if (!task)
9438
- throw new TaskNotFoundError(id);
9439
- const expired = isLockExpired(task.locked_at);
9440
- return {
9441
- task_id: id,
9442
- locked: !!task.locked_by && !expired,
9443
- locked_by: task.locked_by,
9444
- locked_at: task.locked_at,
9445
- expires_at: lockExpiresAt(task.locked_at),
9446
- expired
9447
- };
9448
- }
9449
- function claimNextTask(agentId, filters, db) {
9450
- const d = db || getDatabase();
9451
- const tx = d.transaction(() => {
9452
- const task = getNextTask(agentId, filters, d);
9453
- if (!task)
9454
- return null;
9455
- return startTask(task.id, agentId, d);
9456
- });
9457
- return tx();
9458
- }
9459
- function getNextTask(agentId, filters, db) {
9460
- const d = db || getDatabase();
9461
- clearExpiredLocks(d);
9462
- const conditions = ["status = 'pending'", "(locked_by IS NULL OR locked_at < ?)"];
9463
- const params = [lockExpiryCutoff()];
9464
- if (filters?.project_id) {
9465
- conditions.push("project_id = ?");
9466
- params.push(filters.project_id);
9668
+ if (input.actual_minutes !== undefined) {
9669
+ sets.push("actual_minutes = ?");
9670
+ params.push(input.actual_minutes);
9467
9671
  }
9468
- if (filters?.task_list_id) {
9469
- conditions.push("task_list_id = ?");
9470
- params.push(filters.task_list_id);
9672
+ if (input.completed_at !== undefined && input.status !== "completed") {
9673
+ sets.push("completed_at = ?");
9674
+ params.push(input.completed_at);
9471
9675
  }
9472
- if (filters?.plan_id) {
9473
- conditions.push("plan_id = ?");
9474
- params.push(filters.plan_id);
9676
+ if (input.confidence !== undefined) {
9677
+ sets.push("confidence = ?");
9678
+ params.push(input.confidence);
9475
9679
  }
9476
- if (filters?.tags && filters.tags.length > 0) {
9477
- const placeholders = filters.tags.map(() => "?").join(",");
9478
- conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
9479
- params.push(...filters.tags);
9680
+ if (input.retry_count !== undefined) {
9681
+ sets.push("retry_count = ?");
9682
+ params.push(input.retry_count);
9480
9683
  }
9481
- conditions.push("id NOT IN (SELECT td.task_id FROM task_dependencies td JOIN tasks dep ON dep.id = td.depends_on WHERE dep.status != 'completed')");
9482
- const where = conditions.join(" AND ");
9483
- let recentProjectIds = [];
9484
- if (agentId) {
9485
- const recentRows = d.query(`SELECT DISTINCT project_id FROM tasks WHERE assigned_to = ? AND status = 'completed' AND project_id IS NOT NULL ORDER BY completed_at DESC LIMIT 3`).all(agentId);
9486
- recentProjectIds = recentRows.map((r) => r.project_id);
9684
+ if (input.max_retries !== undefined) {
9685
+ sets.push("max_retries = ?");
9686
+ params.push(input.max_retries);
9487
9687
  }
9488
- let sql = `SELECT * FROM tasks WHERE ${where} ORDER BY `;
9489
- if (agentId) {
9490
- sql += `CASE WHEN assigned_to = ? THEN 0 WHEN assigned_to IS NULL THEN 1 ELSE 2 END, `;
9491
- params.push(agentId);
9688
+ if (input.retry_after !== undefined) {
9689
+ sets.push("retry_after = ?");
9690
+ params.push(input.retry_after);
9492
9691
  }
9493
- if (recentProjectIds.length > 0) {
9494
- const placeholders = recentProjectIds.map(() => "?").join(",");
9495
- sql += `CASE WHEN project_id IN (${placeholders}) THEN 0 ELSE 1 END, `;
9496
- params.push(...recentProjectIds);
9692
+ if (input.requires_approval !== undefined) {
9693
+ sets.push("requires_approval = ?");
9694
+ params.push(input.requires_approval ? 1 : 0);
9497
9695
  }
9498
- sql += `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, created_at ASC LIMIT 1`;
9499
- const row = d.query(sql).get(...params);
9500
- return row ? rowToTask(row) : null;
9501
- }
9502
- function getActiveWork(filters, db) {
9503
- const d = db || getDatabase();
9504
- clearExpiredLocks(d);
9505
- const conditions = ["status = 'in_progress'"];
9506
- const params = [];
9507
- if (filters?.project_id) {
9508
- conditions.push("project_id = ?");
9509
- params.push(filters.project_id);
9696
+ if (input.approved_by !== undefined) {
9697
+ sets.push("approved_by = ?");
9698
+ params.push(input.approved_by);
9699
+ sets.push("approved_at = ?");
9700
+ params.push(now());
9510
9701
  }
9511
- if (filters?.task_list_id) {
9512
- conditions.push("task_list_id = ?");
9513
- params.push(filters.task_list_id);
9702
+ if (input.recurrence_rule !== undefined) {
9703
+ sets.push("recurrence_rule = ?");
9704
+ params.push(input.recurrence_rule);
9514
9705
  }
9515
- const where = conditions.join(" AND ");
9516
- const rows = d.query(`SELECT id, short_id, title, priority, assigned_to, locked_by, locked_at, updated_at FROM tasks WHERE ${where} ORDER BY
9517
- CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
9518
- updated_at DESC`).all(...params);
9519
- return rows;
9520
- }
9521
- function getTasksChangedSince(since, filters, db) {
9522
- const d = db || getDatabase();
9523
- const conditions = ["updated_at > ?"];
9524
- const params = [since];
9525
- if (filters?.project_id) {
9526
- conditions.push("project_id = ?");
9527
- params.push(filters.project_id);
9706
+ if (input.task_type !== undefined) {
9707
+ sets.push("task_type = ?");
9708
+ params.push(input.task_type ?? null);
9528
9709
  }
9529
- if (filters?.task_list_id) {
9530
- conditions.push("task_list_id = ?");
9531
- params.push(filters.task_list_id);
9710
+ params.push(id, input.version);
9711
+ const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
9712
+ if (result.changes === 0) {
9713
+ const current = getTask(id, d);
9714
+ throw new VersionConflictError(id, input.version, current?.version ?? -1);
9532
9715
  }
9533
- const where = conditions.join(" AND ");
9534
- const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at DESC`).all(...params);
9535
- return rows.map(rowToTask);
9536
- }
9537
- function failTask(id, agentId, reason, options, db) {
9538
- const d = db || getDatabase();
9539
- const databasePath = databasePathFromDatabase(d);
9540
- const task = getTask(id, d);
9541
- if (!task)
9542
- throw new TaskNotFoundError(id);
9543
- const meta = {
9544
- ...task.metadata,
9545
- _failure: {
9546
- reason: reason || "Unknown failure",
9547
- error_code: options?.error_code || null,
9548
- failed_by: agentId || null,
9549
- failed_at: now(),
9550
- retry_requested: options?.retry || false
9716
+ if (input.tags !== undefined) {
9717
+ replaceTaskTags(id, input.tags, d);
9718
+ }
9719
+ const transitionedToCompleted = input.status === "completed" && task.status !== "completed";
9720
+ if (transitionedToCompleted && task.recurrence_rule) {
9721
+ try {
9722
+ const { spawnNextRecurrence: spawnNextRecurrence2 } = (init_task_lifecycle(), __toCommonJS(exports_task_lifecycle));
9723
+ spawnNextRecurrence2(task, d, completionTimestamp);
9724
+ } catch (e) {
9725
+ console.warn(`[tasks] failed to spawn next recurrence for ${id}: ${e instanceof Error ? e.message : String(e)}`);
9551
9726
  }
9552
- };
9553
- const timestamp = now();
9554
- d.run(`UPDATE tasks SET status = 'failed', locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
9555
- WHERE id = ?`, [JSON.stringify(meta), timestamp, id]);
9556
- const failedTask = {
9727
+ }
9728
+ const agentId = task.assigned_to || task.agent_id || null;
9729
+ if (input.status !== undefined && input.status !== task.status)
9730
+ logTaskChange(id, "update", "status", task.status, input.status, agentId, d);
9731
+ if (input.priority !== undefined && input.priority !== task.priority)
9732
+ logTaskChange(id, "update", "priority", task.priority, input.priority, agentId, d);
9733
+ if (input.title !== undefined && input.title !== task.title)
9734
+ logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
9735
+ if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
9736
+ logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
9737
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
9738
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
9739
+ if (input.approved_by !== undefined)
9740
+ logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
9741
+ const reopened = input.status !== undefined && input.status !== "completed" && task.status === "completed" && input.completed_at === undefined;
9742
+ const completedNow = input.status === "completed";
9743
+ const updatedTask = {
9557
9744
  ...task,
9558
- status: "failed",
9559
- locked_by: null,
9560
- locked_at: null,
9561
- metadata: meta,
9745
+ ...Object.fromEntries(Object.entries(input).filter(([, v]) => v !== undefined)),
9746
+ tags: input.tags ?? task.tags,
9747
+ metadata: input.metadata ?? task.metadata,
9562
9748
  version: task.version + 1,
9563
- updated_at: timestamp
9749
+ updated_at: timestamp,
9750
+ locked_by: completedNow ? null : task.locked_by,
9751
+ locked_at: completedNow ? null : task.locked_at,
9752
+ completed_at: completedNow ? completionTimestamp : reopened ? null : input.completed_at !== undefined ? input.completed_at : task.completed_at,
9753
+ sla_minutes: input.sla_minutes !== undefined ? input.sla_minutes : task.sla_minutes,
9754
+ actual_minutes: input.actual_minutes ?? task.actual_minutes,
9755
+ confidence: input.confidence !== undefined ? input.confidence : task.confidence,
9756
+ retry_count: input.retry_count ?? task.retry_count,
9757
+ max_retries: input.max_retries ?? task.max_retries,
9758
+ retry_after: input.retry_after !== undefined ? input.retry_after : task.retry_after,
9759
+ requires_approval: input.requires_approval !== undefined ? input.requires_approval : task.requires_approval,
9760
+ approved_by: input.approved_by ?? task.approved_by,
9761
+ approved_at: input.approved_by ? timestamp : task.approved_at
9564
9762
  };
9565
- logTaskChange(id, "fail", "status", task.status, "failed", agentId || null, d);
9566
- const failurePayload = taskEventData(failedTask, { reason, error_code: options?.error_code, agent_id: agentId });
9567
- dispatchWebhook2("task.failed", failurePayload, d).catch(() => {});
9568
- emitLocalEventHooksQuiet({ type: "task.failed", payload: failurePayload, databasePath });
9569
- emitSharedTaskEventQuiet({ type: "task.failed", task: failedTask, data: { reason, error_code: options?.error_code, agent_id: agentId }, severity: "warning", databasePath });
9570
- let retryTask;
9571
- if (options?.retry) {
9572
- const retryCount = (task.retry_count || 0) + 1;
9573
- const maxRetries = task.max_retries || 3;
9574
- if (retryCount > maxRetries) {
9575
- d.run("UPDATE tasks SET metadata = ? WHERE id = ?", [
9576
- JSON.stringify({ ...meta, _retry_exhausted: { retry_count: retryCount - 1, max_retries: maxRetries } }),
9577
- id
9578
- ]);
9579
- } else {
9580
- const backoffMinutes = Math.pow(5, retryCount - 1);
9581
- const retryAfter = options.retry_after || new Date(Date.now() + backoffMinutes * 60 * 1000).toISOString();
9582
- let title = task.title;
9583
- if (task.short_id && title.startsWith(task.short_id + ": ")) {
9584
- title = title.slice(task.short_id.length + 2);
9585
- }
9586
- retryTask = createTask({
9587
- title,
9588
- description: task.description ?? undefined,
9589
- priority: task.priority,
9590
- project_id: task.project_id ?? undefined,
9591
- task_list_id: task.task_list_id ?? undefined,
9592
- plan_id: task.plan_id ?? undefined,
9593
- assigned_to: task.assigned_to ?? undefined,
9594
- tags: task.tags,
9595
- metadata: { ...task.metadata, _retry: { original_id: task.id, retry_count: retryCount, max_retries: maxRetries, retry_after: retryAfter, failure_reason: reason } },
9596
- estimated_minutes: task.estimated_minutes ?? undefined,
9597
- recurrence_rule: task.recurrence_rule ?? undefined,
9598
- due_at: retryAfter
9599
- }, d);
9600
- d.run("UPDATE tasks SET retry_count = ?, max_retries = ?, retry_after = ? WHERE id = ?", [retryCount, maxRetries, retryAfter, retryTask.id]);
9601
- }
9763
+ const databasePath = databasePathFromDatabase(d);
9764
+ if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to) {
9765
+ const payload = taskEventData(updatedTask, { assigned_to: input.assigned_to, old_assigned_to: task.assigned_to });
9766
+ dispatchWebhook2("task.assigned", payload, d).catch(() => {});
9767
+ emitLocalEventHooksQuiet({ type: "task.assigned", payload, databasePath });
9768
+ emitSharedTaskEventQuiet({ type: "task.assigned", task: updatedTask, data: { old_assigned_to: task.assigned_to }, databasePath });
9602
9769
  }
9603
- return { task: failedTask, retryTask };
9604
- }
9605
- function getStaleTasks(staleQuery = 30, filters, db) {
9606
- const d = db || getDatabase();
9607
- const staleMinutes = typeof staleQuery === "number" ? staleQuery : staleQuery.minutes ?? (staleQuery.hours !== undefined ? staleQuery.hours * 60 : 30);
9608
- const effectiveFilters = typeof staleQuery === "number" ? filters : { project_id: staleQuery.project_id, task_list_id: staleQuery.task_list_id };
9609
- const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
9610
- const conditions = [
9611
- "status = 'in_progress'",
9612
- "(updated_at < ? OR (locked_at IS NOT NULL AND locked_at < ?))"
9613
- ];
9614
- const params = [cutoff, cutoff];
9615
- if (effectiveFilters?.project_id) {
9616
- conditions.push("project_id = ?");
9617
- params.push(effectiveFilters.project_id);
9770
+ if (input.status !== undefined && input.status !== task.status) {
9771
+ const payload = taskEventData(updatedTask, { old_status: task.status, new_status: input.status });
9772
+ dispatchWebhook2("task.status_changed", payload, d).catch(() => {});
9773
+ emitLocalEventHooksQuiet({ type: "task.status_changed", payload, databasePath });
9774
+ emitSharedTaskEventQuiet({ type: "task.status_changed", task: updatedTask, data: { old_status: task.status, new_status: input.status }, databasePath });
9618
9775
  }
9619
- if (effectiveFilters?.task_list_id) {
9620
- conditions.push("task_list_id = ?");
9621
- params.push(effectiveFilters.task_list_id);
9776
+ if (input.approved_by !== undefined) {
9777
+ emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title }, databasePath });
9622
9778
  }
9623
- const where = conditions.join(" AND ");
9624
- const rows = d.query(`SELECT * FROM tasks WHERE ${where} ORDER BY updated_at ASC`).all(...params);
9625
- return rows.map(rowToTask);
9626
- }
9627
- function stealTask(agentId, opts, db) {
9628
- const d = db || getDatabase();
9629
- const databasePath = databasePathFromDatabase(d);
9630
- const staleMinutes = opts?.stale_minutes ?? 30;
9631
- const staleTasks = getStaleTasks(staleMinutes, { project_id: opts?.project_id, task_list_id: opts?.task_list_id }, d);
9632
- if (staleTasks.length === 0)
9633
- return null;
9634
- const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
9635
- staleTasks.sort((a, b) => (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9));
9636
- const target = staleTasks[0];
9637
- const timestamp = now();
9638
- const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000).toISOString();
9639
- const result = d.run(`UPDATE tasks SET assigned_to = ?, locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
9640
- WHERE id = ? AND status = 'in_progress' AND (updated_at < ? OR (locked_at IS NOT NULL AND locked_at < ?))`, [agentId, agentId, timestamp, timestamp, target.id, cutoff, cutoff]);
9641
- if (result.changes === 0)
9642
- return null;
9643
- logTaskChange(target.id, "steal", "assigned_to", target.assigned_to, agentId, agentId, d);
9644
- logTaskChange(target.id, "steal", "locked_by", target.locked_by, agentId, agentId, d);
9645
- const stolenTask = { ...target, assigned_to: agentId, locked_by: agentId, locked_at: timestamp, updated_at: timestamp, version: target.version + 1 };
9646
- const payload = taskEventData(stolenTask, { agent_id: agentId, stolen_from: target.assigned_to });
9647
- dispatchWebhook2("task.assigned", payload, d).catch(() => {});
9648
- emitLocalEventHooksQuiet({ type: "task.assigned", payload, databasePath });
9649
- emitSharedTaskEventQuiet({ type: "task.assigned", task: stolenTask, data: { agent_id: agentId, stolen_from: target.assigned_to }, databasePath });
9650
- return stolenTask;
9779
+ const updatePayload = taskEventData(updatedTask);
9780
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
9781
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload, databasePath });
9782
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
9783
+ return updatedTask;
9651
9784
  }
9652
- function claimOrSteal(agentId, filters, db) {
9785
+ function deleteTask(id, db) {
9653
9786
  const d = db || getDatabase();
9654
- const tx = d.transaction(() => {
9655
- const next = getNextTask(agentId, filters, d);
9656
- if (next) {
9657
- const started = startTask(next.id, agentId, d);
9658
- return { task: started, stolen: false };
9659
- }
9660
- const stolen = stealTask(agentId, { stale_minutes: filters?.stale_minutes, project_id: filters?.project_id, task_list_id: filters?.task_list_id }, d);
9661
- if (stolen)
9662
- return { task: stolen, stolen: true };
9663
- return null;
9664
- });
9665
- return tx();
9666
- }
9667
- function spawnNextRecurrence(completedTask, db, completedAt) {
9668
- const recurrenceBase = completedTask.due_at ? new Date(completedTask.due_at) : new Date(completedAt);
9669
- const dueAt = nextOccurrence(completedTask.recurrence_rule, recurrenceBase);
9670
- let title = completedTask.title;
9671
- if (completedTask.short_id && title.startsWith(completedTask.short_id + ": ")) {
9672
- title = title.slice(completedTask.short_id.length + 2);
9673
- }
9674
- const recurrenceParentId = completedTask.recurrence_parent_id || completedTask.id;
9675
- return createTask({
9676
- title,
9677
- description: completedTask.description ?? undefined,
9678
- priority: completedTask.priority,
9679
- project_id: completedTask.project_id ?? undefined,
9680
- task_list_id: completedTask.task_list_id ?? undefined,
9681
- plan_id: completedTask.plan_id ?? undefined,
9682
- assigned_to: completedTask.assigned_to ?? undefined,
9683
- tags: completedTask.tags,
9684
- metadata: completedTask.metadata,
9685
- estimated_minutes: completedTask.estimated_minutes ?? undefined,
9686
- sla_minutes: completedTask.sla_minutes ?? undefined,
9687
- recurrence_rule: completedTask.recurrence_rule,
9688
- recurrence_parent_id: recurrenceParentId,
9689
- due_at: dueAt
9690
- }, db);
9787
+ const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
9788
+ if (!row)
9789
+ return false;
9790
+ recordStorageTombstone({
9791
+ object_type: "tasks",
9792
+ object_id: id,
9793
+ payload: rowToTask(row),
9794
+ version: row.version
9795
+ }, d);
9796
+ const result = d.run("DELETE FROM tasks WHERE id = ?", [id]);
9797
+ return result.changes > 0;
9691
9798
  }
9692
- var MAX_SPAWN_DEPTH = 10;
9693
- var init_task_lifecycle = __esm(() => {
9799
+ var init_task_crud = __esm(() => {
9694
9800
  init_types();
9695
9801
  init_database();
9696
9802
  init_completion_guard();
@@ -9698,11 +9804,9 @@ var init_task_lifecycle = __esm(() => {
9698
9804
  init_event_hooks();
9699
9805
  init_shared_events();
9700
9806
  init_audit();
9701
- init_recurrence();
9702
9807
  init_webhooks();
9703
- init_templates();
9704
- init_task_crud();
9705
- init_task_graph();
9808
+ init_checklists();
9809
+ init_storage_tombstones();
9706
9810
  });
9707
9811
 
9708
9812
  // src/db/task-status.ts
@@ -9792,6 +9896,15 @@ function setTaskStatus(id, status, _agentId, db) {
9792
9896
  throw new TaskNotFoundError(id);
9793
9897
  if (task.status === status)
9794
9898
  return task;
9899
+ if (status === "completed") {
9900
+ try {
9901
+ return completeTask(id, _agentId, d);
9902
+ } catch (e) {
9903
+ if (e instanceof VersionConflictError && attempt < 2)
9904
+ continue;
9905
+ throw e;
9906
+ }
9907
+ }
9795
9908
  try {
9796
9909
  return updateTask(id, { status, version: task.version }, d);
9797
9910
  } catch (e) {
@@ -12940,20 +13053,56 @@ import chalk2 from "chalk";
12940
13053
  import { basename as basename3, resolve as resolve9 } from "path";
12941
13054
  function resolveProjectIdOrSlug(input) {
12942
13055
  const db = getDatabase();
13056
+ if (isPathLike(input)) {
13057
+ const projectPath = resolve9(input);
13058
+ const byPath2 = getProjectByPath(projectPath, db);
13059
+ return (byPath2 ?? ensureProject(basename3(projectPath), projectPath, db)).id;
13060
+ }
13061
+ const byPath = getProjectByPath(resolve9(input), db);
13062
+ if (byPath)
13063
+ return byPath.id;
12943
13064
  const byId = getProject(input, db);
12944
13065
  if (byId)
12945
13066
  return byId.id;
12946
- const row = db.query("SELECT id FROM projects WHERE name LIKE ? LIMIT 1").get(`%${input}%`);
13067
+ const partial = resolvePartialId(db, "projects", input);
13068
+ if (partial)
13069
+ return partial;
13070
+ const exact = db.query("SELECT id FROM projects WHERE lower(name) = lower(?) OR task_list_id = ? ORDER BY name LIMIT 1").get(input, input);
13071
+ if (exact)
13072
+ return exact.id;
13073
+ const inputSlug = slugify(input);
13074
+ if (inputSlug) {
13075
+ const all = db.query("SELECT id, name FROM projects ORDER BY name").all();
13076
+ const bySlug = all.find((p) => slugify(p.name) === inputSlug);
13077
+ if (bySlug)
13078
+ return bySlug.id;
13079
+ }
13080
+ const row = db.query("SELECT id FROM projects WHERE name LIKE ? ORDER BY name LIMIT 1").get(`%${input}%`);
12947
13081
  if (row)
12948
13082
  return row.id;
12949
- if (isPathLike(input)) {
12950
- const projectPath = resolve9(input);
12951
- const byPath = getProjectByPath(projectPath, db);
12952
- return (byPath ?? ensureProject(basename3(projectPath), projectPath, db)).id;
12953
- }
12954
13083
  console.error(chalk2.red(`Project not found: ${input}`));
12955
13084
  process.exit(1);
12956
13085
  }
13086
+ function parseStatus(value) {
13087
+ if (!value)
13088
+ return;
13089
+ const normalized = normalizeStatus(value);
13090
+ if (!TASK_STATUSES.includes(normalized)) {
13091
+ console.error(chalk2.red(`--status must be one of: ${TASK_STATUSES.join(", ")}`));
13092
+ process.exit(1);
13093
+ }
13094
+ return normalized;
13095
+ }
13096
+ function parseIntOption(value, flag) {
13097
+ if (value === undefined)
13098
+ return;
13099
+ const n = parseInt(value, 10);
13100
+ if (!Number.isFinite(n)) {
13101
+ console.error(chalk2.red(`${flag} must be a number`));
13102
+ process.exit(1);
13103
+ }
13104
+ return n;
13105
+ }
12957
13106
  function isPathLike(input) {
12958
13107
  return input.startsWith(".") || input.includes("/") || input.includes("\\");
12959
13108
  }
@@ -13052,27 +13201,32 @@ function registerTaskCommands(program2) {
13052
13201
  }
13053
13202
  return id;
13054
13203
  })() : undefined;
13055
- const task2 = createTask({
13056
- title,
13057
- description: opts.description,
13058
- priority: parsePriority(opts.priority),
13059
- parent_id: opts.parent ? resolveTaskId(opts.parent) : undefined,
13060
- tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined,
13061
- plan_id: opts.plan ? resolvePlanId(opts.plan) : undefined,
13062
- assigned_to: opts.assign,
13063
- status: opts.status ? normalizeStatus(opts.status) : undefined,
13064
- task_list_id: taskListId,
13065
- agent_id: globalOpts.agent,
13066
- session_id: globalOpts.session,
13067
- project_id: projectId,
13068
- working_dir: process.cwd(),
13069
- estimated_minutes: opts.estimated ? parseInt(opts.estimated, 10) : undefined,
13070
- sla_minutes: opts.slaMinutes !== undefined || opts.sla !== undefined ? parseInt(opts.slaMinutes ?? opts.sla, 10) : undefined,
13071
- requires_approval: opts.approval || false,
13072
- recurrence_rule: opts.recurrence,
13073
- due_at: opts.due ? opts.due.length === 10 ? opts.due + "T00:00:00.000Z" : opts.due : undefined,
13074
- reason: opts.reason
13075
- });
13204
+ let task2;
13205
+ try {
13206
+ task2 = createTask({
13207
+ title,
13208
+ description: opts.description,
13209
+ priority: parsePriority(opts.priority),
13210
+ parent_id: opts.parent ? resolveTaskId(opts.parent) : undefined,
13211
+ tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined,
13212
+ plan_id: opts.plan ? resolvePlanId(opts.plan) : undefined,
13213
+ assigned_to: opts.assign,
13214
+ status: parseStatus(opts.status),
13215
+ task_list_id: taskListId,
13216
+ agent_id: globalOpts.agent,
13217
+ session_id: globalOpts.session,
13218
+ project_id: projectId,
13219
+ working_dir: process.cwd(),
13220
+ estimated_minutes: parseIntOption(opts.estimated, "--estimated"),
13221
+ sla_minutes: opts.slaMinutes !== undefined || opts.sla !== undefined ? parseIntOption(opts.slaMinutes ?? opts.sla, "--sla-minutes") : undefined,
13222
+ requires_approval: opts.approval || false,
13223
+ recurrence_rule: opts.recurrence,
13224
+ due_at: opts.due ? opts.due.length === 10 ? opts.due + "T00:00:00.000Z" : opts.due : undefined,
13225
+ reason: opts.reason
13226
+ });
13227
+ } catch (e) {
13228
+ handleError(e);
13229
+ }
13076
13230
  if (globalOpts.json) {
13077
13231
  output(task2, true);
13078
13232
  } else {
@@ -13103,7 +13257,7 @@ function registerTaskCommands(program2) {
13103
13257
  title: opts.title,
13104
13258
  description: opts.description,
13105
13259
  priority: parsePriority(opts.priority),
13106
- status: opts.status ? normalizeStatus(opts.status) : undefined,
13260
+ status: parseStatus(opts.status),
13107
13261
  task_list_id: taskListId,
13108
13262
  tags: parseTags(opts.tags),
13109
13263
  metadata: buildExpectationMetadata(opts),
@@ -13580,7 +13734,7 @@ ${chalk2.cyan(sid)} ${statusColor(task2.status)} ${prioColor(task2.priority)} ${
13580
13734
  console.log(` ${chalk2.dim(h.created_at)} ${chalk2.bold(h.action)}${field}${change}${agent}`);
13581
13735
  }
13582
13736
  });
13583
- program2.command("update <id>").description("Update a task").option("--title <text>", "New title").option("-d, --description <text>", "New description").option("-s, --status <status>", "New status").option("-p, --priority <priority>", "New priority").option("--assign <agent>", "Assign to agent").option("--tags <tags>", "New tags (comma-separated)").option("--tag <tags>", "New tags (alias for --tags)").option("--list <id>", "Move to a task list").option("--task-list <id>", "Move to a task list (alias for --list)").option("--plan <id>", "Move to a plan").option("--clear-plan", "Remove from its current plan").option("--estimated <minutes>", "Estimated time in minutes").option("--sla-minutes <minutes>", "SLA minutes before unfinished work is escalated").option("--sla <minutes>", "Alias for --sla-minutes").option("--due <date>", "Due date (ISO string or YYYY-MM-DD), empty to clear").option("--recurrence <rule>", "Recurrence rule, empty to clear").option("--approval", "Require approval before completion").action((id, opts) => {
13737
+ program2.command("update <id>").description("Update a task").option("--title <text>", "New title").option("-d, --description <text>", "New description").option("-s, --status <status>", "New status").option("-p, --priority <priority>", "New priority").option("--assign <agent>", "Assign to agent").option("--tags <tags>", "New tags (comma-separated)").option("--tag <tags>", "New tags (alias for --tags)").option("--list <id>", "Move to a task list").option("--task-list <id>", "Move to a task list (alias for --list)").option("--plan <id>", "Move to a plan").option("--clear-plan", "Remove from its current plan").option("--estimated <minutes>", "Estimated time in minutes").option("--sla-minutes <minutes>", "SLA minutes before unfinished work is escalated").option("--sla <minutes>", "Alias for --sla-minutes").option("--due <date>", "Due date (ISO string or YYYY-MM-DD), empty to clear").option("--recurrence <rule>", "Recurrence rule, empty to clear").option("--approval", "Require approval before completion").option("--clear-approval", "Remove the approval requirement").action((id, opts) => {
13584
13738
  const globalOpts = program2.opts();
13585
13739
  opts.tags = opts.tags || opts.tag;
13586
13740
  opts.list = opts.list || opts.taskList;
@@ -13594,6 +13748,10 @@ ${chalk2.cyan(sid)} ${statusColor(task2.status)} ${prioColor(task2.priority)} ${
13594
13748
  console.error(chalk2.red("Use either --plan or --clear-plan, not both."));
13595
13749
  process.exit(1);
13596
13750
  }
13751
+ if (opts.approval && opts.clearApproval) {
13752
+ console.error(chalk2.red("Use either --approval or --clear-approval, not both."));
13753
+ process.exit(1);
13754
+ }
13597
13755
  const taskListId = opts.list ? (() => {
13598
13756
  const db = getDatabase();
13599
13757
  const resolved = resolvePartialId(db, "task_lists", opts.list);
@@ -13610,17 +13768,17 @@ ${chalk2.cyan(sid)} ${statusColor(task2.status)} ${prioColor(task2.priority)} ${
13610
13768
  version: current.version,
13611
13769
  title: opts.title,
13612
13770
  description: opts.description,
13613
- status: opts.status ? normalizeStatus(opts.status) : undefined,
13614
- priority: opts.priority,
13771
+ status: parseStatus(opts.status),
13772
+ priority: parsePriority(opts.priority),
13615
13773
  assigned_to: opts.assign,
13616
13774
  tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined,
13617
13775
  plan_id: planId,
13618
13776
  task_list_id: taskListId,
13619
- estimated_minutes: opts.estimated !== undefined ? parseInt(opts.estimated, 10) : undefined,
13620
- sla_minutes: opts.slaMinutes !== undefined || opts.sla !== undefined ? parseInt(opts.slaMinutes ?? opts.sla, 10) : undefined,
13777
+ estimated_minutes: opts.estimated !== undefined ? parseIntOption(opts.estimated, "--estimated") : undefined,
13778
+ sla_minutes: opts.slaMinutes !== undefined || opts.sla !== undefined ? parseIntOption(opts.slaMinutes ?? opts.sla, "--sla-minutes") : undefined,
13621
13779
  due_at: opts.due !== undefined ? opts.due === "" ? null : opts.due.length === 10 ? opts.due + "T00:00:00.000Z" : opts.due : undefined,
13622
13780
  recurrence_rule: opts.recurrence !== undefined ? opts.recurrence === "" ? null : opts.recurrence : undefined,
13623
- requires_approval: opts.approval !== undefined ? true : undefined
13781
+ requires_approval: opts.clearApproval ? false : opts.approval !== undefined ? true : undefined
13624
13782
  });
13625
13783
  } catch (e) {
13626
13784
  handleError(e);
@@ -13637,7 +13795,14 @@ ${chalk2.cyan(sid)} ${statusColor(task2.status)} ${prioColor(task2.priority)} ${
13637
13795
  const resolvedId = resolveTaskId(id);
13638
13796
  const attachmentIds = opts.attachIds ? opts.attachIds.split(",").map((s) => s.trim()) : undefined;
13639
13797
  const filesChanged = opts.filesChanged ? opts.filesChanged.split(",").map((s) => s.trim()) : undefined;
13640
- const confidence = opts.confidence !== undefined ? parseFloat(opts.confidence) : undefined;
13798
+ let confidence;
13799
+ if (opts.confidence !== undefined) {
13800
+ confidence = parseFloat(opts.confidence);
13801
+ if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
13802
+ console.error(chalk2.red("--confidence must be a number between 0.0 and 1.0"));
13803
+ process.exit(1);
13804
+ }
13805
+ }
13641
13806
  const evidence = attachmentIds || filesChanged || opts.testResults || opts.commitHash || opts.notes ? { attachment_ids: attachmentIds, files_changed: filesChanged, test_results: opts.testResults, commit_hash: opts.commitHash, notes: opts.notes } : undefined;
13642
13807
  let task2;
13643
13808
  try {
@@ -23269,12 +23434,21 @@ function registerProjectCommands(program2) {
23269
23434
  handleError(e);
23270
23435
  }
23271
23436
  });
23272
- program2.command("comment <id> <text>").description("Add a comment to a task").action((id, text) => {
23437
+ program2.command("comment <id> <text>").alias("log-progress").description("Add a comment to a task (alias: log-progress, for recording intermediate progress)").option("--pct <percent>", "Progress percentage (0-100) to record alongside the note").action((id, text, opts) => {
23273
23438
  const globalOpts = program2.opts();
23274
23439
  const resolvedId = resolveTaskId(id);
23440
+ let content = text;
23441
+ if (opts.pct !== undefined) {
23442
+ const pct = parseInt(opts.pct, 10);
23443
+ if (!Number.isFinite(pct) || pct < 0 || pct > 100) {
23444
+ console.error(chalk4.red("--pct must be a number between 0 and 100"));
23445
+ process.exit(1);
23446
+ }
23447
+ content = `[progress ${pct}%] ${text}`;
23448
+ }
23275
23449
  const comment = addComment({
23276
23450
  task_id: resolvedId,
23277
- content: text,
23451
+ content,
23278
23452
  agent_id: globalOpts.agent,
23279
23453
  session_id: globalOpts.session
23280
23454
  });
@@ -25406,10 +25580,8 @@ var init_token_utils = __esm(() => {
25406
25580
  "cancel_task",
25407
25581
  "check_task_done_contract",
25408
25582
  "claim_task",
25409
- "clone_task",
25410
25583
  "delete_task",
25411
25584
  "extend_task",
25412
- "get_active_work",
25413
25585
  "get_archived_tasks",
25414
25586
  "get_blocked_tasks",
25415
25587
  "get_blocking_tasks",
@@ -25445,7 +25617,8 @@ var init_token_utils = __esm(() => {
25445
25617
  "task_context",
25446
25618
  "unlock_task",
25447
25619
  "unarchive_task",
25448
- "update_task"
25620
+ "update_task",
25621
+ "upsert_task"
25449
25622
  ],
25450
25623
  projects: [
25451
25624
  "bootstrap_project",
@@ -25704,12 +25877,8 @@ var init_token_utils = __esm(() => {
25704
25877
  "delete_tag",
25705
25878
  "get_label",
25706
25879
  "get_activity_timeline",
25707
- "get_recent_activity",
25708
25880
  "get_tag",
25709
25881
  "get_task_fields",
25710
- "get_task_graph",
25711
- "get_task_history",
25712
- "get_task_stats",
25713
25882
  "list_workflow_states",
25714
25883
  "list_labels",
25715
25884
  "list_tags",
@@ -25720,6 +25889,10 @@ var init_token_utils = __esm(() => {
25720
25889
  "describe_tools",
25721
25890
  "set_task_workflow_state",
25722
25891
  "set_task_fields",
25892
+ "assign_label_to_task",
25893
+ "create_custom_field",
25894
+ "set_task_custom_field",
25895
+ "set_task_priority_meta",
25723
25896
  "update_label",
25724
25897
  "update_tag"
25725
25898
  ],
@@ -25745,7 +25918,6 @@ var init_token_utils = __esm(() => {
25745
25918
  "update_template",
25746
25919
  "write_template_library"
25747
25920
  ],
25748
- webhooks: ["create_webhook", "delete_webhook", "list_webhooks"],
25749
25921
  machines: [
25750
25922
  "machines_archive",
25751
25923
  "machines_delete",
@@ -27303,6 +27475,11 @@ function safeEqualHex(a, b) {
27303
27475
  return false;
27304
27476
  return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
27305
27477
  }
27478
+ function safeEqualStrings(a, b) {
27479
+ const ah = createHash6("sha256").update(a, "utf8").digest();
27480
+ const bh = createHash6("sha256").update(b, "utf8").digest();
27481
+ return timingSafeEqual3(ah, bh);
27482
+ }
27306
27483
  function generatePlaintextKey() {
27307
27484
  return `tdos_${randomBytes2(32).toString("base64url")}`;
27308
27485
  }
@@ -27910,6 +28087,37 @@ function parseBoundedLimit(value, fallback, max) {
27910
28087
  return fallback;
27911
28088
  return Math.min(parsed, max);
27912
28089
  }
28090
+ function mapTaskError(e, json2) {
28091
+ if (e instanceof VersionConflictError) {
28092
+ return json2({
28093
+ error: e.message,
28094
+ code: VersionConflictError.code,
28095
+ expected_version: e.expectedVersion,
28096
+ current_version: e.actualVersion
28097
+ }, 409);
28098
+ }
28099
+ if (e instanceof TaskNotFoundError) {
28100
+ return json2({ error: e.message, code: TaskNotFoundError.code }, 404);
28101
+ }
28102
+ if (e instanceof LockError) {
28103
+ return json2({ error: e.message, code: LockError.code }, 409);
28104
+ }
28105
+ if (e instanceof CompletionGuardError) {
28106
+ return json2({
28107
+ error: e.message,
28108
+ code: CompletionGuardError.code,
28109
+ retry_after: e.retryAfterSeconds ?? null
28110
+ }, 409);
28111
+ }
28112
+ if (e instanceof Error && (/ is blocked by /.test(e.message) || /cannot be started/.test(e.message))) {
28113
+ return json2({ error: e.message, code: "TASK_NOT_STARTABLE" }, 409);
28114
+ }
28115
+ return null;
28116
+ }
28117
+ function countRecurringTasks() {
28118
+ const row = getDatabase().query("SELECT COUNT(*) as count FROM tasks WHERE recurrence_rule IS NOT NULL AND recurrence_rule != '' AND archived_at IS NULL").get();
28119
+ return row?.count ?? 0;
28120
+ }
27913
28121
  function handleSseEvents(_req, url, ctx) {
27914
28122
  const agentId = url.searchParams.get("agent_id") || undefined;
27915
28123
  const projectId = url.searchParams.get("project_id") || undefined;
@@ -27988,35 +28196,40 @@ data: ${JSON.stringify({ type: "connected", agent_id: agentId, timestamp: new Da
27988
28196
  });
27989
28197
  }
27990
28198
  function handleHealth(_ctx, json2) {
27991
- const all = listTasks({ limit: 1e4 });
27992
- const stale = all.filter((t) => t.status === "in_progress" && new Date(t.updated_at).getTime() < Date.now() - 30 * 60 * 1000);
27993
- const overdue = all.filter((t) => t.recurrence_rule && t.status === "pending" && t.due_at && t.due_at < new Date().toISOString());
27994
- return json2({ status: stale.length === 0 && overdue.length === 0 ? "ok" : "warn", tasks: all.length, stale: stale.length, overdue_recurring: overdue.length, timestamp: new Date().toISOString() });
28199
+ const stats = getTaskStats();
28200
+ const staleCount = getStaleTasks(30).length;
28201
+ const overdueRecurring = getOverdueTasks().filter((t) => t.recurrence_rule && t.status === "pending").length;
28202
+ return json2({
28203
+ status: staleCount === 0 && overdueRecurring === 0 ? "ok" : "warn",
28204
+ tasks: stats.total,
28205
+ stale: staleCount,
28206
+ overdue_recurring: overdueRecurring,
28207
+ timestamp: new Date().toISOString()
28208
+ });
27995
28209
  }
27996
28210
  function handleHeadlessBoundary(_ctx, json2) {
27997
28211
  const { getHeadlessBoundaryManifest: getHeadlessBoundaryManifest2 } = (init_headless_boundaries(), __toCommonJS(exports_headless_boundaries));
27998
28212
  return json2(getHeadlessBoundaryManifest2());
27999
28213
  }
28000
28214
  function handleStats(_ctx, json2) {
28001
- const all = listTasks({ limit: 1e4 });
28215
+ const stats = getTaskStats();
28216
+ const byStatus = stats.by_status;
28002
28217
  const projects = listProjects();
28003
28218
  const agents = listAgents();
28004
- const staleItems = getStaleTasks(30);
28005
- const nowStr = new Date().toISOString();
28006
- const overdueRecurring = all.filter((t) => t.recurrence_rule && t.status === "pending" && t.due_at && t.due_at < nowStr).length;
28007
- const recurringTasks = all.filter((t) => t.recurrence_rule).length;
28219
+ const staleCount = getStaleTasks(30).length;
28220
+ const overdueRecurring = getOverdueTasks().filter((t) => t.recurrence_rule && t.status === "pending").length;
28008
28221
  return json2({
28009
- total_tasks: all.length,
28010
- pending: all.filter((t) => t.status === "pending").length,
28011
- in_progress: all.filter((t) => t.status === "in_progress").length,
28012
- completed: all.filter((t) => t.status === "completed").length,
28013
- failed: all.filter((t) => t.status === "failed").length,
28014
- cancelled: all.filter((t) => t.status === "cancelled").length,
28222
+ total_tasks: stats.total,
28223
+ pending: byStatus["pending"] ?? 0,
28224
+ in_progress: byStatus["in_progress"] ?? 0,
28225
+ completed: byStatus["completed"] ?? 0,
28226
+ failed: byStatus["failed"] ?? 0,
28227
+ cancelled: byStatus["cancelled"] ?? 0,
28015
28228
  projects: projects.length,
28016
28229
  agents: agents.length,
28017
- stale_count: staleItems.length,
28230
+ stale_count: staleCount,
28018
28231
  overdue_recurring: overdueRecurring,
28019
- recurring_tasks: recurringTasks
28232
+ recurring_tasks: countRecurringTasks()
28020
28233
  });
28021
28234
  }
28022
28235
  async function handleListTasks(_req, url, _ctx, json2, taskToSummary2) {
@@ -28095,27 +28308,34 @@ function handleTasksExport(_req, url, _ctx, _json, taskToSummary2) {
28095
28308
  const summaries = tasks.map((t) => taskToSummary2(t));
28096
28309
  if (format === "csv") {
28097
28310
  const headers = ["id", "short_id", "title", "status", "priority", "project_id", "assigned_to", "agent_id", "created_at", "updated_at", "completed_at", "due_at"];
28098
- const rows = summaries.map((t) => headers.map((h) => {
28099
- const val = t[h];
28311
+ const csvCell = (val) => {
28100
28312
  if (val === null || val === undefined)
28101
28313
  return "";
28102
- const str = String(val);
28103
- return str.includes(",") || str.includes('"') || str.includes(`
28104
- `) ? `"${str.replace(/"/g, '""')}"` : str;
28105
- }).join(","));
28314
+ let str = String(val);
28315
+ if (/^[=+\-@\t\r]/.test(str))
28316
+ str = `'${str}`;
28317
+ if (str.includes(",") || str.includes('"') || str.includes(`
28318
+ `) || str.includes("\r")) {
28319
+ str = `"${str.replace(/"/g, '""')}"`;
28320
+ }
28321
+ return str;
28322
+ };
28323
+ const rows = summaries.map((t) => headers.map((h) => csvCell(t[h])).join(","));
28106
28324
  const csv = [headers.join(","), ...rows].join(`
28107
28325
  `);
28108
28326
  return new Response(csv, {
28109
28327
  headers: {
28110
28328
  "Content-Type": "text/csv",
28111
- "Content-Disposition": "attachment; filename=tasks.csv"
28329
+ "Content-Disposition": "attachment; filename=tasks.csv",
28330
+ ...SECURITY_HEADERS
28112
28331
  }
28113
28332
  });
28114
28333
  }
28115
28334
  return new Response(JSON.stringify(summaries, null, 2), {
28116
28335
  headers: {
28117
28336
  "Content-Type": "application/json",
28118
- "Content-Disposition": "attachment; filename=tasks.json"
28337
+ "Content-Disposition": "attachment; filename=tasks.json",
28338
+ ...SECURITY_HEADERS
28119
28339
  }
28120
28340
  });
28121
28341
  }
@@ -28289,12 +28509,16 @@ async function handlePatchTask(id, req, _ctx, json2, taskToSummary2) {
28289
28509
  if (ALLOWED.has(key))
28290
28510
  safeBody[key] = value;
28291
28511
  }
28512
+ const clientVersion = typeof body["version"] === "number" ? body["version"] : task.version;
28292
28513
  const updated = updateTask(id, {
28293
28514
  ...safeBody,
28294
- version: task.version
28515
+ version: clientVersion
28295
28516
  });
28296
28517
  return json2(taskToSummary2(updated));
28297
28518
  } catch (e) {
28519
+ const mapped = mapTaskError(e, json2);
28520
+ if (mapped)
28521
+ return mapped;
28298
28522
  return json2({ error: e instanceof Error ? e.message : "Failed to update task" }, 500);
28299
28523
  }
28300
28524
  }
@@ -28310,6 +28534,9 @@ function handleStartTask(id, ctx, json2, taskToSummary2) {
28310
28534
  ctx.broadcastEvent({ type: "task", task_id: task.id, action: "started", agent_id: "dashboard", project_id: task.project_id });
28311
28535
  return json2(taskToSummary2(task));
28312
28536
  } catch (e) {
28537
+ const mapped = mapTaskError(e, json2);
28538
+ if (mapped)
28539
+ return mapped;
28313
28540
  return json2({ error: e instanceof Error ? e.message : "Failed to start task" }, 500);
28314
28541
  }
28315
28542
  }
@@ -28329,6 +28556,9 @@ function handleCompleteTask(id, ctx, json2, taskToSummary2) {
28329
28556
  ctx.broadcastEvent({ type: "task", task_id: task.id, action: "completed", agent_id: "dashboard", project_id: task.project_id });
28330
28557
  return json2(taskToSummary2(task));
28331
28558
  } catch (e) {
28559
+ const mapped = mapTaskError(e, json2);
28560
+ if (mapped)
28561
+ return mapped;
28332
28562
  return json2({ error: e instanceof Error ? e.message : "Failed to complete task" }, 500);
28333
28563
  }
28334
28564
  }
@@ -28653,6 +28883,8 @@ function handleStaticFiles(path, method, ctx, json2, serveStaticFile2) {
28653
28883
  }
28654
28884
  var init_routes = __esm(() => {
28655
28885
  init_tasks();
28886
+ init_database();
28887
+ init_types();
28656
28888
  init_projects();
28657
28889
  init_agents();
28658
28890
  init_plans();
@@ -51852,14 +52084,16 @@ function printHelp() {
51852
52084
  Start the @hasna/todos MCP server.
51853
52085
 
51854
52086
  Options:
51855
- --stdio Use stdio transport
51856
- --port <port> Use Streamable HTTP on the given port
52087
+ --stdio Use stdio transport (default)
52088
+ --http Use Streamable HTTP transport
52089
+ --port <port> Use Streamable HTTP on the given port (implies --http)
51857
52090
  -V, --version output the version number
51858
52091
  -h, --help display help for command
51859
52092
 
51860
52093
  Environment:
51861
- TODOS_MCP_STDIO=true Force stdio transport
51862
- TODOS_MCP_PORT=<port> HTTP port when not using stdio
52094
+ MCP_STDIO=1 Force stdio transport
52095
+ MCP_HTTP=1 Use Streamable HTTP transport
52096
+ MCP_HTTP_PORT=<port> HTTP port when using HTTP transport
51863
52097
  TODOS_PROFILE=<profile> Tool profile filter
51864
52098
  TODOS_TOOL_GROUPS=<list> Comma-separated tool group filter`);
51865
52099
  }
@@ -51948,8 +52182,22 @@ function formatError(error) {
51948
52182
  function resolveId(partialId, table = "tasks") {
51949
52183
  const db = getDatabase();
51950
52184
  const id = resolvePartialId(db, table, partialId);
51951
- if (!id)
51952
- throw new Error(`Could not resolve ID: ${partialId}`);
52185
+ if (!id) {
52186
+ switch (table) {
52187
+ case "tasks":
52188
+ throw new TaskNotFoundError(partialId);
52189
+ case "projects":
52190
+ throw new ProjectNotFoundError(partialId);
52191
+ case "plans":
52192
+ throw new PlanNotFoundError(partialId);
52193
+ case "task_lists":
52194
+ throw new TaskListNotFoundError(partialId);
52195
+ case "agents":
52196
+ throw new AgentNotFoundError(partialId);
52197
+ default:
52198
+ throw new TaskNotFoundError(partialId);
52199
+ }
52200
+ }
51953
52201
  return id;
51954
52202
  }
51955
52203
  function formatTask(task2) {
@@ -52038,8 +52286,9 @@ function buildServer() {
52038
52286
  return server;
52039
52287
  }
52040
52288
  async function main() {
52041
- const { isStdioMode, resolveHttpPort } = await Promise.resolve().then(() => (init_http(), exports_http));
52042
- if (isStdioMode()) {
52289
+ const { isHttpMode, resolveHttpPort } = await Promise.resolve().then(() => (init_http(), exports_http));
52290
+ const portRequested = process.argv.some((arg) => arg === "--port" || arg.startsWith("--port="));
52291
+ if (!isHttpMode() && !portRequested) {
52043
52292
  const server = buildServer();
52044
52293
  const transport = new StdioServerTransport;
52045
52294
  await server.connect(transport);
@@ -52215,7 +52464,7 @@ function checkAuth(req, apiKey) {
52215
52464
  if (!apiKey && !generatedKeysEnabled)
52216
52465
  return null;
52217
52466
  const provided = getProvidedApiKey(req);
52218
- const matchesEnvKey = Boolean(apiKey && provided && provided === apiKey);
52467
+ const matchesEnvKey = Boolean(apiKey && provided && safeEqualStrings(provided, apiKey));
52219
52468
  const matchesGeneratedKey = Boolean(provided && verifyApiKey(provided));
52220
52469
  if (!matchesEnvKey && !matchesGeneratedKey) {
52221
52470
  return new Response(JSON.stringify({ error: "Unauthorized" }), {
@@ -52225,6 +52474,15 @@ function checkAuth(req, apiKey) {
52225
52474
  }
52226
52475
  return null;
52227
52476
  }
52477
+ function resolveClientIp(req, server) {
52478
+ const trustProxy = process.env["TODOS_TRUST_PROXY"] === "1" || process.env["TODOS_TRUST_PROXY"] === "true";
52479
+ if (trustProxy) {
52480
+ const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim();
52481
+ if (forwarded)
52482
+ return forwarded;
52483
+ }
52484
+ return server.requestIP(req)?.address || "unknown";
52485
+ }
52228
52486
  function checkRateLimit(ip) {
52229
52487
  const now4 = Date.now();
52230
52488
  const entry = rateLimitMap.get(ip);
@@ -52352,7 +52610,7 @@ Dashboard not found at: ${dashboardDir}`);
52352
52610
  const server = Bun.serve({
52353
52611
  port,
52354
52612
  hostname: hostname3,
52355
- async fetch(req) {
52613
+ async fetch(req, server2) {
52356
52614
  const url = new URL(req.url);
52357
52615
  const path = url.pathname;
52358
52616
  const method = req.method;
@@ -52364,15 +52622,6 @@ Dashboard not found at: ${dashboardDir}`);
52364
52622
  Vary: "Origin"
52365
52623
  } : undefined;
52366
52624
  const jsonWithCors = (data, status = 200) => json(data, status, corsHeaders);
52367
- if (path === "/health" && method === "GET") {
52368
- const { healthResponse: healthResponse2 } = await Promise.resolve().then(() => (init_http(), exports_http));
52369
- return healthResponse2("todos");
52370
- }
52371
- if (path === "/mcp") {
52372
- const { handleMcpHttpRequest: handleMcpHttpRequest2 } = await Promise.resolve().then(() => (init_http(), exports_http));
52373
- const { buildServer: buildServer2 } = await Promise.resolve().then(() => (init_mcp2(), exports_mcp));
52374
- return handleMcpHttpRequest2(req, buildServer2);
52375
- }
52376
52625
  if (method === "OPTIONS") {
52377
52626
  return new Response(null, {
52378
52627
  headers: corsHeaders || {
@@ -52380,7 +52629,7 @@ Dashboard not found at: ${dashboardDir}`);
52380
52629
  }
52381
52630
  });
52382
52631
  }
52383
- const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
52632
+ const ip = resolveClientIp(req, server2);
52384
52633
  const rl = checkRateLimit(ip);
52385
52634
  if (!rl.allowed) {
52386
52635
  return new Response(JSON.stringify({ error: "Too many requests", retry_after: rl.retryAfter }), {
@@ -52388,6 +52637,18 @@ Dashboard not found at: ${dashboardDir}`);
52388
52637
  headers: { "Content-Type": "application/json", "Retry-After": String(rl.retryAfter ?? 60), ...SECURITY_HEADERS }
52389
52638
  });
52390
52639
  }
52640
+ if (path === "/health" && method === "GET") {
52641
+ const { healthResponse: healthResponse2 } = await Promise.resolve().then(() => (init_http(), exports_http));
52642
+ return healthResponse2("todos");
52643
+ }
52644
+ if (path === "/mcp") {
52645
+ const authError = checkAuth(req, apiKey);
52646
+ if (authError)
52647
+ return authError;
52648
+ const { handleMcpHttpRequest: handleMcpHttpRequest2 } = await Promise.resolve().then(() => (init_http(), exports_http));
52649
+ const { buildServer: buildServer2 } = await Promise.resolve().then(() => (init_mcp2(), exports_mcp));
52650
+ return handleMcpHttpRequest2(req, buildServer2);
52651
+ }
52391
52652
  if (path.startsWith("/api/")) {
52392
52653
  const authError = checkAuth(req, apiKey);
52393
52654
  if (authError)
@@ -56237,6 +56498,7 @@ function registerQueryCommands(program2) {
56237
56498
  });
56238
56499
  program2.command("next").description("Show the best pending task to work on next").option("--agent <id>", "Prefer tasks assigned to this agent").option("--project <id>", "Filter to project").option("-j, --json", "Output as JSON").action(async (opts) => {
56239
56500
  const globalOpts = program2.opts();
56501
+ const json2 = opts.json || globalOpts.json;
56240
56502
  const db = getDatabase();
56241
56503
  const filters = {};
56242
56504
  const projectInput = opts.project || globalOpts.project;
@@ -56247,10 +56509,14 @@ function registerQueryCommands(program2) {
56247
56509
  }
56248
56510
  const task2 = getNextTask(opts.agent, Object.keys(filters).length ? filters : undefined, db);
56249
56511
  if (!task2) {
56512
+ if (json2) {
56513
+ console.log(JSON.stringify(null));
56514
+ return;
56515
+ }
56250
56516
  console.log(chalk7.dim("No tasks available."));
56251
56517
  return;
56252
56518
  }
56253
- if (opts.json) {
56519
+ if (json2) {
56254
56520
  console.log(JSON.stringify(task2, null, 2));
56255
56521
  return;
56256
56522
  }
@@ -56260,16 +56526,26 @@ function registerQueryCommands(program2) {
56260
56526
  console.log(chalk7.dim(` ${task2.description.slice(0, 100)}`));
56261
56527
  });
56262
56528
  program2.command("claim <agent>").description("Atomically claim the best pending task for an agent").option("--project <id>", "Filter to project").option("--steal-stale", "Steal the highest-priority stale task when no pending task is available").option("--stale-minutes <n>", "How long a task must be stale before stealing (default: 30)", "30").option("-j, --json", "Output as JSON").action(async (agent, opts) => {
56529
+ const globalOpts = program2.opts();
56530
+ const json2 = opts.json || globalOpts.json;
56263
56531
  const db = getDatabase();
56264
56532
  const filters = {};
56265
- if (opts.project)
56266
- filters.project_id = opts.project;
56533
+ const projectInput = opts.project || globalOpts.project;
56534
+ if (projectInput) {
56535
+ const pid = autoProject({ project: projectInput }) || resolvePartialId(db, "projects", projectInput) || db.query("SELECT id FROM projects WHERE path = ? OR name = ? OR task_list_id = ?").get(projectInput, projectInput, projectInput)?.id;
56536
+ if (pid)
56537
+ filters.project_id = pid;
56538
+ }
56267
56539
  const task2 = opts.stealStale ? (await Promise.resolve().then(() => (init_tasks(), exports_tasks))).claimOrSteal(agent, { ...filters, stale_minutes: parseInt(opts.staleMinutes, 10) }, db)?.task ?? null : claimNextTask(agent, Object.keys(filters).length ? filters : undefined, db);
56268
56540
  if (!task2) {
56541
+ if (json2) {
56542
+ console.log(JSON.stringify(null));
56543
+ return;
56544
+ }
56269
56545
  console.log(chalk7.dim("No tasks available to claim."));
56270
56546
  return;
56271
56547
  }
56272
- if (opts.json) {
56548
+ if (json2) {
56273
56549
  console.log(JSON.stringify(task2, null, 2));
56274
56550
  return;
56275
56551
  }
@@ -56290,12 +56566,14 @@ function registerQueryCommands(program2) {
56290
56566
  console.log(chalk7.green(`Stolen: ${task2.short_id || task2.id.slice(0, 8)} | ${task2.priority} | ${task2.title}`));
56291
56567
  });
56292
56568
  program2.command("status").description("Show full project health snapshot").option("--agent <id>", "Include next task for this agent").option("--project <id>", "Filter to project").option("-j, --json", "Output as JSON").action(async (opts) => {
56569
+ const globalOpts = program2.opts();
56570
+ const json2 = opts.json || globalOpts.json;
56293
56571
  const db = getDatabase();
56294
56572
  const filters = {};
56295
56573
  if (opts.project)
56296
56574
  filters.project_id = opts.project;
56297
56575
  const s = getStatus(Object.keys(filters).length ? filters : undefined, opts.agent, undefined, db);
56298
- if (opts.json) {
56576
+ if (json2) {
56299
56577
  console.log(JSON.stringify(s, null, 2));
56300
56578
  return;
56301
56579
  }
@@ -56432,6 +56710,8 @@ Blocked:`));
56432
56710
  console.log();
56433
56711
  });
56434
56712
  program2.command("fail <id>").description("Mark a task as failed with optional reason and retry").option("--reason <text>", "Why it failed").option("--agent <id>", "Agent reporting the failure").option("--retry", "Auto-create a retry copy").option("-j, --json", "Output as JSON").action(async (id, opts) => {
56713
+ const globalOpts = program2.opts();
56714
+ const json2 = opts.json || globalOpts.json;
56435
56715
  const db = getDatabase();
56436
56716
  const resolvedId = resolvePartialId(db, "tasks", id);
56437
56717
  if (!resolvedId) {
@@ -56439,7 +56719,7 @@ Blocked:`));
56439
56719
  process.exit(1);
56440
56720
  }
56441
56721
  const result = failTask(resolvedId, opts.agent, opts.reason, { retry: opts.retry }, db);
56442
- if (opts.json) {
56722
+ if (json2) {
56443
56723
  console.log(JSON.stringify(result, null, 2));
56444
56724
  return;
56445
56725
  }
@@ -56450,12 +56730,14 @@ Blocked:`));
56450
56730
  console.log(chalk7.yellow(`Retry created: ${result.retryTask.short_id || result.retryTask.id.slice(0, 8)} | ${result.retryTask.title}`));
56451
56731
  });
56452
56732
  program2.command("active").description("Show all currently in-progress tasks").option("--project <id>", "Filter to project").option("-j, --json", "Output as JSON").action(async (opts) => {
56733
+ const globalOpts = program2.opts();
56734
+ const json2 = opts.json || globalOpts.json;
56453
56735
  const db = getDatabase();
56454
56736
  const filters = {};
56455
56737
  if (opts.project)
56456
56738
  filters.project_id = opts.project;
56457
56739
  const work = getActiveWork(Object.keys(filters).length ? filters : undefined, db);
56458
- if (opts.json) {
56740
+ if (json2) {
56459
56741
  console.log(JSON.stringify(work, null, 2));
56460
56742
  return;
56461
56743
  }
@@ -56471,12 +56753,14 @@ Blocked:`));
56471
56753
  }
56472
56754
  });
56473
56755
  program2.command("stale").description("Find tasks stuck in_progress with no recent activity").option("--minutes <n>", "Stale threshold in minutes", "30").option("--project <id>", "Filter to project").option("-j, --json", "Output as JSON").action(async (opts) => {
56756
+ const globalOpts = program2.opts();
56757
+ const json2 = opts.json || globalOpts.json;
56474
56758
  const db = getDatabase();
56475
56759
  const filters = {};
56476
56760
  if (opts.project)
56477
56761
  filters.project_id = opts.project;
56478
56762
  const tasks = getStaleTasks(parseInt(opts.minutes, 10), Object.keys(filters).length ? filters : undefined, db);
56479
- if (opts.json) {
56763
+ if (json2) {
56480
56764
  console.log(JSON.stringify(tasks, null, 2));
56481
56765
  return;
56482
56766
  }
@@ -56500,7 +56784,7 @@ Blocked:`));
56500
56784
  project_id: projectId,
56501
56785
  limit: opts.limit ? parseInt(opts.limit, 10) : undefined
56502
56786
  }, db);
56503
- if (opts.json) {
56787
+ if (opts.json || globalOpts.json) {
56504
56788
  console.log(JSON.stringify(result, null, 2));
56505
56789
  return;
56506
56790
  }
@@ -58653,7 +58937,7 @@ function envOption(value) {
58653
58937
  }
58654
58938
  function registerClaude(binPath, global) {
58655
58939
  const scope = global ? "user" : "project";
58656
- const cmd = `claude mcp add --transport stdio --scope ${scope} todos -- ${binPath}`;
58940
+ const cmd = `claude mcp add --transport stdio --scope ${scope} todos -- ${binPath} --stdio`;
58657
58941
  try {
58658
58942
  execSync3(cmd, { stdio: "pipe" });
58659
58943
  console.log(chalk8.green(`Claude Code (${scope}): registered via 'claude mcp add'`));
@@ -58678,7 +58962,7 @@ function registerCodex(binPath) {
58678
58962
  const block = `
58679
58963
  [mcp_servers.todos]
58680
58964
  command = "${binPath}"
58681
- args = []
58965
+ args = ["--stdio"]
58682
58966
  `;
58683
58967
  content = content.trimEnd() + `
58684
58968
  ` + block;
@@ -58706,7 +58990,7 @@ function registerGemini(binPath) {
58706
58990
  const servers = config["mcpServers"];
58707
58991
  servers["todos"] = {
58708
58992
  command: binPath,
58709
- args: []
58993
+ args: ["--stdio"]
58710
58994
  };
58711
58995
  writeJsonFile2(configPath, config);
58712
58996
  console.log(chalk8.green(`Gemini CLI: registered in ${configPath}`));
@@ -58837,7 +59121,11 @@ exit 0
58837
59121
  unregisterMcp(opts.unregister, opts.global);
58838
59122
  return;
58839
59123
  }
58840
- await Promise.resolve().then(() => (init_mcp2(), exports_mcp));
59124
+ const { buildServer: buildServer2 } = await Promise.resolve().then(() => (init_mcp2(), exports_mcp));
59125
+ const { StdioServerTransport: StdioServerTransport2 } = await import("@modelcontextprotocol/sdk/server/stdio.js");
59126
+ const server = buildServer2();
59127
+ const transport = new StdioServerTransport2;
59128
+ await server.connect(transport);
58841
59129
  });
58842
59130
  program2.command("import <url>").description("Import a GitHub issue as a task").option("--project <id>", "Project ID").option("--list <id>", "Task list ID").action(async (url, opts) => {
58843
59131
  const globalOpts = program2.opts();
@@ -61229,7 +61517,6 @@ var init_cli_mcp_parity = __esm(() => {
61229
61517
  "bulk_create_tasks",
61230
61518
  "get_next_task",
61231
61519
  "claim_next_task",
61232
- "get_active_work",
61233
61520
  "get_stale_tasks",
61234
61521
  "get_my_tasks",
61235
61522
  "get_blocked_tasks",
@@ -62261,11 +62548,8 @@ var init_cli_mcp_parity = __esm(() => {
62261
62548
  "delete_search_view",
62262
62549
  "get_status",
62263
62550
  "standup",
62264
- "get_task_stats",
62265
62551
  "get_context",
62266
- "task_context",
62267
- "get_task_graph",
62268
- "get_recent_activity"
62552
+ "task_context"
62269
62553
  ],
62270
62554
  jsonContracts: ["task", "saved_search_view", "saved_search_run_result", "status_summary", "audit_history", "structured_error", "api_error"],
62271
62555
  errorContracts: ["structured_error", "api_error"],
@@ -65778,7 +66062,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
65778
66062
  const placeholders2 = presentColumns.map(() => "?").join(", ");
65779
66063
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
65780
66064
  const updateColumns = presentColumns.filter((column) => column !== "id");
65781
- const updateSet = updateColumns.map((column) => `${column} = excluded.${column}`).join(", ");
66065
+ const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
65782
66066
  const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
65783
66067
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})
65784
66068
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})`;
@@ -66222,7 +66506,9 @@ class PostgresTodosSyncStore {
66222
66506
  deleted_at = EXCLUDED.deleted_at,
66223
66507
  source_machine_id = EXCLUDED.source_machine_id,
66224
66508
  version = EXCLUDED.version
66225
- WHERE ${this.tableName}.updated_at <= EXCLUDED.updated_at`, [
66509
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
66510
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
66511
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
66226
66512
  this.service,
66227
66513
  entry.type,
66228
66514
  entry.id,
@@ -66575,7 +66861,7 @@ class PostgresJsonRecordStore {
66575
66861
  async upsert(type, value, context = {}) {
66576
66862
  await this.ensureSchema();
66577
66863
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
66578
- await this.options.client.query(`INSERT INTO ${this.tableName} (
66864
+ const result = await this.options.client.query(`INSERT INTO ${this.tableName} (
66579
66865
  service, object_type, object_id, payload, updated_at,
66580
66866
  deleted_at, source_machine_id, version
66581
66867
  ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, $7)
@@ -66585,7 +66871,11 @@ class PostgresJsonRecordStore {
66585
66871
  deleted_at = NULL,
66586
66872
  source_machine_id = EXCLUDED.source_machine_id,
66587
66873
  version = EXCLUDED.version
66588
- WHERE ${this.tableName}.updated_at IS NULL OR ${this.tableName}.updated_at <= EXCLUDED.updated_at`, [
66874
+ WHERE ${this.tableName}.updated_at IS NULL
66875
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
66876
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
66877
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
66878
+ RETURNING object_id`, [
66589
66879
  this.service,
66590
66880
  type,
66591
66881
  value.id,
@@ -66594,8 +66884,27 @@ class PostgresJsonRecordStore {
66594
66884
  context.requestId ?? this.sourceMachineId ?? null,
66595
66885
  numberValue3(value.version)
66596
66886
  ]);
66887
+ if (result.rows.length === 0) {
66888
+ const current = await this.get(type, value.id);
66889
+ if (current)
66890
+ return current;
66891
+ }
66597
66892
  return value;
66598
66893
  }
66894
+ async incrementProjectTaskCounter(projectId, _context = {}) {
66895
+ await this.ensureSchema();
66896
+ const result = await this.options.client.query(`UPDATE ${this.tableName}
66897
+ SET payload = jsonb_set(payload, '{task_counter}',
66898
+ to_jsonb(COALESCE((payload->>'task_counter')::bigint, 0) + 1)),
66899
+ updated_at = $3::timestamptz,
66900
+ version = COALESCE(version, 0) + 1
66901
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL
66902
+ RETURNING payload->>'task_counter' AS counter`, [this.service, projectId, new Date().toISOString()]);
66903
+ const row = result.rows[0];
66904
+ if (!row || row.counter === null || row.counter === undefined)
66905
+ return null;
66906
+ return Number(row.counter);
66907
+ }
66599
66908
  async delete(type, id, context = {}) {
66600
66909
  await this.ensureSchema();
66601
66910
  const existing = await this.get(type, id);
@@ -66751,6 +67060,9 @@ async function updateTask2(id, input, store) {
66751
67060
  }
66752
67061
  async function startTask2(id, agentId, store) {
66753
67062
  const task2 = await requireRecord("tasks", id, store);
67063
+ if (task2.status !== "pending" && task2.status !== "in_progress") {
67064
+ throw new Error(`Task is ${task2.status} and cannot be started by ${agentId}`);
67065
+ }
66754
67066
  return patchTask(task2, {
66755
67067
  status: "in_progress",
66756
67068
  assigned_to: task2.assigned_to ?? agentId,
@@ -66820,8 +67132,20 @@ async function getNextTask2(filters, store) {
66820
67132
  return (await listTasks3({ ...filters, status: "pending", limit: 1 }, store))[0] ?? null;
66821
67133
  }
66822
67134
  async function claimNextTask2(agentId, filters, store) {
66823
- const task2 = await getNextTask2(filters, store);
66824
- return task2 ? startTask2(task2.id, agentId, store) : null;
67135
+ const MAX_ATTEMPTS = 25;
67136
+ const tried = new Set;
67137
+ for (let attempt = 0;attempt < MAX_ATTEMPTS; attempt++) {
67138
+ const task2 = await getNextTask2(filters, store);
67139
+ if (!task2)
67140
+ return null;
67141
+ if (tried.has(task2.id))
67142
+ return null;
67143
+ tried.add(task2.id);
67144
+ try {
67145
+ return await startTask2(task2.id, agentId, store);
67146
+ } catch {}
67147
+ }
67148
+ return null;
66825
67149
  }
66826
67150
  async function getActiveWork2(filters, store) {
66827
67151
  const tasks = await listTasks3({ ...filters, status: "in_progress" }, store);
@@ -67094,12 +67418,9 @@ async function nextTaskShortId2(projectId, store, context) {
67094
67418
  const project = await store.get("projects", projectId);
67095
67419
  if (!project?.task_prefix)
67096
67420
  return null;
67097
- const counter = project.task_counter + 1;
67098
- await store.upsert("projects", {
67099
- ...project,
67100
- task_counter: counter,
67101
- updated_at: new Date().toISOString()
67102
- }, context);
67421
+ const counter = await store.incrementProjectTaskCounter(projectId, context);
67422
+ if (counter === null)
67423
+ return null;
67103
67424
  return `${project.task_prefix}-${String(counter).padStart(5, "0")}`;
67104
67425
  }
67105
67426
  async function generateProjectPrefix(name, store) {