@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
@@ -1189,6 +1189,49 @@ var init_migrations = __esm(() => {
1189
1189
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1190
1190
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1191
1191
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1192
+ `,
1193
+ `
1194
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1195
+ id TEXT PRIMARY KEY,
1196
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1197
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1198
+ key TEXT NOT NULL,
1199
+ loop_id TEXT,
1200
+ loop_run_id TEXT,
1201
+ metadata TEXT DEFAULT '{}',
1202
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1203
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1204
+ UNIQUE(task_id, key)
1205
+ );
1206
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1207
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1208
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1209
+
1210
+ CREATE TABLE IF NOT EXISTS task_findings (
1211
+ id TEXT PRIMARY KEY,
1212
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1213
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1214
+ fingerprint TEXT NOT NULL,
1215
+ title TEXT NOT NULL,
1216
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1217
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1218
+ source TEXT,
1219
+ summary TEXT,
1220
+ artifact_path TEXT,
1221
+ metadata TEXT DEFAULT '{}',
1222
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1223
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1224
+ resolved_at TEXT,
1225
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1226
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1227
+ UNIQUE(task_id, fingerprint)
1228
+ );
1229
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1230
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1231
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1232
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1233
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1234
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1192
1235
  `
1193
1236
  ];
1194
1237
  });
@@ -1626,6 +1669,47 @@ function ensureSchema(db) {
1626
1669
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1627
1670
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1628
1671
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1672
+ ensureTable("task_run_transactions", `
1673
+ CREATE TABLE task_run_transactions (
1674
+ id TEXT PRIMARY KEY,
1675
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1676
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1677
+ key TEXT NOT NULL,
1678
+ loop_id TEXT,
1679
+ loop_run_id TEXT,
1680
+ metadata TEXT DEFAULT '{}',
1681
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1682
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1683
+ UNIQUE(task_id, key)
1684
+ )`);
1685
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1686
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1687
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1688
+ ensureTable("task_findings", `
1689
+ CREATE TABLE task_findings (
1690
+ id TEXT PRIMARY KEY,
1691
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1692
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1693
+ fingerprint TEXT NOT NULL,
1694
+ title TEXT NOT NULL,
1695
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1696
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1697
+ source TEXT,
1698
+ summary TEXT,
1699
+ artifact_path TEXT,
1700
+ metadata TEXT DEFAULT '{}',
1701
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1702
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1703
+ resolved_at TEXT,
1704
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1705
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1706
+ UNIQUE(task_id, fingerprint)
1707
+ )`);
1708
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1709
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1710
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1711
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1712
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1629
1713
  ensureTable("inbox_items", `
1630
1714
  CREATE TABLE inbox_items (
1631
1715
  id TEXT PRIMARY KEY,
@@ -3994,6 +4078,7 @@ var init_event_hooks = __esm(() => {
3994
4078
  "task.blocked",
3995
4079
  "task.started",
3996
4080
  "task.completed",
4081
+ "task.updated",
3997
4082
  "task.due",
3998
4083
  "task.due_soon",
3999
4084
  "task.failed",
@@ -5290,6 +5375,17 @@ function replaceTaskTags(taskId, tags, db) {
5290
5375
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
5291
5376
  insertTaskTags(taskId, tags, db);
5292
5377
  }
5378
+ function addMetadataConditions(metadata, conditions, params) {
5379
+ if (!metadata)
5380
+ return;
5381
+ for (const [key, value] of Object.entries(metadata)) {
5382
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
5383
+ throw new Error(`Invalid metadata filter key: ${key}`);
5384
+ }
5385
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
5386
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
5387
+ }
5388
+ }
5293
5389
  function createTask(input, db) {
5294
5390
  const d = db || getDatabase();
5295
5391
  const timestamp = now();
@@ -5472,6 +5568,7 @@ function listTasks(filter = {}, db) {
5472
5568
  params.push(filter.task_type);
5473
5569
  }
5474
5570
  }
5571
+ addMetadataConditions(filter.metadata, conditions, params);
5475
5572
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
5476
5573
  if (filter.cursor) {
5477
5574
  try {
@@ -5496,6 +5593,54 @@ function listTasks(filter = {}, db) {
5496
5593
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
5497
5594
  return rows.map(rowToTask);
5498
5595
  }
5596
+ function getTaskByFingerprint(fingerprint, db) {
5597
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
5598
+ return tasks[0] ?? null;
5599
+ }
5600
+ function mergeTaskMetadata(current, next, fingerprint) {
5601
+ return {
5602
+ ...current,
5603
+ ...next ?? {},
5604
+ fingerprint
5605
+ };
5606
+ }
5607
+ function upsertTaskByFingerprint(input, db) {
5608
+ const d = db || getDatabase();
5609
+ const fingerprint = input.fingerprint.trim();
5610
+ if (!fingerprint)
5611
+ throw new Error("fingerprint is required");
5612
+ const existing = getTaskByFingerprint(fingerprint, d);
5613
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
5614
+ if (!existing) {
5615
+ const task2 = createTask({ ...input, metadata }, d);
5616
+ return { task: task2, created: true };
5617
+ }
5618
+ const task = updateTask(existing.id, {
5619
+ version: existing.version,
5620
+ title: input.title,
5621
+ description: input.description,
5622
+ status: input.status,
5623
+ priority: input.priority,
5624
+ project_id: input.project_id,
5625
+ assigned_to: input.assigned_to,
5626
+ working_dir: input.working_dir,
5627
+ plan_id: input.plan_id,
5628
+ task_list_id: input.task_list_id,
5629
+ tags: input.tags,
5630
+ metadata,
5631
+ due_at: input.due_at,
5632
+ estimated_minutes: input.estimated_minutes,
5633
+ sla_minutes: input.sla_minutes,
5634
+ confidence: input.confidence,
5635
+ retry_count: input.retry_count,
5636
+ max_retries: input.max_retries,
5637
+ retry_after: input.retry_after,
5638
+ requires_approval: input.requires_approval,
5639
+ recurrence_rule: input.recurrence_rule,
5640
+ task_type: input.task_type
5641
+ }, d);
5642
+ return { task, created: false };
5643
+ }
5499
5644
  function countTasks(filter = {}, db) {
5500
5645
  const d = db || getDatabase();
5501
5646
  const conditions = [];
@@ -5559,6 +5704,7 @@ function countTasks(filter = {}, db) {
5559
5704
  conditions.push("task_list_id = ?");
5560
5705
  params.push(filter.task_list_id);
5561
5706
  }
5707
+ addMetadataConditions(filter.metadata, conditions, params);
5562
5708
  if (!filter.include_archived) {
5563
5709
  conditions.push("archived_at IS NULL");
5564
5710
  }
@@ -5609,6 +5755,10 @@ function updateTask(id, input, db) {
5609
5755
  sets.push("assigned_to = ?");
5610
5756
  params.push(input.assigned_to);
5611
5757
  }
5758
+ if (input.working_dir !== undefined) {
5759
+ sets.push("working_dir = ?");
5760
+ params.push(input.working_dir);
5761
+ }
5612
5762
  if (input.tags !== undefined) {
5613
5763
  sets.push("tags = ?");
5614
5764
  params.push(JSON.stringify(input.tags));
@@ -5697,6 +5847,8 @@ function updateTask(id, input, db) {
5697
5847
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
5698
5848
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
5699
5849
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
5850
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
5851
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
5700
5852
  if (input.approved_by !== undefined)
5701
5853
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
5702
5854
  const updatedTask = {
@@ -5732,6 +5884,10 @@ function updateTask(id, input, db) {
5732
5884
  if (input.approved_by !== undefined) {
5733
5885
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
5734
5886
  }
5887
+ const updatePayload = taskEventData(updatedTask);
5888
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
5889
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
5890
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
5735
5891
  return updatedTask;
5736
5892
  }
5737
5893
  function deleteTask(id, db) {
@@ -8749,6 +8905,72 @@ function rowToArtifact(row) {
8749
8905
  function getRunRow(runId, db) {
8750
8906
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
8751
8907
  }
8908
+ function normalizeTransactionKey(input) {
8909
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
8910
+ if (!key)
8911
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
8912
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
8913
+ }
8914
+ function loopTransactionMetadata(record) {
8915
+ const value = record.metadata["loop_transaction"];
8916
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
8917
+ }
8918
+ function runKey(record) {
8919
+ const tx = loopTransactionMetadata(record);
8920
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
8921
+ return typeof key === "string" ? key : null;
8922
+ }
8923
+ function loopId(record) {
8924
+ const tx = loopTransactionMetadata(record);
8925
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
8926
+ return typeof value === "string" ? value : null;
8927
+ }
8928
+ function loopRunId(record) {
8929
+ const tx = loopTransactionMetadata(record);
8930
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
8931
+ return typeof value === "string" ? value : null;
8932
+ }
8933
+ function getTaskRunTransactionByKey(key, taskId, db) {
8934
+ if (taskId) {
8935
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
8936
+ }
8937
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
8938
+ if (rows.length > 1)
8939
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
8940
+ return rows[0] ?? null;
8941
+ }
8942
+ function summarizeTaskRun(run) {
8943
+ return {
8944
+ id: run.id,
8945
+ task_id: run.task_id,
8946
+ agent_id: run.agent_id,
8947
+ title: run.title,
8948
+ status: run.status,
8949
+ summary: run.summary,
8950
+ idempotency_key: runKey(run),
8951
+ loop_id: loopId(run),
8952
+ loop_run_id: loopRunId(run),
8953
+ metadata_keys: Object.keys(run.metadata).sort(),
8954
+ started_at: run.started_at,
8955
+ completed_at: run.completed_at,
8956
+ updated_at: run.updated_at
8957
+ };
8958
+ }
8959
+ function findTaskRunByTransactionKey(key, taskId, db) {
8960
+ const d = db || getDatabase();
8961
+ const normalized = normalizeTransactionKey({ key });
8962
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
8963
+ if (transaction?.run_id)
8964
+ return getTaskRun(transaction.run_id, d);
8965
+ return null;
8966
+ }
8967
+ function loopRunCommands(run, key) {
8968
+ return [
8969
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
8970
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
8971
+ `todos runs begin <task-id> --key ${key} --apply --json`
8972
+ ];
8973
+ }
8752
8974
  function resolveTaskRunId(idOrPrefix, db) {
8753
8975
  const d = db || getDatabase();
8754
8976
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -8767,7 +8989,7 @@ function startTaskRun(input, db) {
8767
8989
  const d = db || getDatabase();
8768
8990
  if (!getTask(input.task_id, d))
8769
8991
  throw new TaskNotFoundError(input.task_id);
8770
- const id = uuid();
8992
+ const id = input.id ?? uuid();
8771
8993
  const timestamp = input.started_at || now();
8772
8994
  if (input.claim && input.agent_id) {
8773
8995
  startTask(input.task_id, input.agent_id, d);
@@ -8805,6 +9027,97 @@ function startTaskRun(input, db) {
8805
9027
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
8806
9028
  return run;
8807
9029
  }
9030
+ function beginTaskRunTransaction(input, db) {
9031
+ const d = db || getDatabase();
9032
+ if (!getTask(input.task_id, d))
9033
+ throw new TaskNotFoundError(input.task_id);
9034
+ const timestamp = input.started_at || now();
9035
+ const key = normalizeTransactionKey(input);
9036
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
9037
+ const dryRun = !input.apply;
9038
+ if (existing) {
9039
+ return {
9040
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9041
+ local_only: true,
9042
+ dry_run: dryRun,
9043
+ processed_at: timestamp,
9044
+ action: "matched",
9045
+ key,
9046
+ run: summarizeTaskRun(existing),
9047
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
9048
+ commands: loopRunCommands(existing, key)
9049
+ };
9050
+ }
9051
+ if (dryRun) {
9052
+ return {
9053
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9054
+ local_only: true,
9055
+ dry_run: true,
9056
+ processed_at: timestamp,
9057
+ action: "preview",
9058
+ key,
9059
+ run: null,
9060
+ warnings: [],
9061
+ commands: loopRunCommands(null, key)
9062
+ };
9063
+ }
9064
+ const metadata = redactValue({
9065
+ ...input.metadata || {},
9066
+ loop_transaction: {
9067
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9068
+ idempotency_key: key,
9069
+ loop_id: input.loop_id ?? null,
9070
+ loop_run_id: input.loop_run_id ?? null,
9071
+ first_seen_at: timestamp
9072
+ },
9073
+ idempotency_key: key
9074
+ });
9075
+ const created = d.transaction(() => {
9076
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
9077
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
9078
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
9079
+ uuid(),
9080
+ input.task_id,
9081
+ key,
9082
+ input.loop_id ?? null,
9083
+ input.loop_run_id ?? null,
9084
+ JSON.stringify(metadata),
9085
+ timestamp,
9086
+ timestamp
9087
+ ]);
9088
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
9089
+ if (!transaction)
9090
+ throw new Error(`Could not create run transaction for key: ${key}`);
9091
+ if (transaction.run_id) {
9092
+ const existingRun = getTaskRun(transaction.run_id, d);
9093
+ if (existingRun)
9094
+ return { run: existingRun, action: "matched" };
9095
+ }
9096
+ const run = startTaskRun({
9097
+ id: uuid(),
9098
+ task_id: input.task_id,
9099
+ agent_id: input.agent_id,
9100
+ title: input.title,
9101
+ summary: input.summary,
9102
+ metadata,
9103
+ claim: input.claim,
9104
+ started_at: timestamp
9105
+ }, d);
9106
+ 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]);
9107
+ return { run, action: "created" };
9108
+ })();
9109
+ return {
9110
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9111
+ local_only: true,
9112
+ dry_run: false,
9113
+ processed_at: timestamp,
9114
+ action: created.action,
9115
+ key,
9116
+ run: summarizeTaskRun(created.run),
9117
+ warnings: [],
9118
+ commands: loopRunCommands(created.run, key)
9119
+ };
9120
+ }
8808
9121
  function addTaskRunEvent(input, db) {
8809
9122
  const d = db || getDatabase();
8810
9123
  const runId = resolveTaskRunId(input.run_id, d);
@@ -8984,6 +9297,66 @@ function finishTaskRun(input, db) {
8984
9297
  });
8985
9298
  return updated;
8986
9299
  }
9300
+ function finishTaskRunTransaction(input, db) {
9301
+ const d = db || getDatabase();
9302
+ const timestamp = input.completed_at || now();
9303
+ const status = input.status || "completed";
9304
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
9305
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
9306
+ if (!run) {
9307
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
9308
+ }
9309
+ if (input.task_id && run.task_id !== input.task_id) {
9310
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
9311
+ }
9312
+ const resolvedKey = key || runKey(run) || run.id;
9313
+ const dryRun = input.apply === false;
9314
+ if (run.status !== "running") {
9315
+ const conflict = run.status !== status;
9316
+ return {
9317
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9318
+ local_only: true,
9319
+ dry_run: dryRun,
9320
+ processed_at: timestamp,
9321
+ action: conflict ? "conflict" : "matched",
9322
+ key: resolvedKey,
9323
+ run: summarizeTaskRun(run),
9324
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
9325
+ commands: loopRunCommands(run, resolvedKey)
9326
+ };
9327
+ }
9328
+ if (dryRun) {
9329
+ return {
9330
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9331
+ local_only: true,
9332
+ dry_run: true,
9333
+ processed_at: timestamp,
9334
+ action: "preview",
9335
+ key: resolvedKey,
9336
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
9337
+ warnings: [],
9338
+ commands: loopRunCommands(run, resolvedKey)
9339
+ };
9340
+ }
9341
+ const finished = finishTaskRun({
9342
+ run_id: run.id,
9343
+ status,
9344
+ summary: input.summary,
9345
+ agent_id: input.agent_id,
9346
+ completed_at: timestamp
9347
+ }, d);
9348
+ return {
9349
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9350
+ local_only: true,
9351
+ dry_run: false,
9352
+ processed_at: timestamp,
9353
+ action: "finished",
9354
+ key: resolvedKey,
9355
+ run: summarizeTaskRun(finished),
9356
+ warnings: [],
9357
+ commands: loopRunCommands(finished, resolvedKey)
9358
+ };
9359
+ }
8987
9360
  function listTaskRuns(taskId, db) {
8988
9361
  const d = db || getDatabase();
8989
9362
  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();
@@ -9001,6 +9374,7 @@ function getTaskRunLedger(runId, db) {
9001
9374
  const files = d.query("SELECT * FROM task_files WHERE task_id = ? ORDER BY updated_at DESC, path").all(run.task_id);
9002
9375
  return { run, events, commands, artifacts, files };
9003
9376
  }
9377
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
9004
9378
  var init_task_runs = __esm(() => {
9005
9379
  init_artifact_store();
9006
9380
  init_event_hooks();
@@ -9389,6 +9763,7 @@ var init_calendar = __esm(() => {
9389
9763
  var exports_tasks = {};
9390
9764
  __export(exports_tasks, {
9391
9765
  watchTask: () => watchTask,
9766
+ upsertTaskByFingerprint: () => upsertTaskByFingerprint,
9392
9767
  updateTaskBoard: () => updateTaskBoard,
9393
9768
  updateTask: () => updateTask,
9394
9769
  unwatchTask: () => unwatchTask,
@@ -9432,6 +9807,7 @@ __export(exports_tasks, {
9432
9807
  getTaskGraph: () => getTaskGraph,
9433
9808
  getTaskDependents: () => getTaskDependents,
9434
9809
  getTaskDependencies: () => getTaskDependencies,
9810
+ getTaskByFingerprint: () => getTaskByFingerprint,
9435
9811
  getTaskBoard: () => getTaskBoard,
9436
9812
  getTask: () => getTask,
9437
9813
  getStatus: () => getStatus,
@@ -10825,6 +11201,39 @@ async function handleCreateTask(req, ctx, json2, taskToSummary2) {
10825
11201
  return json2({ error: e instanceof Error ? e.message : "Failed to create task" }, 500);
10826
11202
  }
10827
11203
  }
11204
+ async function handleUpsertTask(req, ctx, json2, taskToSummary2) {
11205
+ try {
11206
+ const body = await req.json();
11207
+ if (typeof body["fingerprint"] !== "string" || body["fingerprint"].trim() === "") {
11208
+ return json2({ error: "Missing 'fingerprint'" }, 400);
11209
+ }
11210
+ if (typeof body["title"] !== "string" || body["title"].trim() === "") {
11211
+ return json2({ error: "Missing 'title'" }, 400);
11212
+ }
11213
+ const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? { ...body["metadata"] } : {};
11214
+ for (const key of ["expectation_id", "expectation_fingerprint", "evidence_paths", "origin_loop_id", "origin_run_id", "expected", "observed", "acceptance"]) {
11215
+ if (body[key] !== undefined)
11216
+ metadata[key] = body[key];
11217
+ }
11218
+ const result = upsertTaskByFingerprint({
11219
+ fingerprint: body["fingerprint"],
11220
+ title: body["title"],
11221
+ description: typeof body["description"] === "string" ? body["description"] : undefined,
11222
+ status: body["status"],
11223
+ priority: body["priority"],
11224
+ project_id: typeof body["project_id"] === "string" ? body["project_id"] : undefined,
11225
+ task_list_id: typeof body["task_list_id"] === "string" ? body["task_list_id"] : undefined,
11226
+ assigned_to: typeof body["assigned_to"] === "string" ? body["assigned_to"] : undefined,
11227
+ working_dir: typeof body["working_dir"] === "string" ? body["working_dir"] : undefined,
11228
+ tags: Array.isArray(body["tags"]) ? body["tags"].filter((tag) => typeof tag === "string") : undefined,
11229
+ metadata
11230
+ });
11231
+ ctx.broadcastEvent({ type: "task", task_id: result.task.id, action: result.created ? "created" : "updated", agent_id: result.task.agent_id, project_id: result.task.project_id });
11232
+ return json2({ created: result.created, task: taskToSummary2(result.task) }, result.created ? 201 : 200);
11233
+ } catch (e) {
11234
+ return json2({ error: e instanceof Error ? e.message : "Failed to upsert task" }, 500);
11235
+ }
11236
+ }
10828
11237
  function handleTasksExport(_req, url, _ctx, _json, taskToSummary2) {
10829
11238
  const format = url.searchParams.get("format") || "json";
10830
11239
  const status = url.searchParams.get("status") || undefined;
@@ -32569,6 +32978,7 @@ var init_token_utils = __esm(() => {
32569
32978
  "add_task_run_event",
32570
32979
  "add_task_run_file",
32571
32980
  "acknowledge_handoff",
32981
+ "begin_task_run_transaction",
32572
32982
  "build_local_report",
32573
32983
  "cancel_agent_run_dispatch",
32574
32984
  "finish_task_run",
@@ -32616,6 +33026,7 @@ var init_token_utils = __esm(() => {
32616
33026
  "list_local_snapshots",
32617
33027
  "list_retrospectives",
32618
33028
  "list_risks",
33029
+ "list_task_findings",
32619
33030
  "list_task_runs",
32620
33031
  "list_verification_providers",
32621
33032
  "merge_duplicate_task",
@@ -32624,6 +33035,7 @@ var init_token_utils = __esm(() => {
32624
33035
  "remove_review_routing_rule",
32625
33036
  "restore_local_backup",
32626
33037
  "retry_agent_run_dispatch",
33038
+ "resolve_missing_task_findings",
32627
33039
  "resolve_mentions",
32628
33040
  "run_next_agent_dispatch",
32629
33041
  "search_knowledge_records",
@@ -32666,9 +33078,17 @@ var init_token_utils = __esm(() => {
32666
33078
  "unlock_file",
32667
33079
  "unwatch_task",
32668
33080
  "update_comment",
33081
+ "upsert_task_finding",
32669
33082
  "update_risk",
32670
33083
  "watch_task"
32671
33084
  ],
33085
+ loops: [
33086
+ "begin_task_run_transaction",
33087
+ "finish_task_run",
33088
+ "list_task_findings",
33089
+ "resolve_missing_task_findings",
33090
+ "upsert_task_finding"
33091
+ ],
32672
33092
  agents: [
32673
33093
  "auto_assign_task",
32674
33094
  "delete_agent",
@@ -32750,7 +33170,7 @@ var init_token_utils = __esm(() => {
32750
33170
  maintenance: ["extract_todos", "get_sla_breaches", "notify_upcoming_deadlines", "run_doctor", "score_task", "watch_source_todos"]
32751
33171
  };
32752
33172
  MCP_PROFILE_GROUPS = {
32753
- minimal: ["core"],
33173
+ minimal: ["core", "loops"],
32754
33174
  core: ["core"],
32755
33175
  standard: ["core", "tasks", "projects", "resources", "agents", "metadata"],
32756
33176
  agent: ["core", "tasks", "projects", "resources"],
@@ -32830,6 +33250,61 @@ function registerTaskCrudTools(server, ctx) {
32830
33250
  }
32831
33251
  });
32832
33252
  }
33253
+ if (shouldRegisterTool("upsert_task")) {
33254
+ server.tool("upsert_task", "Create or update a task by stable metadata fingerprint. Metadata is shallow-merged on updates.", {
33255
+ fingerprint: exports_external.string().describe("Stable dedupe fingerprint stored as metadata.fingerprint"),
33256
+ title: exports_external.string().describe("Task title"),
33257
+ description: exports_external.string().optional().describe("Task description"),
33258
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
33259
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
33260
+ project_id: exports_external.string().optional().describe("Project ID"),
33261
+ task_list_id: exports_external.string().optional().describe("Task list ID"),
33262
+ assigned_to: exports_external.string().optional().describe("Agent ID or name to assign to"),
33263
+ tags: exports_external.array(exports_external.string()).optional().describe("Tags for the task"),
33264
+ working_dir: exports_external.string().optional().describe("Working directory associated with the task"),
33265
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Metadata object to shallow-merge"),
33266
+ expectation_id: exports_external.string().optional(),
33267
+ expectation_fingerprint: exports_external.string().optional(),
33268
+ evidence_paths: exports_external.array(exports_external.string()).optional(),
33269
+ origin_loop_id: exports_external.string().optional(),
33270
+ origin_run_id: exports_external.string().optional(),
33271
+ expected: exports_external.unknown().optional(),
33272
+ observed: exports_external.unknown().optional(),
33273
+ acceptance: exports_external.unknown().optional()
33274
+ }, async (params) => {
33275
+ try {
33276
+ const { assigned_to, project_id, task_list_id, metadata, expectation_id, expectation_fingerprint, evidence_paths, origin_loop_id, origin_run_id, expected, observed, acceptance, ...rest } = params;
33277
+ const mergedMetadata = { ...metadata ?? {} };
33278
+ if (expectation_id !== undefined)
33279
+ mergedMetadata["expectation_id"] = expectation_id;
33280
+ if (expectation_fingerprint !== undefined)
33281
+ mergedMetadata["expectation_fingerprint"] = expectation_fingerprint;
33282
+ if (evidence_paths !== undefined)
33283
+ mergedMetadata["evidence_paths"] = evidence_paths;
33284
+ if (origin_loop_id !== undefined)
33285
+ mergedMetadata["origin_loop_id"] = origin_loop_id;
33286
+ if (origin_run_id !== undefined)
33287
+ mergedMetadata["origin_run_id"] = origin_run_id;
33288
+ if (expected !== undefined)
33289
+ mergedMetadata["expected"] = expected;
33290
+ if (observed !== undefined)
33291
+ mergedMetadata["observed"] = observed;
33292
+ if (acceptance !== undefined)
33293
+ mergedMetadata["acceptance"] = acceptance;
33294
+ const resolved = { ...rest, metadata: mergedMetadata };
33295
+ if (assigned_to)
33296
+ resolved.assigned_to = resolveAssignee(assigned_to);
33297
+ if (project_id)
33298
+ resolved.project_id = resolveId(project_id, "projects");
33299
+ if (task_list_id)
33300
+ resolved.task_list_id = resolveId(task_list_id, "task_lists");
33301
+ const result = upsertTaskByFingerprint(resolved);
33302
+ return { content: [{ type: "text", text: compactJson({ created: result.created, task: JSON.parse(mutationTaskResponse(result.task)) }) }] };
33303
+ } catch (e) {
33304
+ return { content: [{ type: "text", text: formatError2(e) }], isError: true };
33305
+ }
33306
+ });
33307
+ }
32833
33308
  if (shouldRegisterTool("list_tasks")) {
32834
33309
  server.tool("list_tasks", "List tasks with optional filters. Pass empty arrays for multi-value filters (e.g. status=[] shows all).", {
32835
33310
  status: exports_external.union([exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]), exports_external.array(exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]))]).optional().describe("Filter by status"),
@@ -32841,7 +33316,8 @@ function registerTaskCrudTools(server, ctx) {
32841
33316
  created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
32842
33317
  created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
32843
33318
  limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
32844
- offset: exports_external.number().optional().describe("Pagination offset")
33319
+ offset: exports_external.number().optional().describe("Pagination offset"),
33320
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Exact top-level metadata filters")
32845
33321
  }, async (params) => {
32846
33322
  try {
32847
33323
  const resolved = { ...params };
@@ -36382,7 +36858,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
36382
36858
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
36383
36859
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
36384
36860
  }
36385
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
36861
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
36386
36862
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
36387
36863
  mergedDuplicates.push({
36388
36864
  id: duplicate.id,
@@ -36443,7 +36919,7 @@ function mergeDuplicateTask(input, db) {
36443
36919
  updateTask(primary.id, {
36444
36920
  version: primary.version,
36445
36921
  tags: mergedTags,
36446
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
36922
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
36447
36923
  description: mergeTaskDescription(primary, duplicate) ?? undefined
36448
36924
  }, d);
36449
36925
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -66620,6 +67096,356 @@ var init_task_meta_tools = __esm(() => {
66620
67096
  init_zod();
66621
67097
  });
66622
67098
 
67099
+ // src/db/findings.ts
67100
+ function parseObject2(value) {
67101
+ if (!value)
67102
+ return {};
67103
+ try {
67104
+ const parsed = JSON.parse(value);
67105
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
67106
+ } catch {
67107
+ return {};
67108
+ }
67109
+ }
67110
+ function normalizeKey(value) {
67111
+ return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
67112
+ }
67113
+ function normalizeFingerprint(value) {
67114
+ const normalized = normalizeKey(value);
67115
+ if (!normalized)
67116
+ throw new Error("finding fingerprint is required");
67117
+ return normalized.slice(0, 240);
67118
+ }
67119
+ function normalizeSeverity(value) {
67120
+ const normalized = normalizeKey(value || "medium");
67121
+ if (SEVERITIES.has(normalized))
67122
+ return normalized;
67123
+ if (/^(p0|blocker|urgent|highest)$/.test(normalized))
67124
+ return "critical";
67125
+ if (/^(p1|major)$/.test(normalized))
67126
+ return "high";
67127
+ if (/^(p3|minor|info)$/.test(normalized))
67128
+ return "low";
67129
+ return "medium";
67130
+ }
67131
+ function normalizeStatus(value) {
67132
+ const normalized = normalizeKey(value || "open");
67133
+ if (STATUSES.has(normalized))
67134
+ return normalized;
67135
+ if (normalized === "closed" || normalized === "fixed")
67136
+ return "resolved";
67137
+ return "open";
67138
+ }
67139
+ function normalizeResolutionStatus(value) {
67140
+ const status = normalizeStatus(value || "resolved");
67141
+ if (status === "open")
67142
+ throw new Error("resolve-missing status must be resolved or ignored");
67143
+ return status;
67144
+ }
67145
+ function redactOptional(value, max = 2000) {
67146
+ if (!value)
67147
+ return null;
67148
+ const redacted = redactEvidenceText(value).trim();
67149
+ if (!redacted)
67150
+ return null;
67151
+ return redacted.length > max ? `${redacted.slice(0, max - 3)}...` : redacted;
67152
+ }
67153
+ function rowToFinding(row) {
67154
+ return {
67155
+ schema_version: TASK_FINDING_SCHEMA_VERSION,
67156
+ ...row,
67157
+ severity: normalizeSeverity(row.severity),
67158
+ status: normalizeStatus(row.status),
67159
+ metadata: parseObject2(row.metadata)
67160
+ };
67161
+ }
67162
+ function compactFinding(finding2) {
67163
+ return {
67164
+ schema_version: TASK_FINDING_SCHEMA_VERSION,
67165
+ id: finding2.id,
67166
+ task_id: finding2.task_id,
67167
+ run_id: finding2.run_id,
67168
+ fingerprint: finding2.fingerprint,
67169
+ title: finding2.title,
67170
+ severity: finding2.severity,
67171
+ status: finding2.status,
67172
+ source: finding2.source,
67173
+ summary: finding2.summary,
67174
+ artifact_path: finding2.artifact_path,
67175
+ first_seen_at: finding2.first_seen_at,
67176
+ last_seen_at: finding2.last_seen_at,
67177
+ resolved_at: finding2.resolved_at,
67178
+ metadata_keys: Object.keys(finding2.metadata).sort()
67179
+ };
67180
+ }
67181
+ function previewFinding(existing, next, timestamp3) {
67182
+ return {
67183
+ ...existing,
67184
+ run_id: next.run_id,
67185
+ title: next.title,
67186
+ severity: next.severity,
67187
+ status: next.status,
67188
+ source: next.source,
67189
+ summary: next.summary,
67190
+ artifact_path: next.artifact_path,
67191
+ metadata: next.metadata,
67192
+ last_seen_at: timestamp3,
67193
+ resolved_at: next.status === "open" ? null : existing.resolved_at || timestamp3,
67194
+ updated_at: timestamp3
67195
+ };
67196
+ }
67197
+ function upsertAction(existing, next) {
67198
+ if (sameFinding(existing, next))
67199
+ return "matched";
67200
+ return existing.status !== "open" && next.status === "open" ? "reopened" : "updated";
67201
+ }
67202
+ function resolveRunForTask(runId, taskId, db) {
67203
+ if (!runId)
67204
+ return null;
67205
+ const resolved = resolveTaskRunId(runId, db);
67206
+ const run = getTaskRun(resolved, db);
67207
+ if (!run)
67208
+ throw new Error(`Run not found: ${runId}`);
67209
+ if (run.task_id !== taskId)
67210
+ throw new Error(`Run ${resolved} belongs to task ${run.task_id}, not ${taskId}`);
67211
+ return resolved;
67212
+ }
67213
+ function getFindingByFingerprint(taskId, fingerprint2, db) {
67214
+ const row = db.query("SELECT * FROM task_findings WHERE task_id = ? AND fingerprint = ?").get(taskId, fingerprint2);
67215
+ return row ? rowToFinding(row) : null;
67216
+ }
67217
+ function assertTask(taskId, db) {
67218
+ if (!getTask(taskId, db))
67219
+ throw new TaskNotFoundError(taskId);
67220
+ }
67221
+ function nextFinding(input, db) {
67222
+ const fingerprint2 = normalizeFingerprint(input.fingerprint);
67223
+ const title = redactOptional(input.title, 300);
67224
+ if (!title)
67225
+ throw new Error("finding title is required");
67226
+ return {
67227
+ fingerprint: fingerprint2,
67228
+ run_id: resolveRunForTask(input.run_id, input.task_id, db),
67229
+ title,
67230
+ severity: normalizeSeverity(input.severity),
67231
+ status: normalizeStatus(input.status),
67232
+ source: redactOptional(input.source, 120),
67233
+ summary: redactOptional(input.summary, 2000),
67234
+ artifact_path: redactOptional(input.artifact_path, 1000),
67235
+ metadata: redactValue(input.metadata || {})
67236
+ };
67237
+ }
67238
+ function sameFinding(left, right) {
67239
+ return left.run_id === right.run_id && left.title === right.title && left.severity === right.severity && left.status === right.status && left.source === right.source && left.summary === right.summary && left.artifact_path === right.artifact_path && JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
67240
+ }
67241
+ function upsertTaskFinding(input, db) {
67242
+ const d = db || getDatabase();
67243
+ assertTask(input.task_id, d);
67244
+ const timestamp3 = input.observed_at || now();
67245
+ const warnings = [];
67246
+ const next = nextFinding(input, d);
67247
+ const existing = getFindingByFingerprint(input.task_id, next.fingerprint, d);
67248
+ const dryRun = !input.apply;
67249
+ if (dryRun) {
67250
+ const action2 = existing ? upsertAction(existing, next) : "preview";
67251
+ return {
67252
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
67253
+ local_only: true,
67254
+ dry_run: true,
67255
+ processed_at: timestamp3,
67256
+ action: action2,
67257
+ fingerprint: next.fingerprint,
67258
+ finding: existing ? compactFinding(previewFinding(existing, next, timestamp3)) : null,
67259
+ warnings
67260
+ };
67261
+ }
67262
+ if (!existing) {
67263
+ const id = uuid();
67264
+ d.run(`INSERT INTO task_findings (
67265
+ id, task_id, run_id, fingerprint, title, severity, status, source, summary, artifact_path,
67266
+ metadata, first_seen_at, last_seen_at, resolved_at, created_at, updated_at
67267
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
67268
+ id,
67269
+ input.task_id,
67270
+ next.run_id,
67271
+ next.fingerprint,
67272
+ next.title,
67273
+ next.severity,
67274
+ next.status,
67275
+ next.source,
67276
+ next.summary,
67277
+ next.artifact_path,
67278
+ JSON.stringify(next.metadata),
67279
+ timestamp3,
67280
+ timestamp3,
67281
+ next.status === "open" ? null : timestamp3,
67282
+ timestamp3,
67283
+ timestamp3
67284
+ ]);
67285
+ return {
67286
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
67287
+ local_only: true,
67288
+ dry_run: false,
67289
+ processed_at: timestamp3,
67290
+ action: "created",
67291
+ fingerprint: next.fingerprint,
67292
+ finding: compactFinding(getFindingByFingerprint(input.task_id, next.fingerprint, d)),
67293
+ warnings
67294
+ };
67295
+ }
67296
+ if (sameFinding(existing, next)) {
67297
+ return {
67298
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
67299
+ local_only: true,
67300
+ dry_run: false,
67301
+ processed_at: timestamp3,
67302
+ action: "matched",
67303
+ fingerprint: next.fingerprint,
67304
+ finding: compactFinding(existing),
67305
+ warnings
67306
+ };
67307
+ }
67308
+ const action = upsertAction(existing, next);
67309
+ d.run(`UPDATE task_findings SET
67310
+ run_id = ?, title = ?, severity = ?, status = ?, source = ?, summary = ?, artifact_path = ?,
67311
+ metadata = ?, last_seen_at = ?, resolved_at = ?, updated_at = ?
67312
+ WHERE id = ?`, [
67313
+ next.run_id,
67314
+ next.title,
67315
+ next.severity,
67316
+ next.status,
67317
+ next.source,
67318
+ next.summary,
67319
+ next.artifact_path,
67320
+ JSON.stringify(next.metadata),
67321
+ timestamp3,
67322
+ next.status === "open" ? null : existing.resolved_at || timestamp3,
67323
+ timestamp3,
67324
+ existing.id
67325
+ ]);
67326
+ return {
67327
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
67328
+ local_only: true,
67329
+ dry_run: false,
67330
+ processed_at: timestamp3,
67331
+ action,
67332
+ fingerprint: next.fingerprint,
67333
+ finding: compactFinding(getFindingByFingerprint(input.task_id, next.fingerprint, d)),
67334
+ warnings
67335
+ };
67336
+ }
67337
+ function listTaskFindings(filter = {}, db) {
67338
+ const d = db || getDatabase();
67339
+ const conditions = ["1=1"];
67340
+ const params = [];
67341
+ if (filter.task_id) {
67342
+ conditions.push("task_id = ?");
67343
+ params.push(filter.task_id);
67344
+ }
67345
+ if (filter.run_id) {
67346
+ conditions.push("run_id = ?");
67347
+ params.push(resolveTaskRunId(filter.run_id, d));
67348
+ }
67349
+ if (filter.status) {
67350
+ conditions.push("status = ?");
67351
+ params.push(normalizeStatus(filter.status));
67352
+ }
67353
+ if (filter.source) {
67354
+ conditions.push("source = ?");
67355
+ params.push(redactOptional(filter.source, 120));
67356
+ }
67357
+ const limit = Math.min(Math.max(Math.floor(filter.limit ?? 50), 1), 500);
67358
+ const rows = d.query(`SELECT * FROM task_findings WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC, created_at DESC LIMIT ?`).all(...[...params, limit]);
67359
+ return rows.map(rowToFinding);
67360
+ }
67361
+ function listCompactTaskFindings(filter = {}, db) {
67362
+ return listTaskFindings(filter, db).map(compactFinding);
67363
+ }
67364
+ function resolveMissingTaskFindings(input, db) {
67365
+ const d = db || getDatabase();
67366
+ assertTask(input.task_id, d);
67367
+ const timestamp3 = input.resolved_at || now();
67368
+ const status = normalizeResolutionStatus(input.status);
67369
+ const runId = resolveRunForTask(input.run_id, input.task_id, d);
67370
+ const present = new Set(input.fingerprints.map(normalizeFingerprint));
67371
+ const warnings = [];
67372
+ const conditions = ["task_id = ?", "status = 'open'"];
67373
+ const params = [input.task_id];
67374
+ if (input.source) {
67375
+ conditions.push("source = ?");
67376
+ params.push(redactOptional(input.source, 120));
67377
+ }
67378
+ const candidates = d.query(`SELECT * FROM task_findings WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC, created_at DESC`).all(...params).map(rowToFinding).filter((finding2) => !present.has(finding2.fingerprint));
67379
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 200);
67380
+ const display = candidates.slice(0, limit);
67381
+ const omittedCount = Math.max(0, candidates.length - display.length);
67382
+ if (!input.apply) {
67383
+ return {
67384
+ schema_version: TASK_FINDING_RESOLVE_MISSING_SCHEMA_VERSION,
67385
+ local_only: true,
67386
+ dry_run: true,
67387
+ processed_at: timestamp3,
67388
+ action: candidates.length > 0 ? "preview" : "noop",
67389
+ task_id: input.task_id,
67390
+ source: input.source ? redactOptional(input.source, 120) : null,
67391
+ run_id: runId,
67392
+ present_fingerprint_count: present.size,
67393
+ candidate_count: candidates.length,
67394
+ changed_count: 0,
67395
+ omitted_count: omittedCount,
67396
+ findings: display.map(compactFinding),
67397
+ warnings
67398
+ };
67399
+ }
67400
+ const metadataPatch = redactValue({
67401
+ resolved_by: {
67402
+ agent_id: input.agent_id ?? null,
67403
+ run_id: runId,
67404
+ reason: input.reason ? redactEvidenceText(input.reason) : "missing from latest loop finding set"
67405
+ }
67406
+ });
67407
+ const tx = d.transaction(() => {
67408
+ for (const finding2 of candidates) {
67409
+ d.run("UPDATE task_findings SET status = ?, resolved_at = ?, metadata = ?, updated_at = ? WHERE id = ? AND status = 'open'", [
67410
+ status,
67411
+ timestamp3,
67412
+ JSON.stringify({ ...finding2.metadata, ...metadataPatch }),
67413
+ timestamp3,
67414
+ finding2.id
67415
+ ]);
67416
+ }
67417
+ });
67418
+ tx();
67419
+ const updated = candidates.map((finding2) => d.query("SELECT * FROM task_findings WHERE id = ?").get(finding2.id)).filter((row) => Boolean(row)).map(rowToFinding);
67420
+ const visibleUpdated = updated.slice(0, limit);
67421
+ return {
67422
+ schema_version: TASK_FINDING_RESOLVE_MISSING_SCHEMA_VERSION,
67423
+ local_only: true,
67424
+ dry_run: false,
67425
+ processed_at: timestamp3,
67426
+ action: updated.length > 0 ? status : "noop",
67427
+ task_id: input.task_id,
67428
+ source: input.source ? redactOptional(input.source, 120) : null,
67429
+ run_id: runId,
67430
+ present_fingerprint_count: present.size,
67431
+ candidate_count: candidates.length,
67432
+ changed_count: updated.length,
67433
+ omitted_count: omittedCount,
67434
+ findings: visibleUpdated.map(compactFinding),
67435
+ warnings
67436
+ };
67437
+ }
67438
+ var TASK_FINDING_SCHEMA_VERSION = "todos.task_finding.v1", TASK_FINDING_UPSERT_SCHEMA_VERSION = "todos.task_finding_upsert.v1", TASK_FINDING_RESOLVE_MISSING_SCHEMA_VERSION = "todos.task_finding_resolve_missing.v1", SEVERITIES, STATUSES;
67439
+ var init_findings = __esm(() => {
67440
+ init_redaction();
67441
+ init_types();
67442
+ init_database();
67443
+ init_tasks();
67444
+ init_task_runs();
67445
+ SEVERITIES = new Set(["low", "medium", "high", "critical"]);
67446
+ STATUSES = new Set(["open", "resolved", "ignored"]);
67447
+ });
67448
+
66623
67449
  // src/lib/agent-run-dispatcher.ts
66624
67450
  function dispatcherFromRun(run) {
66625
67451
  const value = run.metadata["agent_run_dispatcher"];
@@ -67280,7 +68106,7 @@ function parseArray2(value) {
67280
68106
  return [];
67281
68107
  }
67282
68108
  }
67283
- function parseObject2(value) {
68109
+ function parseObject3(value) {
67284
68110
  try {
67285
68111
  const parsed = JSON.parse(value || "{}");
67286
68112
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -67321,7 +68147,7 @@ function rowToKnowledgeRecord(row) {
67321
68147
  agent_id: row.agent_id,
67322
68148
  snapshot_id: row.snapshot_id,
67323
68149
  tags: parseArray2(row.tags),
67324
- metadata: redactValue(parseObject2(row.metadata)),
68150
+ metadata: redactValue(parseObject3(row.metadata)),
67325
68151
  created_at: row.created_at,
67326
68152
  updated_at: row.updated_at
67327
68153
  };
@@ -67540,7 +68366,7 @@ function parseArray3(value) {
67540
68366
  return [];
67541
68367
  }
67542
68368
  }
67543
- function parseObject3(value) {
68369
+ function parseObject4(value) {
67544
68370
  try {
67545
68371
  const parsed = JSON.parse(value || "{}");
67546
68372
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -67589,7 +68415,7 @@ function rowToRisk(row) {
67589
68415
  plan_id: row.plan_id,
67590
68416
  task_id: row.task_id,
67591
68417
  tags: parseArray3(row.tags),
67592
- metadata: redactValue(parseObject3(row.metadata)),
68418
+ metadata: redactValue(parseObject4(row.metadata)),
67593
68419
  created_at: row.created_at,
67594
68420
  updated_at: row.updated_at,
67595
68421
  closed_at: row.closed_at
@@ -73407,6 +74233,38 @@ ${lines.join(`
73407
74233
  }
73408
74234
  });
73409
74235
  }
74236
+ if (shouldRegisterTool("begin_task_run_transaction")) {
74237
+ server.tool("begin_task_run_transaction", "Preview or apply an idempotent local loop run transaction keyed by a stable loop/run id.", {
74238
+ task_id: exports_external.string().describe("Task ID"),
74239
+ key: exports_external.string().optional().describe("Stable idempotency key"),
74240
+ loop_id: exports_external.string().optional().describe("Loop identifier; used as key fallback"),
74241
+ loop_run_id: exports_external.string().optional().describe("Loop run identifier; used as key fallback"),
74242
+ agent_id: exports_external.string().optional().describe("Agent starting the run"),
74243
+ title: exports_external.string().optional().describe("Run title"),
74244
+ summary: exports_external.string().optional().describe("Run summary"),
74245
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Additional local metadata"),
74246
+ claim: exports_external.boolean().optional().describe("Claim/start the task before recording the run"),
74247
+ apply: exports_external.boolean().optional().describe("Apply the transaction; false or omitted is dry-run")
74248
+ }, async ({ task_id, key, loop_id, loop_run_id, agent_id, title, summary, metadata, claim, apply }) => {
74249
+ try {
74250
+ const result = beginTaskRunTransaction({
74251
+ task_id: resolveId(task_id),
74252
+ key,
74253
+ loop_id,
74254
+ loop_run_id,
74255
+ agent_id,
74256
+ title,
74257
+ summary,
74258
+ metadata,
74259
+ claim,
74260
+ apply
74261
+ });
74262
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
74263
+ } catch (e) {
74264
+ return { content: [{ type: "text", text: formatError2(e) }], isError: true };
74265
+ }
74266
+ });
74267
+ }
73410
74268
  if (shouldRegisterTool("list_task_runs")) {
73411
74269
  server.tool("list_task_runs", "List local run ledger entries, optionally scoped to a task.", { task_id: exports_external.string().optional().describe("Optional task ID") }, async ({ task_id }) => {
73412
74270
  try {
@@ -73504,15 +74362,117 @@ ${lines.join(`
73504
74362
  });
73505
74363
  }
73506
74364
  if (shouldRegisterTool("finish_task_run")) {
73507
- server.tool("finish_task_run", "Finish a local run ledger entry as completed, failed, or cancelled.", {
73508
- run_id: exports_external.string().describe("Run ID or prefix"),
73509
- status: exports_external.enum(["completed", "failed", "cancelled"]).describe("Final run status"),
74365
+ server.tool("finish_task_run", "Finish a local run ledger entry as completed, failed, or cancelled. Supports idempotent lookup by key.", {
74366
+ run_id: exports_external.string().optional().describe("Run ID or prefix"),
74367
+ key: exports_external.string().optional().describe("Idempotency key when run_id is omitted"),
74368
+ task_id: exports_external.string().optional().describe("Task scope for key lookup"),
74369
+ status: exports_external.enum(["completed", "failed", "cancelled"]).optional().describe("Final run status"),
73510
74370
  summary: exports_external.string().optional().describe("Final summary"),
73511
- agent_id: exports_external.string().optional().describe("Agent finishing the run")
73512
- }, async ({ run_id, status, summary, agent_id }) => {
74371
+ agent_id: exports_external.string().optional().describe("Agent finishing the run"),
74372
+ apply: exports_external.boolean().optional().describe("Apply the transaction; false previews without mutating")
74373
+ }, async ({ run_id, key, task_id, status, summary, agent_id, apply }) => {
73513
74374
  try {
73514
- const run = finishTaskRun({ run_id, status, summary, agent_id });
73515
- return { content: [{ type: "text", text: JSON.stringify(run, null, 2) }] };
74375
+ if (run_id && !key && apply === undefined) {
74376
+ const result2 = finishTaskRunTransaction({ run_id, status: status || "completed", summary, agent_id, apply: true });
74377
+ const run = result2.run ? getTaskRunLedger(result2.run.id).run : null;
74378
+ return { content: [{ type: "text", text: JSON.stringify(run, null, 2) }] };
74379
+ }
74380
+ const result = finishTaskRunTransaction({
74381
+ run_id,
74382
+ key,
74383
+ task_id: task_id ? resolveId(task_id) : undefined,
74384
+ status: status || "completed",
74385
+ summary,
74386
+ agent_id,
74387
+ apply: apply !== false
74388
+ });
74389
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
74390
+ } catch (e) {
74391
+ return { content: [{ type: "text", text: formatError2(e) }], isError: true };
74392
+ }
74393
+ });
74394
+ }
74395
+ if (shouldRegisterTool("upsert_task_finding")) {
74396
+ server.tool("upsert_task_finding", "Preview or apply an idempotent local finding upsert scoped by task and fingerprint.", {
74397
+ task_id: exports_external.string().describe("Task ID"),
74398
+ fingerprint: exports_external.string().describe("Stable finding fingerprint"),
74399
+ title: exports_external.string().describe("Finding title"),
74400
+ severity: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Finding severity"),
74401
+ status: exports_external.enum(["open", "resolved", "ignored"]).optional().describe("Finding status"),
74402
+ source: exports_external.string().optional().describe("Loop/tool source name"),
74403
+ summary: exports_external.string().optional().describe("Bounded finding summary"),
74404
+ artifact_path: exports_external.string().optional().describe("Local artifact path/reference; content is not read"),
74405
+ run_id: exports_external.string().optional().describe("Run ledger ID or prefix"),
74406
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Additional local metadata"),
74407
+ apply: exports_external.boolean().optional().describe("Apply the upsert; false or omitted is dry-run")
74408
+ }, async ({ task_id, fingerprint: fingerprint3, title, severity, status, source: source3, summary, artifact_path, run_id, metadata, apply }) => {
74409
+ try {
74410
+ const result = upsertTaskFinding({
74411
+ task_id: resolveId(task_id),
74412
+ fingerprint: fingerprint3,
74413
+ title,
74414
+ severity,
74415
+ status,
74416
+ source: source3,
74417
+ summary,
74418
+ artifact_path,
74419
+ run_id,
74420
+ metadata,
74421
+ apply
74422
+ });
74423
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
74424
+ } catch (e) {
74425
+ return { content: [{ type: "text", text: formatError2(e) }], isError: true };
74426
+ }
74427
+ });
74428
+ }
74429
+ if (shouldRegisterTool("list_task_findings")) {
74430
+ server.tool("list_task_findings", "List compact local findings with bounded output.", {
74431
+ task_id: exports_external.string().optional().describe("Filter by task"),
74432
+ run_id: exports_external.string().optional().describe("Filter by run"),
74433
+ status: exports_external.enum(["open", "resolved", "ignored"]).optional().describe("Filter by status"),
74434
+ source: exports_external.string().optional().describe("Filter by source"),
74435
+ limit: exports_external.number().optional().describe("Maximum findings to return")
74436
+ }, async ({ task_id, run_id, status, source: source3, limit }) => {
74437
+ try {
74438
+ const findings = listCompactTaskFindings({
74439
+ task_id: task_id ? resolveId(task_id) : undefined,
74440
+ run_id,
74441
+ status,
74442
+ source: source3,
74443
+ limit
74444
+ });
74445
+ return { content: [{ type: "text", text: JSON.stringify(findings) }] };
74446
+ } catch (e) {
74447
+ return { content: [{ type: "text", text: formatError2(e) }], isError: true };
74448
+ }
74449
+ });
74450
+ }
74451
+ if (shouldRegisterTool("resolve_missing_task_findings")) {
74452
+ server.tool("resolve_missing_task_findings", "Resolve open findings absent from the latest loop finding set.", {
74453
+ task_id: exports_external.string().describe("Task ID"),
74454
+ fingerprints: exports_external.array(exports_external.string()).optional().describe("Fingerprints still present in the latest run"),
74455
+ source: exports_external.string().optional().describe("Only resolve findings from this source"),
74456
+ run_id: exports_external.string().optional().describe("Run ledger ID or prefix for audit metadata"),
74457
+ status: exports_external.enum(["resolved", "ignored"]).optional().describe("Resolution status"),
74458
+ agent_id: exports_external.string().optional().describe("Agent resolving findings"),
74459
+ reason: exports_external.string().optional().describe("Resolution reason"),
74460
+ limit: exports_external.number().optional().describe("Maximum findings returned"),
74461
+ apply: exports_external.boolean().optional().describe("Apply resolution; false or omitted is dry-run")
74462
+ }, async ({ task_id, fingerprints, source: source3, run_id, status, agent_id, reason, limit, apply }) => {
74463
+ try {
74464
+ const result = resolveMissingTaskFindings({
74465
+ task_id: resolveId(task_id),
74466
+ fingerprints: fingerprints || [],
74467
+ source: source3,
74468
+ run_id,
74469
+ status,
74470
+ agent_id,
74471
+ reason,
74472
+ limit,
74473
+ apply
74474
+ });
74475
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
73516
74476
  } catch (e) {
73517
74477
  return { content: [{ type: "text", text: formatError2(e) }], isError: true };
73518
74478
  }
@@ -73848,6 +74808,7 @@ var init_task_resources = __esm(() => {
73848
74808
  init_agents();
73849
74809
  init_task_commits();
73850
74810
  init_task_runs();
74811
+ init_findings();
73851
74812
  init_agent_run_dispatcher();
73852
74813
  init_verification_providers();
73853
74814
  init_release_notes();
@@ -78423,8 +79384,10 @@ function taskToSummary(task2, fields) {
78423
79384
  task_list_id: task2.task_list_id,
78424
79385
  agent_id: task2.agent_id,
78425
79386
  assigned_to: task2.assigned_to,
79387
+ working_dir: task2.working_dir,
78426
79388
  locked_by: task2.locked_by,
78427
79389
  tags: task2.tags,
79390
+ metadata: task2.metadata,
78428
79391
  version: task2.version,
78429
79392
  created_at: task2.created_at,
78430
79393
  updated_at: task2.updated_at,
@@ -78566,6 +79529,9 @@ Dashboard not found at: ${dashboardDir}`);
78566
79529
  if (path === "/api/tasks" && method === "POST") {
78567
79530
  return handleCreateTask(req, ctx, jsonWithCors, taskToSummary);
78568
79531
  }
79532
+ if (path === "/api/tasks/upsert" && method === "POST") {
79533
+ return handleUpsertTask(req, ctx, jsonWithCors, taskToSummary);
79534
+ }
78569
79535
  if (path === "/api/tasks/export" && method === "GET") {
78570
79536
  return handleTasksExport(req, url, ctx, jsonWithCors, taskToSummary);
78571
79537
  }
@@ -78761,7 +79727,7 @@ Dashboard not found at: ${dashboardDir}`);
78761
79727
  } catch {}
78762
79728
  }
78763
79729
  }
78764
- var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX = 120;
79730
+ var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX;
78765
79731
  var init_serve = __esm(() => {
78766
79732
  init_database();
78767
79733
  init_api_keys();
@@ -78786,6 +79752,7 @@ var init_serve = __esm(() => {
78786
79752
  "Permissions-Policy": "camera=, microphone=, geolocation="
78787
79753
  };
78788
79754
  rateLimitMap = new Map;
79755
+ RATE_LIMIT_MAX = Number.parseInt(process.env["TODOS_RATE_LIMIT_MAX"] || "120", 10);
78789
79756
  });
78790
79757
 
78791
79758
  // src/server/index.ts