@hasna/todos 0.11.58 → 0.11.59

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 +38 -0
  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 +1420 -187
  5. package/dist/contracts.d.ts.map +1 -1
  6. package/dist/contracts.js +502 -4
  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 +870 -5
  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 +984 -17
  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 +502 -4
  32. package/dist/release-provenance.json +3 -3
  33. package/dist/server/index.js +984 -17
  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 +375 -1
  39. package/dist/types/index.d.ts +11 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +1 -1
package/dist/registry.js CHANGED
@@ -1139,6 +1139,49 @@ var init_migrations = __esm(() => {
1139
1139
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1140
1140
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1141
1141
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1142
+ `,
1143
+ `
1144
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1145
+ id TEXT PRIMARY KEY,
1146
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1147
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1148
+ key TEXT NOT NULL,
1149
+ loop_id TEXT,
1150
+ loop_run_id TEXT,
1151
+ metadata TEXT DEFAULT '{}',
1152
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1153
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1154
+ UNIQUE(task_id, key)
1155
+ );
1156
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1157
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1158
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1159
+
1160
+ CREATE TABLE IF NOT EXISTS task_findings (
1161
+ id TEXT PRIMARY KEY,
1162
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1163
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1164
+ fingerprint TEXT NOT NULL,
1165
+ title TEXT NOT NULL,
1166
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1167
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1168
+ source TEXT,
1169
+ summary TEXT,
1170
+ artifact_path TEXT,
1171
+ metadata TEXT DEFAULT '{}',
1172
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1173
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1174
+ resolved_at TEXT,
1175
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1176
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1177
+ UNIQUE(task_id, fingerprint)
1178
+ );
1179
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1180
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1181
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1182
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1183
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1184
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1142
1185
  `
1143
1186
  ];
1144
1187
  });
@@ -1576,6 +1619,47 @@ function ensureSchema(db) {
1576
1619
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1577
1620
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1578
1621
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1622
+ ensureTable("task_run_transactions", `
1623
+ CREATE TABLE task_run_transactions (
1624
+ id TEXT PRIMARY KEY,
1625
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1626
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1627
+ key TEXT NOT NULL,
1628
+ loop_id TEXT,
1629
+ loop_run_id TEXT,
1630
+ metadata TEXT DEFAULT '{}',
1631
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1632
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1633
+ UNIQUE(task_id, key)
1634
+ )`);
1635
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1636
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1637
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1638
+ ensureTable("task_findings", `
1639
+ CREATE TABLE task_findings (
1640
+ id TEXT PRIMARY KEY,
1641
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1642
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1643
+ fingerprint TEXT NOT NULL,
1644
+ title TEXT NOT NULL,
1645
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1646
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1647
+ source TEXT,
1648
+ summary TEXT,
1649
+ artifact_path TEXT,
1650
+ metadata TEXT DEFAULT '{}',
1651
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1652
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1653
+ resolved_at TEXT,
1654
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1655
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1656
+ UNIQUE(task_id, fingerprint)
1657
+ )`);
1658
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1659
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1660
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1661
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1662
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1579
1663
  ensureTable("inbox_items", `
1580
1664
  CREATE TABLE inbox_items (
1581
1665
  id TEXT PRIMARY KEY,
@@ -3576,6 +3660,7 @@ var MCP_TOOL_GROUPS = {
3576
3660
  "add_task_run_event",
3577
3661
  "add_task_run_file",
3578
3662
  "acknowledge_handoff",
3663
+ "begin_task_run_transaction",
3579
3664
  "build_local_report",
3580
3665
  "cancel_agent_run_dispatch",
3581
3666
  "finish_task_run",
@@ -3623,6 +3708,7 @@ var MCP_TOOL_GROUPS = {
3623
3708
  "list_local_snapshots",
3624
3709
  "list_retrospectives",
3625
3710
  "list_risks",
3711
+ "list_task_findings",
3626
3712
  "list_task_runs",
3627
3713
  "list_verification_providers",
3628
3714
  "merge_duplicate_task",
@@ -3631,6 +3717,7 @@ var MCP_TOOL_GROUPS = {
3631
3717
  "remove_review_routing_rule",
3632
3718
  "restore_local_backup",
3633
3719
  "retry_agent_run_dispatch",
3720
+ "resolve_missing_task_findings",
3634
3721
  "resolve_mentions",
3635
3722
  "run_next_agent_dispatch",
3636
3723
  "search_knowledge_records",
@@ -3673,9 +3760,17 @@ var MCP_TOOL_GROUPS = {
3673
3760
  "unlock_file",
3674
3761
  "unwatch_task",
3675
3762
  "update_comment",
3763
+ "upsert_task_finding",
3676
3764
  "update_risk",
3677
3765
  "watch_task"
3678
3766
  ],
3767
+ loops: [
3768
+ "begin_task_run_transaction",
3769
+ "finish_task_run",
3770
+ "list_task_findings",
3771
+ "resolve_missing_task_findings",
3772
+ "upsert_task_finding"
3773
+ ],
3679
3774
  agents: [
3680
3775
  "auto_assign_task",
3681
3776
  "delete_agent",
@@ -3757,7 +3852,7 @@ var MCP_TOOL_GROUPS = {
3757
3852
  maintenance: ["extract_todos", "get_sla_breaches", "notify_upcoming_deadlines", "run_doctor", "score_task", "watch_source_todos"]
3758
3853
  };
3759
3854
  var MCP_PROFILE_GROUPS = {
3760
- minimal: ["core"],
3855
+ minimal: ["core", "loops"],
3761
3856
  core: ["core"],
3762
3857
  standard: ["core", "tasks", "projects", "resources", "agents", "metadata"],
3763
3858
  agent: ["core", "tasks", "projects", "resources"],
@@ -4830,6 +4925,92 @@ var TODOS_JSON_CONTRACTS = [
4830
4925
  },
4831
4926
  optional: {}
4832
4927
  }),
4928
+ contract({
4929
+ id: "loop_run_transaction",
4930
+ name: "Loop Run Transaction",
4931
+ description: "Compact local result for idempotent loop run begin/finish transactions.",
4932
+ surfaces: ["cli", "mcp", "sdk"],
4933
+ stability: "stable",
4934
+ required: {
4935
+ schema_version: field("string", "Result schema version."),
4936
+ local_only: field("boolean", "Always true; loop run transactions use local state."),
4937
+ dry_run: field("boolean", "True when no run ledger mutation was applied."),
4938
+ processed_at: isoDateField,
4939
+ action: field("string", "preview, created, matched, finished, or conflict."),
4940
+ key: field("string", "Stable idempotency key used to dedupe the transaction."),
4941
+ run: field(["object", "null"], "Compact run summary or null for create previews.", true),
4942
+ warnings: field("array", "Non-fatal warnings such as terminal-status conflicts."),
4943
+ commands: field("array", "Follow-up CLI commands for agents and operators.")
4944
+ },
4945
+ optional: {}
4946
+ }),
4947
+ contract({
4948
+ id: "task_finding",
4949
+ name: "Task Finding",
4950
+ description: "Compact local finding record deduped by task and fingerprint.",
4951
+ surfaces: ["cli", "mcp", "sdk"],
4952
+ stability: "stable",
4953
+ required: {
4954
+ schema_version: field("string", "Finding schema version."),
4955
+ id: idField,
4956
+ task_id: idField,
4957
+ run_id: field(["string", "null"], "Optional run ledger ID.", true),
4958
+ fingerprint: field("string", "Stable finding fingerprint scoped to the task."),
4959
+ title: field("string", "Short redacted finding title."),
4960
+ severity: field("string", "low, medium, high, or critical."),
4961
+ status: field("string", "open, resolved, or ignored."),
4962
+ source: field(["string", "null"], "Optional loop/tool source.", true),
4963
+ summary: field(["string", "null"], "Bounded redacted finding summary.", true),
4964
+ artifact_path: field(["string", "null"], "Local artifact path/reference; raw content is not included.", true),
4965
+ first_seen_at: isoDateField,
4966
+ last_seen_at: isoDateField,
4967
+ resolved_at: field(["string", "null"], "Resolution timestamp when closed.", true),
4968
+ metadata_keys: field("array", "Sorted metadata keys; metadata values are intentionally omitted in compact output.")
4969
+ },
4970
+ optional: {}
4971
+ }),
4972
+ contract({
4973
+ id: "task_finding_upsert",
4974
+ name: "Task Finding Upsert Result",
4975
+ description: "Local-only dry-run or applied result from idempotently upserting a task finding.",
4976
+ surfaces: ["cli", "mcp", "sdk"],
4977
+ stability: "stable",
4978
+ required: {
4979
+ schema_version: field("string", "Result schema version."),
4980
+ local_only: field("boolean", "Always true; finding upserts use local state."),
4981
+ dry_run: field("boolean", "True when no finding row was created or updated."),
4982
+ processed_at: isoDateField,
4983
+ action: field("string", "preview, created, matched, updated, or reopened."),
4984
+ fingerprint: field("string", "Normalized finding fingerprint."),
4985
+ finding: field(["object", "null"], "Compact finding summary or null for create previews.", true),
4986
+ warnings: field("array", "Non-fatal warnings.")
4987
+ },
4988
+ optional: {}
4989
+ }),
4990
+ contract({
4991
+ id: "task_finding_resolve_missing",
4992
+ name: "Task Finding Resolve Missing Result",
4993
+ description: "Local-only dry-run or applied result from resolving open findings absent from the latest loop finding set.",
4994
+ surfaces: ["cli", "mcp", "sdk"],
4995
+ stability: "stable",
4996
+ required: {
4997
+ schema_version: field("string", "Result schema version."),
4998
+ local_only: field("boolean", "Always true; finding resolution uses local state."),
4999
+ dry_run: field("boolean", "True when no finding rows were changed."),
5000
+ processed_at: isoDateField,
5001
+ action: field("string", "preview, resolved, ignored, or noop."),
5002
+ task_id: idField,
5003
+ source: field(["string", "null"], "Optional source scope.", true),
5004
+ run_id: field(["string", "null"], "Optional run ledger ID used for audit metadata.", true),
5005
+ present_fingerprint_count: field("integer", "Number of fingerprints supplied as still present."),
5006
+ candidate_count: field("integer", "Open findings missing from the supplied set."),
5007
+ changed_count: field("integer", "Rows resolved or ignored by the applied transaction."),
5008
+ omitted_count: field("integer", "Matching findings omitted from bounded output."),
5009
+ findings: field("array", "Bounded compact finding summaries."),
5010
+ warnings: field("array", "Non-fatal warnings.")
5011
+ },
5012
+ optional: {}
5013
+ }),
4833
5014
  contract({
4834
5015
  id: "verification_provider",
4835
5016
  name: "Verification Provider",
@@ -6535,6 +6716,7 @@ var LOCAL_EVENT_TYPES = [
6535
6716
  "task.blocked",
6536
6717
  "task.started",
6537
6718
  "task.completed",
6719
+ "task.updated",
6538
6720
  "task.due",
6539
6721
  "task.due_soon",
6540
6722
  "task.failed",
@@ -7873,6 +8055,17 @@ function replaceTaskTags(taskId, tags, db) {
7873
8055
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
7874
8056
  insertTaskTags(taskId, tags, db);
7875
8057
  }
8058
+ function addMetadataConditions(metadata, conditions, params) {
8059
+ if (!metadata)
8060
+ return;
8061
+ for (const [key, value] of Object.entries(metadata)) {
8062
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
8063
+ throw new Error(`Invalid metadata filter key: ${key}`);
8064
+ }
8065
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
8066
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
8067
+ }
8068
+ }
7876
8069
  function createTask(input, db) {
7877
8070
  const d = db || getDatabase();
7878
8071
  const timestamp = now();
@@ -8055,6 +8248,7 @@ function listTasks(filter = {}, db) {
8055
8248
  params.push(filter.task_type);
8056
8249
  }
8057
8250
  }
8251
+ addMetadataConditions(filter.metadata, conditions, params);
8058
8252
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
8059
8253
  if (filter.cursor) {
8060
8254
  try {
@@ -8079,6 +8273,54 @@ function listTasks(filter = {}, db) {
8079
8273
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
8080
8274
  return rows.map(rowToTask);
8081
8275
  }
8276
+ function getTaskByFingerprint(fingerprint, db) {
8277
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
8278
+ return tasks[0] ?? null;
8279
+ }
8280
+ function mergeTaskMetadata(current, next, fingerprint) {
8281
+ return {
8282
+ ...current,
8283
+ ...next ?? {},
8284
+ fingerprint
8285
+ };
8286
+ }
8287
+ function upsertTaskByFingerprint(input, db) {
8288
+ const d = db || getDatabase();
8289
+ const fingerprint = input.fingerprint.trim();
8290
+ if (!fingerprint)
8291
+ throw new Error("fingerprint is required");
8292
+ const existing = getTaskByFingerprint(fingerprint, d);
8293
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
8294
+ if (!existing) {
8295
+ const task2 = createTask({ ...input, metadata }, d);
8296
+ return { task: task2, created: true };
8297
+ }
8298
+ const task = updateTask(existing.id, {
8299
+ version: existing.version,
8300
+ title: input.title,
8301
+ description: input.description,
8302
+ status: input.status,
8303
+ priority: input.priority,
8304
+ project_id: input.project_id,
8305
+ assigned_to: input.assigned_to,
8306
+ working_dir: input.working_dir,
8307
+ plan_id: input.plan_id,
8308
+ task_list_id: input.task_list_id,
8309
+ tags: input.tags,
8310
+ metadata,
8311
+ due_at: input.due_at,
8312
+ estimated_minutes: input.estimated_minutes,
8313
+ sla_minutes: input.sla_minutes,
8314
+ confidence: input.confidence,
8315
+ retry_count: input.retry_count,
8316
+ max_retries: input.max_retries,
8317
+ retry_after: input.retry_after,
8318
+ requires_approval: input.requires_approval,
8319
+ recurrence_rule: input.recurrence_rule,
8320
+ task_type: input.task_type
8321
+ }, d);
8322
+ return { task, created: false };
8323
+ }
8082
8324
  function countTasks(filter = {}, db) {
8083
8325
  const d = db || getDatabase();
8084
8326
  const conditions = [];
@@ -8142,6 +8384,7 @@ function countTasks(filter = {}, db) {
8142
8384
  conditions.push("task_list_id = ?");
8143
8385
  params.push(filter.task_list_id);
8144
8386
  }
8387
+ addMetadataConditions(filter.metadata, conditions, params);
8145
8388
  if (!filter.include_archived) {
8146
8389
  conditions.push("archived_at IS NULL");
8147
8390
  }
@@ -8192,6 +8435,10 @@ function updateTask(id, input, db) {
8192
8435
  sets.push("assigned_to = ?");
8193
8436
  params.push(input.assigned_to);
8194
8437
  }
8438
+ if (input.working_dir !== undefined) {
8439
+ sets.push("working_dir = ?");
8440
+ params.push(input.working_dir);
8441
+ }
8195
8442
  if (input.tags !== undefined) {
8196
8443
  sets.push("tags = ?");
8197
8444
  params.push(JSON.stringify(input.tags));
@@ -8280,6 +8527,8 @@ function updateTask(id, input, db) {
8280
8527
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
8281
8528
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
8282
8529
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
8530
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
8531
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
8283
8532
  if (input.approved_by !== undefined)
8284
8533
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
8285
8534
  const updatedTask = {
@@ -8315,6 +8564,10 @@ function updateTask(id, input, db) {
8315
8564
  if (input.approved_by !== undefined) {
8316
8565
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
8317
8566
  }
8567
+ const updatePayload = taskEventData(updatedTask);
8568
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
8569
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
8570
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
8318
8571
  return updatedTask;
8319
8572
  }
8320
8573
  function deleteTask(id, db) {
@@ -11010,6 +11263,7 @@ function getTaskTraceability(taskId, db) {
11010
11263
 
11011
11264
  // src/db/task-runs.ts
11012
11265
  init_redaction();
11266
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
11013
11267
  function parseObject(value) {
11014
11268
  if (!value)
11015
11269
  return {};
@@ -11032,6 +11286,72 @@ function rowToArtifact(row) {
11032
11286
  function getRunRow(runId, db) {
11033
11287
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
11034
11288
  }
11289
+ function normalizeTransactionKey(input) {
11290
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
11291
+ if (!key)
11292
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
11293
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
11294
+ }
11295
+ function loopTransactionMetadata(record) {
11296
+ const value = record.metadata["loop_transaction"];
11297
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11298
+ }
11299
+ function runKey(record) {
11300
+ const tx = loopTransactionMetadata(record);
11301
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
11302
+ return typeof key === "string" ? key : null;
11303
+ }
11304
+ function loopId(record) {
11305
+ const tx = loopTransactionMetadata(record);
11306
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
11307
+ return typeof value === "string" ? value : null;
11308
+ }
11309
+ function loopRunId(record) {
11310
+ const tx = loopTransactionMetadata(record);
11311
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
11312
+ return typeof value === "string" ? value : null;
11313
+ }
11314
+ function getTaskRunTransactionByKey(key, taskId, db) {
11315
+ if (taskId) {
11316
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
11317
+ }
11318
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
11319
+ if (rows.length > 1)
11320
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
11321
+ return rows[0] ?? null;
11322
+ }
11323
+ function summarizeTaskRun(run) {
11324
+ return {
11325
+ id: run.id,
11326
+ task_id: run.task_id,
11327
+ agent_id: run.agent_id,
11328
+ title: run.title,
11329
+ status: run.status,
11330
+ summary: run.summary,
11331
+ idempotency_key: runKey(run),
11332
+ loop_id: loopId(run),
11333
+ loop_run_id: loopRunId(run),
11334
+ metadata_keys: Object.keys(run.metadata).sort(),
11335
+ started_at: run.started_at,
11336
+ completed_at: run.completed_at,
11337
+ updated_at: run.updated_at
11338
+ };
11339
+ }
11340
+ function findTaskRunByTransactionKey(key, taskId, db) {
11341
+ const d = db || getDatabase();
11342
+ const normalized = normalizeTransactionKey({ key });
11343
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
11344
+ if (transaction?.run_id)
11345
+ return getTaskRun(transaction.run_id, d);
11346
+ return null;
11347
+ }
11348
+ function loopRunCommands(run, key) {
11349
+ return [
11350
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
11351
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
11352
+ `todos runs begin <task-id> --key ${key} --apply --json`
11353
+ ];
11354
+ }
11035
11355
  function resolveTaskRunId(idOrPrefix, db) {
11036
11356
  const d = db || getDatabase();
11037
11357
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -11050,7 +11370,7 @@ function startTaskRun(input, db) {
11050
11370
  const d = db || getDatabase();
11051
11371
  if (!getTask(input.task_id, d))
11052
11372
  throw new TaskNotFoundError(input.task_id);
11053
- const id = uuid();
11373
+ const id = input.id ?? uuid();
11054
11374
  const timestamp = input.started_at || now();
11055
11375
  if (input.claim && input.agent_id) {
11056
11376
  startTask(input.task_id, input.agent_id, d);
@@ -11088,6 +11408,97 @@ function startTaskRun(input, db) {
11088
11408
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
11089
11409
  return run;
11090
11410
  }
11411
+ function beginTaskRunTransaction(input, db) {
11412
+ const d = db || getDatabase();
11413
+ if (!getTask(input.task_id, d))
11414
+ throw new TaskNotFoundError(input.task_id);
11415
+ const timestamp = input.started_at || now();
11416
+ const key = normalizeTransactionKey(input);
11417
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
11418
+ const dryRun = !input.apply;
11419
+ if (existing) {
11420
+ return {
11421
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11422
+ local_only: true,
11423
+ dry_run: dryRun,
11424
+ processed_at: timestamp,
11425
+ action: "matched",
11426
+ key,
11427
+ run: summarizeTaskRun(existing),
11428
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
11429
+ commands: loopRunCommands(existing, key)
11430
+ };
11431
+ }
11432
+ if (dryRun) {
11433
+ return {
11434
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11435
+ local_only: true,
11436
+ dry_run: true,
11437
+ processed_at: timestamp,
11438
+ action: "preview",
11439
+ key,
11440
+ run: null,
11441
+ warnings: [],
11442
+ commands: loopRunCommands(null, key)
11443
+ };
11444
+ }
11445
+ const metadata = redactValue({
11446
+ ...input.metadata || {},
11447
+ loop_transaction: {
11448
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11449
+ idempotency_key: key,
11450
+ loop_id: input.loop_id ?? null,
11451
+ loop_run_id: input.loop_run_id ?? null,
11452
+ first_seen_at: timestamp
11453
+ },
11454
+ idempotency_key: key
11455
+ });
11456
+ const created = d.transaction(() => {
11457
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
11458
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
11459
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
11460
+ uuid(),
11461
+ input.task_id,
11462
+ key,
11463
+ input.loop_id ?? null,
11464
+ input.loop_run_id ?? null,
11465
+ JSON.stringify(metadata),
11466
+ timestamp,
11467
+ timestamp
11468
+ ]);
11469
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
11470
+ if (!transaction)
11471
+ throw new Error(`Could not create run transaction for key: ${key}`);
11472
+ if (transaction.run_id) {
11473
+ const existingRun = getTaskRun(transaction.run_id, d);
11474
+ if (existingRun)
11475
+ return { run: existingRun, action: "matched" };
11476
+ }
11477
+ const run = startTaskRun({
11478
+ id: uuid(),
11479
+ task_id: input.task_id,
11480
+ agent_id: input.agent_id,
11481
+ title: input.title,
11482
+ summary: input.summary,
11483
+ metadata,
11484
+ claim: input.claim,
11485
+ started_at: timestamp
11486
+ }, d);
11487
+ 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]);
11488
+ return { run, action: "created" };
11489
+ })();
11490
+ return {
11491
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11492
+ local_only: true,
11493
+ dry_run: false,
11494
+ processed_at: timestamp,
11495
+ action: created.action,
11496
+ key,
11497
+ run: summarizeTaskRun(created.run),
11498
+ warnings: [],
11499
+ commands: loopRunCommands(created.run, key)
11500
+ };
11501
+ }
11091
11502
  function addTaskRunEvent(input, db) {
11092
11503
  const d = db || getDatabase();
11093
11504
  const runId = resolveTaskRunId(input.run_id, d);
@@ -11267,6 +11678,66 @@ function finishTaskRun(input, db) {
11267
11678
  });
11268
11679
  return updated;
11269
11680
  }
11681
+ function finishTaskRunTransaction(input, db) {
11682
+ const d = db || getDatabase();
11683
+ const timestamp = input.completed_at || now();
11684
+ const status = input.status || "completed";
11685
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
11686
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
11687
+ if (!run) {
11688
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
11689
+ }
11690
+ if (input.task_id && run.task_id !== input.task_id) {
11691
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
11692
+ }
11693
+ const resolvedKey = key || runKey(run) || run.id;
11694
+ const dryRun = input.apply === false;
11695
+ if (run.status !== "running") {
11696
+ const conflict = run.status !== status;
11697
+ return {
11698
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11699
+ local_only: true,
11700
+ dry_run: dryRun,
11701
+ processed_at: timestamp,
11702
+ action: conflict ? "conflict" : "matched",
11703
+ key: resolvedKey,
11704
+ run: summarizeTaskRun(run),
11705
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
11706
+ commands: loopRunCommands(run, resolvedKey)
11707
+ };
11708
+ }
11709
+ if (dryRun) {
11710
+ return {
11711
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11712
+ local_only: true,
11713
+ dry_run: true,
11714
+ processed_at: timestamp,
11715
+ action: "preview",
11716
+ key: resolvedKey,
11717
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
11718
+ warnings: [],
11719
+ commands: loopRunCommands(run, resolvedKey)
11720
+ };
11721
+ }
11722
+ const finished = finishTaskRun({
11723
+ run_id: run.id,
11724
+ status,
11725
+ summary: input.summary,
11726
+ agent_id: input.agent_id,
11727
+ completed_at: timestamp
11728
+ }, d);
11729
+ return {
11730
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11731
+ local_only: true,
11732
+ dry_run: false,
11733
+ processed_at: timestamp,
11734
+ action: "finished",
11735
+ key: resolvedKey,
11736
+ run: summarizeTaskRun(finished),
11737
+ warnings: [],
11738
+ commands: loopRunCommands(finished, resolvedKey)
11739
+ };
11740
+ }
11270
11741
  function listTaskRuns(taskId, db) {
11271
11742
  const d = db || getDatabase();
11272
11743
  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();
@@ -17447,7 +17918,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
17447
17918
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
17448
17919
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
17449
17920
  }
17450
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
17921
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
17451
17922
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
17452
17923
  mergedDuplicates.push({
17453
17924
  id: duplicate.id,
@@ -17508,7 +17979,7 @@ function mergeDuplicateTask(input, db) {
17508
17979
  updateTask(primary.id, {
17509
17980
  version: primary.version,
17510
17981
  tags: mergedTags,
17511
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
17982
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
17512
17983
  description: mergeTaskDescription(primary, duplicate) ?? undefined
17513
17984
  }, d);
17514
17985
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -19979,6 +20450,33 @@ var TODOS_API_ROUTES = [
19979
20450
  tags: ["tasks", "mutation"],
19980
20451
  stability: "stable"
19981
20452
  },
20453
+ {
20454
+ id: "tasks.upsert",
20455
+ method: "POST",
20456
+ path: "/api/tasks/upsert",
20457
+ description: "Create or update a task by stable metadata fingerprint, merging metadata on updates.",
20458
+ auth: "optional-api-key",
20459
+ requestSchema: {
20460
+ type: "object",
20461
+ properties: {
20462
+ fingerprint: { type: "string" },
20463
+ title: { type: "string" },
20464
+ description: { type: "string" },
20465
+ priority: { type: "string", enum: TASK_PRIORITIES },
20466
+ status: { type: "string", enum: TASK_STATUSES },
20467
+ project_id: { type: "string" },
20468
+ task_list_id: { type: "string" },
20469
+ working_dir: { type: "string" },
20470
+ tags: { type: "array", items: { type: "string" } },
20471
+ metadata: objectSchema
20472
+ },
20473
+ required: ["fingerprint", "title"],
20474
+ additionalProperties: true
20475
+ },
20476
+ responseSchema: objectSchema,
20477
+ tags: ["tasks", "mutation", "dedupe"],
20478
+ stability: "stable"
20479
+ },
19982
20480
  {
19983
20481
  id: "tasks.read",
19984
20482
  method: "GET",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "packageName": "@hasna/todos",
3
- "packageVersion": "0.11.58",
3
+ "packageVersion": "0.11.59",
4
4
  "repository": "https://github.com/hasna/todos.git",
5
- "gitCommit": "5f39ea1289e91b8985c1b17e3a21cc58565c0eeb",
6
- "generatedAt": "2026-06-26T17:33:40.342Z"
5
+ "gitCommit": "b507930ee09c9b1f5c7a46881679965fd35b03fb",
6
+ "generatedAt": "2026-06-27T09:34:02.825Z"
7
7
  }