@hasna/todos 0.11.58 → 0.11.60

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.
Files changed (41) hide show
  1. package/README.md +66 -2
  2. package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
  3. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  4. package/dist/cli/index.js +1518 -197
  5. package/dist/contracts.d.ts.map +1 -1
  6. package/dist/contracts.js +600 -14
  7. package/dist/db/findings.d.ts +108 -0
  8. package/dist/db/findings.d.ts.map +1 -0
  9. package/dist/db/migrations.d.ts.map +1 -1
  10. package/dist/db/schema.d.ts.map +1 -1
  11. package/dist/db/task-crud.d.ts +3 -1
  12. package/dist/db/task-crud.d.ts.map +1 -1
  13. package/dist/db/task-runs.d.ts +56 -0
  14. package/dist/db/task-runs.d.ts.map +1 -1
  15. package/dist/db/tasks.d.ts +2 -2
  16. package/dist/db/tasks.d.ts.map +1 -1
  17. package/dist/index.d.ts +5 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +968 -15
  20. package/dist/json-contracts.d.ts.map +1 -1
  21. package/dist/lib/access-profiles.d.ts.map +1 -1
  22. package/dist/lib/event-hooks.d.ts +1 -1
  23. package/dist/lib/event-hooks.d.ts.map +1 -1
  24. package/dist/lib/shared-events.d.ts +1 -1
  25. package/dist/lib/shared-events.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1082 -27
  27. package/dist/mcp/token-utils.d.ts.map +1 -1
  28. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  29. package/dist/mcp/tools/task-resources.d.ts.map +1 -1
  30. package/dist/mcp.js +12 -1
  31. package/dist/registry.js +600 -14
  32. package/dist/release-provenance.json +3 -3
  33. package/dist/server/index.js +1082 -27
  34. package/dist/server/routes.d.ts +1 -0
  35. package/dist/server/routes.d.ts.map +1 -1
  36. package/dist/server/serve.d.ts +2 -0
  37. package/dist/server/serve.d.ts.map +1 -1
  38. package/dist/storage.js +473 -11
  39. package/dist/types/index.d.ts +11 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +2 -2
package/dist/storage.js CHANGED
@@ -1269,6 +1269,49 @@ var init_migrations = __esm(() => {
1269
1269
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1270
1270
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1271
1271
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1272
+ `,
1273
+ `
1274
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1275
+ id TEXT PRIMARY KEY,
1276
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1277
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1278
+ key TEXT NOT NULL,
1279
+ loop_id TEXT,
1280
+ loop_run_id TEXT,
1281
+ metadata TEXT DEFAULT '{}',
1282
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1283
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1284
+ UNIQUE(task_id, key)
1285
+ );
1286
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1287
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1288
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1289
+
1290
+ CREATE TABLE IF NOT EXISTS task_findings (
1291
+ id TEXT PRIMARY KEY,
1292
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1293
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1294
+ fingerprint TEXT NOT NULL,
1295
+ title TEXT NOT NULL,
1296
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1297
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1298
+ source TEXT,
1299
+ summary TEXT,
1300
+ artifact_path TEXT,
1301
+ metadata TEXT DEFAULT '{}',
1302
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1303
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1304
+ resolved_at TEXT,
1305
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1306
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1307
+ UNIQUE(task_id, fingerprint)
1308
+ );
1309
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1310
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1311
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1312
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1313
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1314
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1272
1315
  `
1273
1316
  ];
1274
1317
  });
@@ -1706,6 +1749,47 @@ function ensureSchema(db) {
1706
1749
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1707
1750
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1708
1751
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1752
+ ensureTable("task_run_transactions", `
1753
+ CREATE TABLE task_run_transactions (
1754
+ id TEXT PRIMARY KEY,
1755
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1756
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1757
+ key TEXT NOT NULL,
1758
+ loop_id TEXT,
1759
+ loop_run_id TEXT,
1760
+ metadata TEXT DEFAULT '{}',
1761
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1762
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1763
+ UNIQUE(task_id, key)
1764
+ )`);
1765
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1766
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1767
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1768
+ ensureTable("task_findings", `
1769
+ CREATE TABLE task_findings (
1770
+ id TEXT PRIMARY KEY,
1771
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1772
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1773
+ fingerprint TEXT NOT NULL,
1774
+ title TEXT NOT NULL,
1775
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1776
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1777
+ source TEXT,
1778
+ summary TEXT,
1779
+ artifact_path TEXT,
1780
+ metadata TEXT DEFAULT '{}',
1781
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1782
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1783
+ resolved_at TEXT,
1784
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1785
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1786
+ UNIQUE(task_id, fingerprint)
1787
+ )`);
1788
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1789
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1790
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1791
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1792
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1709
1793
  ensureTable("inbox_items", `
1710
1794
  CREATE TABLE inbox_items (
1711
1795
  id TEXT PRIMARY KEY,
@@ -4180,6 +4264,7 @@ var LOCAL_EVENT_TYPES = [
4180
4264
  "task.blocked",
4181
4265
  "task.started",
4182
4266
  "task.completed",
4267
+ "task.updated",
4183
4268
  "task.due",
4184
4269
  "task.due_soon",
4185
4270
  "task.failed",
@@ -4418,7 +4503,7 @@ async function testLocalEventHook(name, input) {
4418
4503
  return emitLocalEventHooks({ ...input, hooks: [hook] });
4419
4504
  }
4420
4505
 
4421
- // node_modules/.bun/@hasna+events@0.1.9/node_modules/@hasna/events/dist/index.js
4506
+ // node_modules/.bun/@hasna+events@0.1.10/node_modules/@hasna/events/dist/index.js
4422
4507
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
4423
4508
  import { existsSync as existsSync5 } from "fs";
4424
4509
  import { homedir } from "os";
@@ -4465,14 +4550,40 @@ function matchRecord(input, matcher) {
4465
4550
  return true;
4466
4551
  return Object.entries(matcher).every(([path, expected]) => {
4467
4552
  const actual = getPathValue(input, path);
4468
- if (typeof expected === "string" || Array.isArray(expected)) {
4469
- return matchString(actual === undefined ? undefined : String(actual), expected, {
4470
- segmentSafe: path.endsWith("_path") || path.endsWith(".path")
4471
- });
4472
- }
4473
- return actual === expected;
4553
+ return matchField(actual, expected, path);
4474
4554
  });
4475
4555
  }
4556
+ function matchField(actual, expected, path) {
4557
+ if (isNegativeMatcher(expected)) {
4558
+ return !matchPositiveField(actual, expected.not, path);
4559
+ }
4560
+ return matchPositiveField(actual, expected, path);
4561
+ }
4562
+ function matchPositiveField(actual, expected, path) {
4563
+ if (typeof expected === "string" || Array.isArray(expected)) {
4564
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
4565
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
4566
+ }));
4567
+ }
4568
+ if (Array.isArray(actual)) {
4569
+ return actual.some((item) => item === expected);
4570
+ }
4571
+ return actual === expected;
4572
+ }
4573
+ function stringCandidates(actual) {
4574
+ if (actual === undefined)
4575
+ return [];
4576
+ if (Array.isArray(actual)) {
4577
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
4578
+ }
4579
+ return [String(actual)];
4580
+ }
4581
+ function isPrimitiveFieldValue(value) {
4582
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
4583
+ }
4584
+ function isNegativeMatcher(value) {
4585
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
4586
+ }
4476
4587
  function eventMatchesFilter(event, filter) {
4477
4588
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
4478
4589
  }
@@ -5079,9 +5190,66 @@ function taskEventData(task, extra = {}) {
5079
5190
  started_at: task.started_at,
5080
5191
  completed_at: task.completed_at,
5081
5192
  due_at: task.due_at,
5193
+ requires_approval: task.requires_approval,
5194
+ approved_by: task.approved_by,
5195
+ approved_at: task.approved_at,
5082
5196
  ...extra
5083
5197
  };
5084
5198
  }
5199
+ function booleanField(value) {
5200
+ if (typeof value === "boolean")
5201
+ return value;
5202
+ if (typeof value === "number") {
5203
+ if (value === 1)
5204
+ return true;
5205
+ if (value === 0)
5206
+ return false;
5207
+ }
5208
+ if (typeof value === "string") {
5209
+ const normalized = value.trim().toLowerCase();
5210
+ if (["true", "1", "yes", "on"].includes(normalized))
5211
+ return true;
5212
+ if (["false", "0", "no", "off"].includes(normalized))
5213
+ return false;
5214
+ }
5215
+ return;
5216
+ }
5217
+ function objectField(value) {
5218
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
5219
+ }
5220
+ function firstBoolean(records, keys) {
5221
+ for (const record of records) {
5222
+ for (const key of keys) {
5223
+ const value = booleanField(record[key]);
5224
+ if (value !== undefined)
5225
+ return value;
5226
+ }
5227
+ }
5228
+ return;
5229
+ }
5230
+ function routingAutomationMetadata(task) {
5231
+ const automation = objectField(task.metadata.automation);
5232
+ const records = [task.metadata];
5233
+ if (automation)
5234
+ records.push(automation);
5235
+ const result = {};
5236
+ const aliases = [
5237
+ ["allowed", ["allowed", "automation_allowed", "automationAllowed"]],
5238
+ ["no_auto", ["no_auto", "noAuto"]],
5239
+ ["manual", ["manual"]],
5240
+ ["manual_required", ["manual_required", "manualRequired"]],
5241
+ ["requires_approval", ["requires_approval", "requiresApproval"]],
5242
+ ["approval_required", ["approval_required", "approvalRequired"]]
5243
+ ];
5244
+ for (const [canonical, keys] of aliases) {
5245
+ const value = firstBoolean(records, keys);
5246
+ if (value !== undefined)
5247
+ result[canonical] = value;
5248
+ }
5249
+ if (task.requires_approval)
5250
+ result.requires_approval = true;
5251
+ return Object.keys(result).length > 0 ? result : undefined;
5252
+ }
5085
5253
  function taskEventMetadata(task) {
5086
5254
  const metadata = {
5087
5255
  package: "@hasna/todos",
@@ -5092,6 +5260,14 @@ function taskEventMetadata(task) {
5092
5260
  task_list_id: task.task_list_id,
5093
5261
  working_dir: task.working_dir
5094
5262
  };
5263
+ const routeEnabled = booleanField(task.metadata.route_enabled);
5264
+ if (routeEnabled !== undefined) {
5265
+ metadata.route_enabled = routeEnabled;
5266
+ }
5267
+ const automation = routingAutomationMetadata(task);
5268
+ if (automation) {
5269
+ metadata.automation = automation;
5270
+ }
5095
5271
  try {
5096
5272
  const project = task.project_id ? getProject(task.project_id) : null;
5097
5273
  const projectPath = project ? readMachineLocalPath(project) ?? project.path : task.working_dir;
@@ -5109,9 +5285,6 @@ function taskEventMetadata(task) {
5109
5285
  if (projectPath) {
5110
5286
  metadata.project_kind = classifyProjectKind(projectPath);
5111
5287
  metadata.project_is_worktree = isWorktreePath(projectPath);
5112
- if (typeof task.metadata.route_enabled === "boolean") {
5113
- metadata.route_enabled = task.metadata.route_enabled;
5114
- }
5115
5288
  metadata.working_dir = task.working_dir ?? projectPath;
5116
5289
  }
5117
5290
  const taskList = task.task_list_id ? getTaskList(task.task_list_id) ?? (project ? getTaskListBySlug(task.task_list_id, project.id) : null) : project?.task_list_id ? getTaskListBySlug(project.task_list_id, project.id) : null;
@@ -5518,6 +5691,17 @@ function replaceTaskTags(taskId, tags, db) {
5518
5691
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
5519
5692
  insertTaskTags(taskId, tags, db);
5520
5693
  }
5694
+ function addMetadataConditions(metadata, conditions, params) {
5695
+ if (!metadata)
5696
+ return;
5697
+ for (const [key, value] of Object.entries(metadata)) {
5698
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
5699
+ throw new Error(`Invalid metadata filter key: ${key}`);
5700
+ }
5701
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
5702
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
5703
+ }
5704
+ }
5521
5705
  function createTask(input, db) {
5522
5706
  const d = db || getDatabase();
5523
5707
  const timestamp = now();
@@ -5700,6 +5884,7 @@ function listTasks(filter = {}, db) {
5700
5884
  params.push(filter.task_type);
5701
5885
  }
5702
5886
  }
5887
+ addMetadataConditions(filter.metadata, conditions, params);
5703
5888
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
5704
5889
  if (filter.cursor) {
5705
5890
  try {
@@ -5724,6 +5909,54 @@ function listTasks(filter = {}, db) {
5724
5909
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
5725
5910
  return rows.map(rowToTask);
5726
5911
  }
5912
+ function getTaskByFingerprint(fingerprint, db) {
5913
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
5914
+ return tasks[0] ?? null;
5915
+ }
5916
+ function mergeTaskMetadata(current, next, fingerprint) {
5917
+ return {
5918
+ ...current,
5919
+ ...next ?? {},
5920
+ fingerprint
5921
+ };
5922
+ }
5923
+ function upsertTaskByFingerprint(input, db) {
5924
+ const d = db || getDatabase();
5925
+ const fingerprint = input.fingerprint.trim();
5926
+ if (!fingerprint)
5927
+ throw new Error("fingerprint is required");
5928
+ const existing = getTaskByFingerprint(fingerprint, d);
5929
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
5930
+ if (!existing) {
5931
+ const task2 = createTask({ ...input, metadata }, d);
5932
+ return { task: task2, created: true };
5933
+ }
5934
+ const task = updateTask(existing.id, {
5935
+ version: existing.version,
5936
+ title: input.title,
5937
+ description: input.description,
5938
+ status: input.status,
5939
+ priority: input.priority,
5940
+ project_id: input.project_id,
5941
+ assigned_to: input.assigned_to,
5942
+ working_dir: input.working_dir,
5943
+ plan_id: input.plan_id,
5944
+ task_list_id: input.task_list_id,
5945
+ tags: input.tags,
5946
+ metadata,
5947
+ due_at: input.due_at,
5948
+ estimated_minutes: input.estimated_minutes,
5949
+ sla_minutes: input.sla_minutes,
5950
+ confidence: input.confidence,
5951
+ retry_count: input.retry_count,
5952
+ max_retries: input.max_retries,
5953
+ retry_after: input.retry_after,
5954
+ requires_approval: input.requires_approval,
5955
+ recurrence_rule: input.recurrence_rule,
5956
+ task_type: input.task_type
5957
+ }, d);
5958
+ return { task, created: false };
5959
+ }
5727
5960
  function countTasks(filter = {}, db) {
5728
5961
  const d = db || getDatabase();
5729
5962
  const conditions = [];
@@ -5787,6 +6020,7 @@ function countTasks(filter = {}, db) {
5787
6020
  conditions.push("task_list_id = ?");
5788
6021
  params.push(filter.task_list_id);
5789
6022
  }
6023
+ addMetadataConditions(filter.metadata, conditions, params);
5790
6024
  if (!filter.include_archived) {
5791
6025
  conditions.push("archived_at IS NULL");
5792
6026
  }
@@ -5837,6 +6071,10 @@ function updateTask(id, input, db) {
5837
6071
  sets.push("assigned_to = ?");
5838
6072
  params.push(input.assigned_to);
5839
6073
  }
6074
+ if (input.working_dir !== undefined) {
6075
+ sets.push("working_dir = ?");
6076
+ params.push(input.working_dir);
6077
+ }
5840
6078
  if (input.tags !== undefined) {
5841
6079
  sets.push("tags = ?");
5842
6080
  params.push(JSON.stringify(input.tags));
@@ -5925,6 +6163,8 @@ function updateTask(id, input, db) {
5925
6163
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
5926
6164
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
5927
6165
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
6166
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
6167
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
5928
6168
  if (input.approved_by !== undefined)
5929
6169
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
5930
6170
  const updatedTask = {
@@ -5960,6 +6200,10 @@ function updateTask(id, input, db) {
5960
6200
  if (input.approved_by !== undefined) {
5961
6201
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
5962
6202
  }
6203
+ const updatePayload = taskEventData(updatedTask);
6204
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
6205
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
6206
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
5963
6207
  return updatedTask;
5964
6208
  }
5965
6209
  function deleteTask(id, db) {
@@ -8655,6 +8899,7 @@ function getTaskTraceability(taskId, db) {
8655
8899
 
8656
8900
  // src/db/task-runs.ts
8657
8901
  init_redaction();
8902
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
8658
8903
  function parseObject(value) {
8659
8904
  if (!value)
8660
8905
  return {};
@@ -8677,6 +8922,72 @@ function rowToArtifact(row) {
8677
8922
  function getRunRow(runId, db) {
8678
8923
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
8679
8924
  }
8925
+ function normalizeTransactionKey(input) {
8926
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
8927
+ if (!key)
8928
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
8929
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
8930
+ }
8931
+ function loopTransactionMetadata(record) {
8932
+ const value = record.metadata["loop_transaction"];
8933
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
8934
+ }
8935
+ function runKey(record) {
8936
+ const tx = loopTransactionMetadata(record);
8937
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
8938
+ return typeof key === "string" ? key : null;
8939
+ }
8940
+ function loopId(record) {
8941
+ const tx = loopTransactionMetadata(record);
8942
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
8943
+ return typeof value === "string" ? value : null;
8944
+ }
8945
+ function loopRunId(record) {
8946
+ const tx = loopTransactionMetadata(record);
8947
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
8948
+ return typeof value === "string" ? value : null;
8949
+ }
8950
+ function getTaskRunTransactionByKey(key, taskId, db) {
8951
+ if (taskId) {
8952
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
8953
+ }
8954
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
8955
+ if (rows.length > 1)
8956
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
8957
+ return rows[0] ?? null;
8958
+ }
8959
+ function summarizeTaskRun(run) {
8960
+ return {
8961
+ id: run.id,
8962
+ task_id: run.task_id,
8963
+ agent_id: run.agent_id,
8964
+ title: run.title,
8965
+ status: run.status,
8966
+ summary: run.summary,
8967
+ idempotency_key: runKey(run),
8968
+ loop_id: loopId(run),
8969
+ loop_run_id: loopRunId(run),
8970
+ metadata_keys: Object.keys(run.metadata).sort(),
8971
+ started_at: run.started_at,
8972
+ completed_at: run.completed_at,
8973
+ updated_at: run.updated_at
8974
+ };
8975
+ }
8976
+ function findTaskRunByTransactionKey(key, taskId, db) {
8977
+ const d = db || getDatabase();
8978
+ const normalized = normalizeTransactionKey({ key });
8979
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
8980
+ if (transaction?.run_id)
8981
+ return getTaskRun(transaction.run_id, d);
8982
+ return null;
8983
+ }
8984
+ function loopRunCommands(run, key) {
8985
+ return [
8986
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
8987
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
8988
+ `todos runs begin <task-id> --key ${key} --apply --json`
8989
+ ];
8990
+ }
8680
8991
  function resolveTaskRunId(idOrPrefix, db) {
8681
8992
  const d = db || getDatabase();
8682
8993
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -8695,7 +9006,7 @@ function startTaskRun(input, db) {
8695
9006
  const d = db || getDatabase();
8696
9007
  if (!getTask(input.task_id, d))
8697
9008
  throw new TaskNotFoundError(input.task_id);
8698
- const id = uuid();
9009
+ const id = input.id ?? uuid();
8699
9010
  const timestamp = input.started_at || now();
8700
9011
  if (input.claim && input.agent_id) {
8701
9012
  startTask(input.task_id, input.agent_id, d);
@@ -8733,6 +9044,97 @@ function startTaskRun(input, db) {
8733
9044
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
8734
9045
  return run;
8735
9046
  }
9047
+ function beginTaskRunTransaction(input, db) {
9048
+ const d = db || getDatabase();
9049
+ if (!getTask(input.task_id, d))
9050
+ throw new TaskNotFoundError(input.task_id);
9051
+ const timestamp = input.started_at || now();
9052
+ const key = normalizeTransactionKey(input);
9053
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
9054
+ const dryRun = !input.apply;
9055
+ if (existing) {
9056
+ return {
9057
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9058
+ local_only: true,
9059
+ dry_run: dryRun,
9060
+ processed_at: timestamp,
9061
+ action: "matched",
9062
+ key,
9063
+ run: summarizeTaskRun(existing),
9064
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
9065
+ commands: loopRunCommands(existing, key)
9066
+ };
9067
+ }
9068
+ if (dryRun) {
9069
+ return {
9070
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9071
+ local_only: true,
9072
+ dry_run: true,
9073
+ processed_at: timestamp,
9074
+ action: "preview",
9075
+ key,
9076
+ run: null,
9077
+ warnings: [],
9078
+ commands: loopRunCommands(null, key)
9079
+ };
9080
+ }
9081
+ const metadata = redactValue({
9082
+ ...input.metadata || {},
9083
+ loop_transaction: {
9084
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9085
+ idempotency_key: key,
9086
+ loop_id: input.loop_id ?? null,
9087
+ loop_run_id: input.loop_run_id ?? null,
9088
+ first_seen_at: timestamp
9089
+ },
9090
+ idempotency_key: key
9091
+ });
9092
+ const created = d.transaction(() => {
9093
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
9094
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
9095
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
9096
+ uuid(),
9097
+ input.task_id,
9098
+ key,
9099
+ input.loop_id ?? null,
9100
+ input.loop_run_id ?? null,
9101
+ JSON.stringify(metadata),
9102
+ timestamp,
9103
+ timestamp
9104
+ ]);
9105
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
9106
+ if (!transaction)
9107
+ throw new Error(`Could not create run transaction for key: ${key}`);
9108
+ if (transaction.run_id) {
9109
+ const existingRun = getTaskRun(transaction.run_id, d);
9110
+ if (existingRun)
9111
+ return { run: existingRun, action: "matched" };
9112
+ }
9113
+ const run = startTaskRun({
9114
+ id: uuid(),
9115
+ task_id: input.task_id,
9116
+ agent_id: input.agent_id,
9117
+ title: input.title,
9118
+ summary: input.summary,
9119
+ metadata,
9120
+ claim: input.claim,
9121
+ started_at: timestamp
9122
+ }, d);
9123
+ d.run("UPDATE task_run_transactions SET run_id = ?, loop_id = COALESCE(?, loop_id), loop_run_id = COALESCE(?, loop_run_id), metadata = ?, updated_at = ? WHERE id = ?", [run.id, input.loop_id ?? null, input.loop_run_id ?? null, JSON.stringify(metadata), timestamp, transaction.id]);
9124
+ return { run, action: "created" };
9125
+ })();
9126
+ return {
9127
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9128
+ local_only: true,
9129
+ dry_run: false,
9130
+ processed_at: timestamp,
9131
+ action: created.action,
9132
+ key,
9133
+ run: summarizeTaskRun(created.run),
9134
+ warnings: [],
9135
+ commands: loopRunCommands(created.run, key)
9136
+ };
9137
+ }
8736
9138
  function addTaskRunEvent(input, db) {
8737
9139
  const d = db || getDatabase();
8738
9140
  const runId = resolveTaskRunId(input.run_id, d);
@@ -8912,6 +9314,66 @@ function finishTaskRun(input, db) {
8912
9314
  });
8913
9315
  return updated;
8914
9316
  }
9317
+ function finishTaskRunTransaction(input, db) {
9318
+ const d = db || getDatabase();
9319
+ const timestamp = input.completed_at || now();
9320
+ const status = input.status || "completed";
9321
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
9322
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
9323
+ if (!run) {
9324
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
9325
+ }
9326
+ if (input.task_id && run.task_id !== input.task_id) {
9327
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
9328
+ }
9329
+ const resolvedKey = key || runKey(run) || run.id;
9330
+ const dryRun = input.apply === false;
9331
+ if (run.status !== "running") {
9332
+ const conflict = run.status !== status;
9333
+ return {
9334
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9335
+ local_only: true,
9336
+ dry_run: dryRun,
9337
+ processed_at: timestamp,
9338
+ action: conflict ? "conflict" : "matched",
9339
+ key: resolvedKey,
9340
+ run: summarizeTaskRun(run),
9341
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
9342
+ commands: loopRunCommands(run, resolvedKey)
9343
+ };
9344
+ }
9345
+ if (dryRun) {
9346
+ return {
9347
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9348
+ local_only: true,
9349
+ dry_run: true,
9350
+ processed_at: timestamp,
9351
+ action: "preview",
9352
+ key: resolvedKey,
9353
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
9354
+ warnings: [],
9355
+ commands: loopRunCommands(run, resolvedKey)
9356
+ };
9357
+ }
9358
+ const finished = finishTaskRun({
9359
+ run_id: run.id,
9360
+ status,
9361
+ summary: input.summary,
9362
+ agent_id: input.agent_id,
9363
+ completed_at: timestamp
9364
+ }, d);
9365
+ return {
9366
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9367
+ local_only: true,
9368
+ dry_run: false,
9369
+ processed_at: timestamp,
9370
+ action: "finished",
9371
+ key: resolvedKey,
9372
+ run: summarizeTaskRun(finished),
9373
+ warnings: [],
9374
+ commands: loopRunCommands(finished, resolvedKey)
9375
+ };
9376
+ }
8915
9377
  function listTaskRuns(taskId, db) {
8916
9378
  const d = db || getDatabase();
8917
9379
  const rows = taskId ? d.query("SELECT * FROM task_runs WHERE task_id = ? ORDER BY started_at DESC, created_at DESC").all(taskId) : d.query("SELECT * FROM task_runs ORDER BY started_at DESC, created_at DESC LIMIT 100").all();
@@ -385,6 +385,14 @@ export interface CreateTaskInput {
385
385
  assigned_from_project?: string;
386
386
  task_type?: string;
387
387
  }
388
+ export interface UpsertTaskByFingerprintInput extends CreateTaskInput {
389
+ /** Stable top-level metadata fingerprint used for deterministic dedupe. */
390
+ fingerprint: string;
391
+ }
392
+ export interface UpsertTaskByFingerprintResult {
393
+ task: Task;
394
+ created: boolean;
395
+ }
388
396
  export interface UpdateTaskInput {
389
397
  title?: string;
390
398
  description?: string;
@@ -392,6 +400,7 @@ export interface UpdateTaskInput {
392
400
  priority?: TaskPriority;
393
401
  project_id?: string | null;
394
402
  assigned_to?: string;
403
+ working_dir?: string | null;
395
404
  plan_id?: string | null;
396
405
  task_list_id?: string;
397
406
  cycle_id?: string | null;
@@ -434,6 +443,8 @@ export interface TaskFilter {
434
443
  cursor?: string;
435
444
  /** When true, include archived tasks. Default: false (archived tasks excluded) */
436
445
  include_archived?: boolean;
446
+ /** Exact top-level metadata filters, e.g. { fingerprint: "loop:key" }. */
447
+ metadata?: Record<string, unknown>;
437
448
  }
438
449
  export interface TaskDependency {
439
450
  task_id: string;