@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/mcp/index.js CHANGED
@@ -1140,6 +1140,49 @@ var init_migrations = __esm(() => {
1140
1140
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1141
1141
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1142
1142
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1143
+ `,
1144
+ `
1145
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1146
+ id TEXT PRIMARY KEY,
1147
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1148
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1149
+ key TEXT NOT NULL,
1150
+ loop_id TEXT,
1151
+ loop_run_id TEXT,
1152
+ metadata TEXT DEFAULT '{}',
1153
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1154
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1155
+ UNIQUE(task_id, key)
1156
+ );
1157
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1158
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1159
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1160
+
1161
+ CREATE TABLE IF NOT EXISTS task_findings (
1162
+ id TEXT PRIMARY KEY,
1163
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1164
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1165
+ fingerprint TEXT NOT NULL,
1166
+ title TEXT NOT NULL,
1167
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1168
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1169
+ source TEXT,
1170
+ summary TEXT,
1171
+ artifact_path TEXT,
1172
+ metadata TEXT DEFAULT '{}',
1173
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1174
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1175
+ resolved_at TEXT,
1176
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1177
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1178
+ UNIQUE(task_id, fingerprint)
1179
+ );
1180
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1181
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1182
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1183
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1184
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1185
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1143
1186
  `
1144
1187
  ];
1145
1188
  });
@@ -1577,6 +1620,47 @@ function ensureSchema(db) {
1577
1620
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1578
1621
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1579
1622
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1623
+ ensureTable("task_run_transactions", `
1624
+ CREATE TABLE task_run_transactions (
1625
+ id TEXT PRIMARY KEY,
1626
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1627
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1628
+ key TEXT NOT NULL,
1629
+ loop_id TEXT,
1630
+ loop_run_id TEXT,
1631
+ metadata TEXT DEFAULT '{}',
1632
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1633
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1634
+ UNIQUE(task_id, key)
1635
+ )`);
1636
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1637
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1638
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1639
+ ensureTable("task_findings", `
1640
+ CREATE TABLE task_findings (
1641
+ id TEXT PRIMARY KEY,
1642
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1643
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1644
+ fingerprint TEXT NOT NULL,
1645
+ title TEXT NOT NULL,
1646
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1647
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1648
+ source TEXT,
1649
+ summary TEXT,
1650
+ artifact_path TEXT,
1651
+ metadata TEXT DEFAULT '{}',
1652
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1653
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1654
+ resolved_at TEXT,
1655
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1656
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1657
+ UNIQUE(task_id, fingerprint)
1658
+ )`);
1659
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1660
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1661
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1662
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1663
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1580
1664
  ensureTable("inbox_items", `
1581
1665
  CREATE TABLE inbox_items (
1582
1666
  id TEXT PRIMARY KEY,
@@ -8633,6 +8717,7 @@ var init_event_hooks = __esm(() => {
8633
8717
  "task.blocked",
8634
8718
  "task.started",
8635
8719
  "task.completed",
8720
+ "task.updated",
8636
8721
  "task.due",
8637
8722
  "task.due_soon",
8638
8723
  "task.failed",
@@ -9929,6 +10014,17 @@ function replaceTaskTags(taskId, tags, db) {
9929
10014
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
9930
10015
  insertTaskTags(taskId, tags, db);
9931
10016
  }
10017
+ function addMetadataConditions(metadata, conditions, params) {
10018
+ if (!metadata)
10019
+ return;
10020
+ for (const [key, value] of Object.entries(metadata)) {
10021
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
10022
+ throw new Error(`Invalid metadata filter key: ${key}`);
10023
+ }
10024
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
10025
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
10026
+ }
10027
+ }
9932
10028
  function createTask(input, db) {
9933
10029
  const d = db || getDatabase();
9934
10030
  const timestamp = now();
@@ -10111,6 +10207,7 @@ function listTasks(filter = {}, db) {
10111
10207
  params.push(filter.task_type);
10112
10208
  }
10113
10209
  }
10210
+ addMetadataConditions(filter.metadata, conditions, params);
10114
10211
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
10115
10212
  if (filter.cursor) {
10116
10213
  try {
@@ -10135,6 +10232,54 @@ function listTasks(filter = {}, db) {
10135
10232
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
10136
10233
  return rows.map(rowToTask);
10137
10234
  }
10235
+ function getTaskByFingerprint(fingerprint, db) {
10236
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
10237
+ return tasks[0] ?? null;
10238
+ }
10239
+ function mergeTaskMetadata(current, next, fingerprint) {
10240
+ return {
10241
+ ...current,
10242
+ ...next ?? {},
10243
+ fingerprint
10244
+ };
10245
+ }
10246
+ function upsertTaskByFingerprint(input, db) {
10247
+ const d = db || getDatabase();
10248
+ const fingerprint = input.fingerprint.trim();
10249
+ if (!fingerprint)
10250
+ throw new Error("fingerprint is required");
10251
+ const existing = getTaskByFingerprint(fingerprint, d);
10252
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
10253
+ if (!existing) {
10254
+ const task2 = createTask({ ...input, metadata }, d);
10255
+ return { task: task2, created: true };
10256
+ }
10257
+ const task = updateTask(existing.id, {
10258
+ version: existing.version,
10259
+ title: input.title,
10260
+ description: input.description,
10261
+ status: input.status,
10262
+ priority: input.priority,
10263
+ project_id: input.project_id,
10264
+ assigned_to: input.assigned_to,
10265
+ working_dir: input.working_dir,
10266
+ plan_id: input.plan_id,
10267
+ task_list_id: input.task_list_id,
10268
+ tags: input.tags,
10269
+ metadata,
10270
+ due_at: input.due_at,
10271
+ estimated_minutes: input.estimated_minutes,
10272
+ sla_minutes: input.sla_minutes,
10273
+ confidence: input.confidence,
10274
+ retry_count: input.retry_count,
10275
+ max_retries: input.max_retries,
10276
+ retry_after: input.retry_after,
10277
+ requires_approval: input.requires_approval,
10278
+ recurrence_rule: input.recurrence_rule,
10279
+ task_type: input.task_type
10280
+ }, d);
10281
+ return { task, created: false };
10282
+ }
10138
10283
  function countTasks(filter = {}, db) {
10139
10284
  const d = db || getDatabase();
10140
10285
  const conditions = [];
@@ -10198,6 +10343,7 @@ function countTasks(filter = {}, db) {
10198
10343
  conditions.push("task_list_id = ?");
10199
10344
  params.push(filter.task_list_id);
10200
10345
  }
10346
+ addMetadataConditions(filter.metadata, conditions, params);
10201
10347
  if (!filter.include_archived) {
10202
10348
  conditions.push("archived_at IS NULL");
10203
10349
  }
@@ -10248,6 +10394,10 @@ function updateTask(id, input, db) {
10248
10394
  sets.push("assigned_to = ?");
10249
10395
  params.push(input.assigned_to);
10250
10396
  }
10397
+ if (input.working_dir !== undefined) {
10398
+ sets.push("working_dir = ?");
10399
+ params.push(input.working_dir);
10400
+ }
10251
10401
  if (input.tags !== undefined) {
10252
10402
  sets.push("tags = ?");
10253
10403
  params.push(JSON.stringify(input.tags));
@@ -10336,6 +10486,8 @@ function updateTask(id, input, db) {
10336
10486
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
10337
10487
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
10338
10488
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
10489
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
10490
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
10339
10491
  if (input.approved_by !== undefined)
10340
10492
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
10341
10493
  const updatedTask = {
@@ -10371,6 +10523,10 @@ function updateTask(id, input, db) {
10371
10523
  if (input.approved_by !== undefined) {
10372
10524
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
10373
10525
  }
10526
+ const updatePayload = taskEventData(updatedTask);
10527
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
10528
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
10529
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
10374
10530
  return updatedTask;
10375
10531
  }
10376
10532
  function deleteTask(id, db) {
@@ -13388,6 +13544,72 @@ function rowToArtifact(row) {
13388
13544
  function getRunRow(runId, db) {
13389
13545
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
13390
13546
  }
13547
+ function normalizeTransactionKey(input) {
13548
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
13549
+ if (!key)
13550
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
13551
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
13552
+ }
13553
+ function loopTransactionMetadata(record) {
13554
+ const value = record.metadata["loop_transaction"];
13555
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
13556
+ }
13557
+ function runKey(record) {
13558
+ const tx = loopTransactionMetadata(record);
13559
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
13560
+ return typeof key === "string" ? key : null;
13561
+ }
13562
+ function loopId(record) {
13563
+ const tx = loopTransactionMetadata(record);
13564
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
13565
+ return typeof value === "string" ? value : null;
13566
+ }
13567
+ function loopRunId(record) {
13568
+ const tx = loopTransactionMetadata(record);
13569
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
13570
+ return typeof value === "string" ? value : null;
13571
+ }
13572
+ function getTaskRunTransactionByKey(key, taskId, db) {
13573
+ if (taskId) {
13574
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
13575
+ }
13576
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
13577
+ if (rows.length > 1)
13578
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
13579
+ return rows[0] ?? null;
13580
+ }
13581
+ function summarizeTaskRun(run) {
13582
+ return {
13583
+ id: run.id,
13584
+ task_id: run.task_id,
13585
+ agent_id: run.agent_id,
13586
+ title: run.title,
13587
+ status: run.status,
13588
+ summary: run.summary,
13589
+ idempotency_key: runKey(run),
13590
+ loop_id: loopId(run),
13591
+ loop_run_id: loopRunId(run),
13592
+ metadata_keys: Object.keys(run.metadata).sort(),
13593
+ started_at: run.started_at,
13594
+ completed_at: run.completed_at,
13595
+ updated_at: run.updated_at
13596
+ };
13597
+ }
13598
+ function findTaskRunByTransactionKey(key, taskId, db) {
13599
+ const d = db || getDatabase();
13600
+ const normalized = normalizeTransactionKey({ key });
13601
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
13602
+ if (transaction?.run_id)
13603
+ return getTaskRun(transaction.run_id, d);
13604
+ return null;
13605
+ }
13606
+ function loopRunCommands(run, key) {
13607
+ return [
13608
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
13609
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
13610
+ `todos runs begin <task-id> --key ${key} --apply --json`
13611
+ ];
13612
+ }
13391
13613
  function resolveTaskRunId(idOrPrefix, db) {
13392
13614
  const d = db || getDatabase();
13393
13615
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -13406,7 +13628,7 @@ function startTaskRun(input, db) {
13406
13628
  const d = db || getDatabase();
13407
13629
  if (!getTask(input.task_id, d))
13408
13630
  throw new TaskNotFoundError(input.task_id);
13409
- const id = uuid();
13631
+ const id = input.id ?? uuid();
13410
13632
  const timestamp = input.started_at || now();
13411
13633
  if (input.claim && input.agent_id) {
13412
13634
  startTask(input.task_id, input.agent_id, d);
@@ -13444,6 +13666,97 @@ function startTaskRun(input, db) {
13444
13666
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
13445
13667
  return run;
13446
13668
  }
13669
+ function beginTaskRunTransaction(input, db) {
13670
+ const d = db || getDatabase();
13671
+ if (!getTask(input.task_id, d))
13672
+ throw new TaskNotFoundError(input.task_id);
13673
+ const timestamp = input.started_at || now();
13674
+ const key = normalizeTransactionKey(input);
13675
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
13676
+ const dryRun = !input.apply;
13677
+ if (existing) {
13678
+ return {
13679
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13680
+ local_only: true,
13681
+ dry_run: dryRun,
13682
+ processed_at: timestamp,
13683
+ action: "matched",
13684
+ key,
13685
+ run: summarizeTaskRun(existing),
13686
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
13687
+ commands: loopRunCommands(existing, key)
13688
+ };
13689
+ }
13690
+ if (dryRun) {
13691
+ return {
13692
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13693
+ local_only: true,
13694
+ dry_run: true,
13695
+ processed_at: timestamp,
13696
+ action: "preview",
13697
+ key,
13698
+ run: null,
13699
+ warnings: [],
13700
+ commands: loopRunCommands(null, key)
13701
+ };
13702
+ }
13703
+ const metadata = redactValue({
13704
+ ...input.metadata || {},
13705
+ loop_transaction: {
13706
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13707
+ idempotency_key: key,
13708
+ loop_id: input.loop_id ?? null,
13709
+ loop_run_id: input.loop_run_id ?? null,
13710
+ first_seen_at: timestamp
13711
+ },
13712
+ idempotency_key: key
13713
+ });
13714
+ const created = d.transaction(() => {
13715
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
13716
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
13717
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
13718
+ uuid(),
13719
+ input.task_id,
13720
+ key,
13721
+ input.loop_id ?? null,
13722
+ input.loop_run_id ?? null,
13723
+ JSON.stringify(metadata),
13724
+ timestamp,
13725
+ timestamp
13726
+ ]);
13727
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
13728
+ if (!transaction)
13729
+ throw new Error(`Could not create run transaction for key: ${key}`);
13730
+ if (transaction.run_id) {
13731
+ const existingRun = getTaskRun(transaction.run_id, d);
13732
+ if (existingRun)
13733
+ return { run: existingRun, action: "matched" };
13734
+ }
13735
+ const run = startTaskRun({
13736
+ id: uuid(),
13737
+ task_id: input.task_id,
13738
+ agent_id: input.agent_id,
13739
+ title: input.title,
13740
+ summary: input.summary,
13741
+ metadata,
13742
+ claim: input.claim,
13743
+ started_at: timestamp
13744
+ }, d);
13745
+ 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]);
13746
+ return { run, action: "created" };
13747
+ })();
13748
+ return {
13749
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13750
+ local_only: true,
13751
+ dry_run: false,
13752
+ processed_at: timestamp,
13753
+ action: created.action,
13754
+ key,
13755
+ run: summarizeTaskRun(created.run),
13756
+ warnings: [],
13757
+ commands: loopRunCommands(created.run, key)
13758
+ };
13759
+ }
13447
13760
  function addTaskRunEvent(input, db) {
13448
13761
  const d = db || getDatabase();
13449
13762
  const runId = resolveTaskRunId(input.run_id, d);
@@ -13623,6 +13936,66 @@ function finishTaskRun(input, db) {
13623
13936
  });
13624
13937
  return updated;
13625
13938
  }
13939
+ function finishTaskRunTransaction(input, db) {
13940
+ const d = db || getDatabase();
13941
+ const timestamp = input.completed_at || now();
13942
+ const status = input.status || "completed";
13943
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
13944
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
13945
+ if (!run) {
13946
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
13947
+ }
13948
+ if (input.task_id && run.task_id !== input.task_id) {
13949
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
13950
+ }
13951
+ const resolvedKey = key || runKey(run) || run.id;
13952
+ const dryRun = input.apply === false;
13953
+ if (run.status !== "running") {
13954
+ const conflict = run.status !== status;
13955
+ return {
13956
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13957
+ local_only: true,
13958
+ dry_run: dryRun,
13959
+ processed_at: timestamp,
13960
+ action: conflict ? "conflict" : "matched",
13961
+ key: resolvedKey,
13962
+ run: summarizeTaskRun(run),
13963
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
13964
+ commands: loopRunCommands(run, resolvedKey)
13965
+ };
13966
+ }
13967
+ if (dryRun) {
13968
+ return {
13969
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13970
+ local_only: true,
13971
+ dry_run: true,
13972
+ processed_at: timestamp,
13973
+ action: "preview",
13974
+ key: resolvedKey,
13975
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
13976
+ warnings: [],
13977
+ commands: loopRunCommands(run, resolvedKey)
13978
+ };
13979
+ }
13980
+ const finished = finishTaskRun({
13981
+ run_id: run.id,
13982
+ status,
13983
+ summary: input.summary,
13984
+ agent_id: input.agent_id,
13985
+ completed_at: timestamp
13986
+ }, d);
13987
+ return {
13988
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
13989
+ local_only: true,
13990
+ dry_run: false,
13991
+ processed_at: timestamp,
13992
+ action: "finished",
13993
+ key: resolvedKey,
13994
+ run: summarizeTaskRun(finished),
13995
+ warnings: [],
13996
+ commands: loopRunCommands(finished, resolvedKey)
13997
+ };
13998
+ }
13626
13999
  function listTaskRuns(taskId, db) {
13627
14000
  const d = db || getDatabase();
13628
14001
  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();
@@ -13640,6 +14013,7 @@ function getTaskRunLedger(runId, db) {
13640
14013
  const files = d.query("SELECT * FROM task_files WHERE task_id = ? ORDER BY updated_at DESC, path").all(run.task_id);
13641
14014
  return { run, events, commands, artifacts, files };
13642
14015
  }
14016
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
13643
14017
  var init_task_runs = __esm(() => {
13644
14018
  init_artifact_store();
13645
14019
  init_event_hooks();
@@ -14028,6 +14402,7 @@ var init_calendar = __esm(() => {
14028
14402
  var exports_tasks = {};
14029
14403
  __export(exports_tasks, {
14030
14404
  watchTask: () => watchTask,
14405
+ upsertTaskByFingerprint: () => upsertTaskByFingerprint,
14031
14406
  updateTaskBoard: () => updateTaskBoard,
14032
14407
  updateTask: () => updateTask,
14033
14408
  unwatchTask: () => unwatchTask,
@@ -14071,6 +14446,7 @@ __export(exports_tasks, {
14071
14446
  getTaskGraph: () => getTaskGraph,
14072
14447
  getTaskDependents: () => getTaskDependents,
14073
14448
  getTaskDependencies: () => getTaskDependencies,
14449
+ getTaskByFingerprint: () => getTaskByFingerprint,
14074
14450
  getTaskBoard: () => getTaskBoard,
14075
14451
  getTask: () => getTask,
14076
14452
  getStatus: () => getStatus,
@@ -14917,6 +15293,7 @@ var init_token_utils = __esm(() => {
14917
15293
  "add_task_run_event",
14918
15294
  "add_task_run_file",
14919
15295
  "acknowledge_handoff",
15296
+ "begin_task_run_transaction",
14920
15297
  "build_local_report",
14921
15298
  "cancel_agent_run_dispatch",
14922
15299
  "finish_task_run",
@@ -14964,6 +15341,7 @@ var init_token_utils = __esm(() => {
14964
15341
  "list_local_snapshots",
14965
15342
  "list_retrospectives",
14966
15343
  "list_risks",
15344
+ "list_task_findings",
14967
15345
  "list_task_runs",
14968
15346
  "list_verification_providers",
14969
15347
  "merge_duplicate_task",
@@ -14972,6 +15350,7 @@ var init_token_utils = __esm(() => {
14972
15350
  "remove_review_routing_rule",
14973
15351
  "restore_local_backup",
14974
15352
  "retry_agent_run_dispatch",
15353
+ "resolve_missing_task_findings",
14975
15354
  "resolve_mentions",
14976
15355
  "run_next_agent_dispatch",
14977
15356
  "search_knowledge_records",
@@ -15014,9 +15393,17 @@ var init_token_utils = __esm(() => {
15014
15393
  "unlock_file",
15015
15394
  "unwatch_task",
15016
15395
  "update_comment",
15396
+ "upsert_task_finding",
15017
15397
  "update_risk",
15018
15398
  "watch_task"
15019
15399
  ],
15400
+ loops: [
15401
+ "begin_task_run_transaction",
15402
+ "finish_task_run",
15403
+ "list_task_findings",
15404
+ "resolve_missing_task_findings",
15405
+ "upsert_task_finding"
15406
+ ],
15020
15407
  agents: [
15021
15408
  "auto_assign_task",
15022
15409
  "delete_agent",
@@ -15098,7 +15485,7 @@ var init_token_utils = __esm(() => {
15098
15485
  maintenance: ["extract_todos", "get_sla_breaches", "notify_upcoming_deadlines", "run_doctor", "score_task", "watch_source_todos"]
15099
15486
  };
15100
15487
  MCP_PROFILE_GROUPS = {
15101
- minimal: ["core"],
15488
+ minimal: ["core", "loops"],
15102
15489
  core: ["core"],
15103
15490
  standard: ["core", "tasks", "projects", "resources", "agents", "metadata"],
15104
15491
  agent: ["core", "tasks", "projects", "resources"],
@@ -15178,6 +15565,61 @@ function registerTaskCrudTools(server, ctx) {
15178
15565
  }
15179
15566
  });
15180
15567
  }
15568
+ if (shouldRegisterTool("upsert_task")) {
15569
+ server.tool("upsert_task", "Create or update a task by stable metadata fingerprint. Metadata is shallow-merged on updates.", {
15570
+ fingerprint: exports_external.string().describe("Stable dedupe fingerprint stored as metadata.fingerprint"),
15571
+ title: exports_external.string().describe("Task title"),
15572
+ description: exports_external.string().optional().describe("Task description"),
15573
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
15574
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
15575
+ project_id: exports_external.string().optional().describe("Project ID"),
15576
+ task_list_id: exports_external.string().optional().describe("Task list ID"),
15577
+ assigned_to: exports_external.string().optional().describe("Agent ID or name to assign to"),
15578
+ tags: exports_external.array(exports_external.string()).optional().describe("Tags for the task"),
15579
+ working_dir: exports_external.string().optional().describe("Working directory associated with the task"),
15580
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Metadata object to shallow-merge"),
15581
+ expectation_id: exports_external.string().optional(),
15582
+ expectation_fingerprint: exports_external.string().optional(),
15583
+ evidence_paths: exports_external.array(exports_external.string()).optional(),
15584
+ origin_loop_id: exports_external.string().optional(),
15585
+ origin_run_id: exports_external.string().optional(),
15586
+ expected: exports_external.unknown().optional(),
15587
+ observed: exports_external.unknown().optional(),
15588
+ acceptance: exports_external.unknown().optional()
15589
+ }, async (params) => {
15590
+ try {
15591
+ 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;
15592
+ const mergedMetadata = { ...metadata ?? {} };
15593
+ if (expectation_id !== undefined)
15594
+ mergedMetadata["expectation_id"] = expectation_id;
15595
+ if (expectation_fingerprint !== undefined)
15596
+ mergedMetadata["expectation_fingerprint"] = expectation_fingerprint;
15597
+ if (evidence_paths !== undefined)
15598
+ mergedMetadata["evidence_paths"] = evidence_paths;
15599
+ if (origin_loop_id !== undefined)
15600
+ mergedMetadata["origin_loop_id"] = origin_loop_id;
15601
+ if (origin_run_id !== undefined)
15602
+ mergedMetadata["origin_run_id"] = origin_run_id;
15603
+ if (expected !== undefined)
15604
+ mergedMetadata["expected"] = expected;
15605
+ if (observed !== undefined)
15606
+ mergedMetadata["observed"] = observed;
15607
+ if (acceptance !== undefined)
15608
+ mergedMetadata["acceptance"] = acceptance;
15609
+ const resolved = { ...rest, metadata: mergedMetadata };
15610
+ if (assigned_to)
15611
+ resolved.assigned_to = resolveAssignee(assigned_to);
15612
+ if (project_id)
15613
+ resolved.project_id = resolveId(project_id, "projects");
15614
+ if (task_list_id)
15615
+ resolved.task_list_id = resolveId(task_list_id, "task_lists");
15616
+ const result = upsertTaskByFingerprint(resolved);
15617
+ return { content: [{ type: "text", text: compactJson({ created: result.created, task: JSON.parse(mutationTaskResponse(result.task)) }) }] };
15618
+ } catch (e) {
15619
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
15620
+ }
15621
+ });
15622
+ }
15181
15623
  if (shouldRegisterTool("list_tasks")) {
15182
15624
  server.tool("list_tasks", "List tasks with optional filters. Pass empty arrays for multi-value filters (e.g. status=[] shows all).", {
15183
15625
  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"),
@@ -15189,7 +15631,8 @@ function registerTaskCrudTools(server, ctx) {
15189
15631
  created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
15190
15632
  created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
15191
15633
  limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
15192
- offset: exports_external.number().optional().describe("Pagination offset")
15634
+ offset: exports_external.number().optional().describe("Pagination offset"),
15635
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Exact top-level metadata filters")
15193
15636
  }, async (params) => {
15194
15637
  try {
15195
15638
  const resolved = { ...params };
@@ -18730,7 +19173,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
18730
19173
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
18731
19174
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
18732
19175
  }
18733
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
19176
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
18734
19177
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
18735
19178
  mergedDuplicates.push({
18736
19179
  id: duplicate.id,
@@ -18791,7 +19234,7 @@ function mergeDuplicateTask(input, db) {
18791
19234
  updateTask(primary.id, {
18792
19235
  version: primary.version,
18793
19236
  tags: mergedTags,
18794
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
19237
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
18795
19238
  description: mergeTaskDescription(primary, duplicate) ?? undefined
18796
19239
  }, d);
18797
19240
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -25588,6 +26031,356 @@ var init_task_meta_tools = __esm(() => {
25588
26031
  init_zod();
25589
26032
  });
25590
26033
 
26034
+ // src/db/findings.ts
26035
+ function parseObject2(value) {
26036
+ if (!value)
26037
+ return {};
26038
+ try {
26039
+ const parsed = JSON.parse(value);
26040
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
26041
+ } catch {
26042
+ return {};
26043
+ }
26044
+ }
26045
+ function normalizeKey(value) {
26046
+ return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
26047
+ }
26048
+ function normalizeFingerprint(value) {
26049
+ const normalized = normalizeKey(value);
26050
+ if (!normalized)
26051
+ throw new Error("finding fingerprint is required");
26052
+ return normalized.slice(0, 240);
26053
+ }
26054
+ function normalizeSeverity(value) {
26055
+ const normalized = normalizeKey(value || "medium");
26056
+ if (SEVERITIES.has(normalized))
26057
+ return normalized;
26058
+ if (/^(p0|blocker|urgent|highest)$/.test(normalized))
26059
+ return "critical";
26060
+ if (/^(p1|major)$/.test(normalized))
26061
+ return "high";
26062
+ if (/^(p3|minor|info)$/.test(normalized))
26063
+ return "low";
26064
+ return "medium";
26065
+ }
26066
+ function normalizeStatus(value) {
26067
+ const normalized = normalizeKey(value || "open");
26068
+ if (STATUSES.has(normalized))
26069
+ return normalized;
26070
+ if (normalized === "closed" || normalized === "fixed")
26071
+ return "resolved";
26072
+ return "open";
26073
+ }
26074
+ function normalizeResolutionStatus(value) {
26075
+ const status = normalizeStatus(value || "resolved");
26076
+ if (status === "open")
26077
+ throw new Error("resolve-missing status must be resolved or ignored");
26078
+ return status;
26079
+ }
26080
+ function redactOptional(value, max = 2000) {
26081
+ if (!value)
26082
+ return null;
26083
+ const redacted = redactEvidenceText(value).trim();
26084
+ if (!redacted)
26085
+ return null;
26086
+ return redacted.length > max ? `${redacted.slice(0, max - 3)}...` : redacted;
26087
+ }
26088
+ function rowToFinding(row) {
26089
+ return {
26090
+ schema_version: TASK_FINDING_SCHEMA_VERSION,
26091
+ ...row,
26092
+ severity: normalizeSeverity(row.severity),
26093
+ status: normalizeStatus(row.status),
26094
+ metadata: parseObject2(row.metadata)
26095
+ };
26096
+ }
26097
+ function compactFinding(finding2) {
26098
+ return {
26099
+ schema_version: TASK_FINDING_SCHEMA_VERSION,
26100
+ id: finding2.id,
26101
+ task_id: finding2.task_id,
26102
+ run_id: finding2.run_id,
26103
+ fingerprint: finding2.fingerprint,
26104
+ title: finding2.title,
26105
+ severity: finding2.severity,
26106
+ status: finding2.status,
26107
+ source: finding2.source,
26108
+ summary: finding2.summary,
26109
+ artifact_path: finding2.artifact_path,
26110
+ first_seen_at: finding2.first_seen_at,
26111
+ last_seen_at: finding2.last_seen_at,
26112
+ resolved_at: finding2.resolved_at,
26113
+ metadata_keys: Object.keys(finding2.metadata).sort()
26114
+ };
26115
+ }
26116
+ function previewFinding(existing, next, timestamp3) {
26117
+ return {
26118
+ ...existing,
26119
+ run_id: next.run_id,
26120
+ title: next.title,
26121
+ severity: next.severity,
26122
+ status: next.status,
26123
+ source: next.source,
26124
+ summary: next.summary,
26125
+ artifact_path: next.artifact_path,
26126
+ metadata: next.metadata,
26127
+ last_seen_at: timestamp3,
26128
+ resolved_at: next.status === "open" ? null : existing.resolved_at || timestamp3,
26129
+ updated_at: timestamp3
26130
+ };
26131
+ }
26132
+ function upsertAction(existing, next) {
26133
+ if (sameFinding(existing, next))
26134
+ return "matched";
26135
+ return existing.status !== "open" && next.status === "open" ? "reopened" : "updated";
26136
+ }
26137
+ function resolveRunForTask(runId, taskId, db) {
26138
+ if (!runId)
26139
+ return null;
26140
+ const resolved = resolveTaskRunId(runId, db);
26141
+ const run = getTaskRun(resolved, db);
26142
+ if (!run)
26143
+ throw new Error(`Run not found: ${runId}`);
26144
+ if (run.task_id !== taskId)
26145
+ throw new Error(`Run ${resolved} belongs to task ${run.task_id}, not ${taskId}`);
26146
+ return resolved;
26147
+ }
26148
+ function getFindingByFingerprint(taskId, fingerprint2, db) {
26149
+ const row = db.query("SELECT * FROM task_findings WHERE task_id = ? AND fingerprint = ?").get(taskId, fingerprint2);
26150
+ return row ? rowToFinding(row) : null;
26151
+ }
26152
+ function assertTask(taskId, db) {
26153
+ if (!getTask(taskId, db))
26154
+ throw new TaskNotFoundError(taskId);
26155
+ }
26156
+ function nextFinding(input, db) {
26157
+ const fingerprint2 = normalizeFingerprint(input.fingerprint);
26158
+ const title = redactOptional(input.title, 300);
26159
+ if (!title)
26160
+ throw new Error("finding title is required");
26161
+ return {
26162
+ fingerprint: fingerprint2,
26163
+ run_id: resolveRunForTask(input.run_id, input.task_id, db),
26164
+ title,
26165
+ severity: normalizeSeverity(input.severity),
26166
+ status: normalizeStatus(input.status),
26167
+ source: redactOptional(input.source, 120),
26168
+ summary: redactOptional(input.summary, 2000),
26169
+ artifact_path: redactOptional(input.artifact_path, 1000),
26170
+ metadata: redactValue(input.metadata || {})
26171
+ };
26172
+ }
26173
+ function sameFinding(left, right) {
26174
+ 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);
26175
+ }
26176
+ function upsertTaskFinding(input, db) {
26177
+ const d = db || getDatabase();
26178
+ assertTask(input.task_id, d);
26179
+ const timestamp3 = input.observed_at || now();
26180
+ const warnings = [];
26181
+ const next = nextFinding(input, d);
26182
+ const existing = getFindingByFingerprint(input.task_id, next.fingerprint, d);
26183
+ const dryRun = !input.apply;
26184
+ if (dryRun) {
26185
+ const action2 = existing ? upsertAction(existing, next) : "preview";
26186
+ return {
26187
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
26188
+ local_only: true,
26189
+ dry_run: true,
26190
+ processed_at: timestamp3,
26191
+ action: action2,
26192
+ fingerprint: next.fingerprint,
26193
+ finding: existing ? compactFinding(previewFinding(existing, next, timestamp3)) : null,
26194
+ warnings
26195
+ };
26196
+ }
26197
+ if (!existing) {
26198
+ const id = uuid();
26199
+ d.run(`INSERT INTO task_findings (
26200
+ id, task_id, run_id, fingerprint, title, severity, status, source, summary, artifact_path,
26201
+ metadata, first_seen_at, last_seen_at, resolved_at, created_at, updated_at
26202
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
26203
+ id,
26204
+ input.task_id,
26205
+ next.run_id,
26206
+ next.fingerprint,
26207
+ next.title,
26208
+ next.severity,
26209
+ next.status,
26210
+ next.source,
26211
+ next.summary,
26212
+ next.artifact_path,
26213
+ JSON.stringify(next.metadata),
26214
+ timestamp3,
26215
+ timestamp3,
26216
+ next.status === "open" ? null : timestamp3,
26217
+ timestamp3,
26218
+ timestamp3
26219
+ ]);
26220
+ return {
26221
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
26222
+ local_only: true,
26223
+ dry_run: false,
26224
+ processed_at: timestamp3,
26225
+ action: "created",
26226
+ fingerprint: next.fingerprint,
26227
+ finding: compactFinding(getFindingByFingerprint(input.task_id, next.fingerprint, d)),
26228
+ warnings
26229
+ };
26230
+ }
26231
+ if (sameFinding(existing, next)) {
26232
+ return {
26233
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
26234
+ local_only: true,
26235
+ dry_run: false,
26236
+ processed_at: timestamp3,
26237
+ action: "matched",
26238
+ fingerprint: next.fingerprint,
26239
+ finding: compactFinding(existing),
26240
+ warnings
26241
+ };
26242
+ }
26243
+ const action = upsertAction(existing, next);
26244
+ d.run(`UPDATE task_findings SET
26245
+ run_id = ?, title = ?, severity = ?, status = ?, source = ?, summary = ?, artifact_path = ?,
26246
+ metadata = ?, last_seen_at = ?, resolved_at = ?, updated_at = ?
26247
+ WHERE id = ?`, [
26248
+ next.run_id,
26249
+ next.title,
26250
+ next.severity,
26251
+ next.status,
26252
+ next.source,
26253
+ next.summary,
26254
+ next.artifact_path,
26255
+ JSON.stringify(next.metadata),
26256
+ timestamp3,
26257
+ next.status === "open" ? null : existing.resolved_at || timestamp3,
26258
+ timestamp3,
26259
+ existing.id
26260
+ ]);
26261
+ return {
26262
+ schema_version: TASK_FINDING_UPSERT_SCHEMA_VERSION,
26263
+ local_only: true,
26264
+ dry_run: false,
26265
+ processed_at: timestamp3,
26266
+ action,
26267
+ fingerprint: next.fingerprint,
26268
+ finding: compactFinding(getFindingByFingerprint(input.task_id, next.fingerprint, d)),
26269
+ warnings
26270
+ };
26271
+ }
26272
+ function listTaskFindings(filter = {}, db) {
26273
+ const d = db || getDatabase();
26274
+ const conditions = ["1=1"];
26275
+ const params = [];
26276
+ if (filter.task_id) {
26277
+ conditions.push("task_id = ?");
26278
+ params.push(filter.task_id);
26279
+ }
26280
+ if (filter.run_id) {
26281
+ conditions.push("run_id = ?");
26282
+ params.push(resolveTaskRunId(filter.run_id, d));
26283
+ }
26284
+ if (filter.status) {
26285
+ conditions.push("status = ?");
26286
+ params.push(normalizeStatus(filter.status));
26287
+ }
26288
+ if (filter.source) {
26289
+ conditions.push("source = ?");
26290
+ params.push(redactOptional(filter.source, 120));
26291
+ }
26292
+ const limit = Math.min(Math.max(Math.floor(filter.limit ?? 50), 1), 500);
26293
+ const rows = d.query(`SELECT * FROM task_findings WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC, created_at DESC LIMIT ?`).all(...[...params, limit]);
26294
+ return rows.map(rowToFinding);
26295
+ }
26296
+ function listCompactTaskFindings(filter = {}, db) {
26297
+ return listTaskFindings(filter, db).map(compactFinding);
26298
+ }
26299
+ function resolveMissingTaskFindings(input, db) {
26300
+ const d = db || getDatabase();
26301
+ assertTask(input.task_id, d);
26302
+ const timestamp3 = input.resolved_at || now();
26303
+ const status = normalizeResolutionStatus(input.status);
26304
+ const runId = resolveRunForTask(input.run_id, input.task_id, d);
26305
+ const present = new Set(input.fingerprints.map(normalizeFingerprint));
26306
+ const warnings = [];
26307
+ const conditions = ["task_id = ?", "status = 'open'"];
26308
+ const params = [input.task_id];
26309
+ if (input.source) {
26310
+ conditions.push("source = ?");
26311
+ params.push(redactOptional(input.source, 120));
26312
+ }
26313
+ 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));
26314
+ const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 200);
26315
+ const display = candidates.slice(0, limit);
26316
+ const omittedCount = Math.max(0, candidates.length - display.length);
26317
+ if (!input.apply) {
26318
+ return {
26319
+ schema_version: TASK_FINDING_RESOLVE_MISSING_SCHEMA_VERSION,
26320
+ local_only: true,
26321
+ dry_run: true,
26322
+ processed_at: timestamp3,
26323
+ action: candidates.length > 0 ? "preview" : "noop",
26324
+ task_id: input.task_id,
26325
+ source: input.source ? redactOptional(input.source, 120) : null,
26326
+ run_id: runId,
26327
+ present_fingerprint_count: present.size,
26328
+ candidate_count: candidates.length,
26329
+ changed_count: 0,
26330
+ omitted_count: omittedCount,
26331
+ findings: display.map(compactFinding),
26332
+ warnings
26333
+ };
26334
+ }
26335
+ const metadataPatch = redactValue({
26336
+ resolved_by: {
26337
+ agent_id: input.agent_id ?? null,
26338
+ run_id: runId,
26339
+ reason: input.reason ? redactEvidenceText(input.reason) : "missing from latest loop finding set"
26340
+ }
26341
+ });
26342
+ const tx = d.transaction(() => {
26343
+ for (const finding2 of candidates) {
26344
+ d.run("UPDATE task_findings SET status = ?, resolved_at = ?, metadata = ?, updated_at = ? WHERE id = ? AND status = 'open'", [
26345
+ status,
26346
+ timestamp3,
26347
+ JSON.stringify({ ...finding2.metadata, ...metadataPatch }),
26348
+ timestamp3,
26349
+ finding2.id
26350
+ ]);
26351
+ }
26352
+ });
26353
+ tx();
26354
+ const updated = candidates.map((finding2) => d.query("SELECT * FROM task_findings WHERE id = ?").get(finding2.id)).filter((row) => Boolean(row)).map(rowToFinding);
26355
+ const visibleUpdated = updated.slice(0, limit);
26356
+ return {
26357
+ schema_version: TASK_FINDING_RESOLVE_MISSING_SCHEMA_VERSION,
26358
+ local_only: true,
26359
+ dry_run: false,
26360
+ processed_at: timestamp3,
26361
+ action: updated.length > 0 ? status : "noop",
26362
+ task_id: input.task_id,
26363
+ source: input.source ? redactOptional(input.source, 120) : null,
26364
+ run_id: runId,
26365
+ present_fingerprint_count: present.size,
26366
+ candidate_count: candidates.length,
26367
+ changed_count: updated.length,
26368
+ omitted_count: omittedCount,
26369
+ findings: visibleUpdated.map(compactFinding),
26370
+ warnings
26371
+ };
26372
+ }
26373
+ 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;
26374
+ var init_findings = __esm(() => {
26375
+ init_redaction();
26376
+ init_types();
26377
+ init_database();
26378
+ init_tasks();
26379
+ init_task_runs();
26380
+ SEVERITIES = new Set(["low", "medium", "high", "critical"]);
26381
+ STATUSES = new Set(["open", "resolved", "ignored"]);
26382
+ });
26383
+
25591
26384
  // src/lib/agent-run-dispatcher.ts
25592
26385
  function dispatcherFromRun(run) {
25593
26386
  const value = run.metadata["agent_run_dispatcher"];
@@ -26248,7 +27041,7 @@ function parseArray2(value) {
26248
27041
  return [];
26249
27042
  }
26250
27043
  }
26251
- function parseObject2(value) {
27044
+ function parseObject3(value) {
26252
27045
  try {
26253
27046
  const parsed = JSON.parse(value || "{}");
26254
27047
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -26289,7 +27082,7 @@ function rowToKnowledgeRecord(row) {
26289
27082
  agent_id: row.agent_id,
26290
27083
  snapshot_id: row.snapshot_id,
26291
27084
  tags: parseArray2(row.tags),
26292
- metadata: redactValue(parseObject2(row.metadata)),
27085
+ metadata: redactValue(parseObject3(row.metadata)),
26293
27086
  created_at: row.created_at,
26294
27087
  updated_at: row.updated_at
26295
27088
  };
@@ -26508,7 +27301,7 @@ function parseArray3(value) {
26508
27301
  return [];
26509
27302
  }
26510
27303
  }
26511
- function parseObject3(value) {
27304
+ function parseObject4(value) {
26512
27305
  try {
26513
27306
  const parsed = JSON.parse(value || "{}");
26514
27307
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -26557,7 +27350,7 @@ function rowToRisk(row) {
26557
27350
  plan_id: row.plan_id,
26558
27351
  task_id: row.task_id,
26559
27352
  tags: parseArray3(row.tags),
26560
- metadata: redactValue(parseObject3(row.metadata)),
27353
+ metadata: redactValue(parseObject4(row.metadata)),
26561
27354
  created_at: row.created_at,
26562
27355
  updated_at: row.updated_at,
26563
27356
  closed_at: row.closed_at
@@ -32399,6 +33192,38 @@ ${lines.join(`
32399
33192
  }
32400
33193
  });
32401
33194
  }
33195
+ if (shouldRegisterTool("begin_task_run_transaction")) {
33196
+ server.tool("begin_task_run_transaction", "Preview or apply an idempotent local loop run transaction keyed by a stable loop/run id.", {
33197
+ task_id: exports_external.string().describe("Task ID"),
33198
+ key: exports_external.string().optional().describe("Stable idempotency key"),
33199
+ loop_id: exports_external.string().optional().describe("Loop identifier; used as key fallback"),
33200
+ loop_run_id: exports_external.string().optional().describe("Loop run identifier; used as key fallback"),
33201
+ agent_id: exports_external.string().optional().describe("Agent starting the run"),
33202
+ title: exports_external.string().optional().describe("Run title"),
33203
+ summary: exports_external.string().optional().describe("Run summary"),
33204
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Additional local metadata"),
33205
+ claim: exports_external.boolean().optional().describe("Claim/start the task before recording the run"),
33206
+ apply: exports_external.boolean().optional().describe("Apply the transaction; false or omitted is dry-run")
33207
+ }, async ({ task_id, key, loop_id, loop_run_id, agent_id, title, summary, metadata, claim, apply }) => {
33208
+ try {
33209
+ const result = beginTaskRunTransaction({
33210
+ task_id: resolveId(task_id),
33211
+ key,
33212
+ loop_id,
33213
+ loop_run_id,
33214
+ agent_id,
33215
+ title,
33216
+ summary,
33217
+ metadata,
33218
+ claim,
33219
+ apply
33220
+ });
33221
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
33222
+ } catch (e) {
33223
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
33224
+ }
33225
+ });
33226
+ }
32402
33227
  if (shouldRegisterTool("list_task_runs")) {
32403
33228
  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 }) => {
32404
33229
  try {
@@ -32496,15 +33321,117 @@ ${lines.join(`
32496
33321
  });
32497
33322
  }
32498
33323
  if (shouldRegisterTool("finish_task_run")) {
32499
- server.tool("finish_task_run", "Finish a local run ledger entry as completed, failed, or cancelled.", {
32500
- run_id: exports_external.string().describe("Run ID or prefix"),
32501
- status: exports_external.enum(["completed", "failed", "cancelled"]).describe("Final run status"),
33324
+ server.tool("finish_task_run", "Finish a local run ledger entry as completed, failed, or cancelled. Supports idempotent lookup by key.", {
33325
+ run_id: exports_external.string().optional().describe("Run ID or prefix"),
33326
+ key: exports_external.string().optional().describe("Idempotency key when run_id is omitted"),
33327
+ task_id: exports_external.string().optional().describe("Task scope for key lookup"),
33328
+ status: exports_external.enum(["completed", "failed", "cancelled"]).optional().describe("Final run status"),
32502
33329
  summary: exports_external.string().optional().describe("Final summary"),
32503
- agent_id: exports_external.string().optional().describe("Agent finishing the run")
32504
- }, async ({ run_id, status, summary, agent_id }) => {
33330
+ agent_id: exports_external.string().optional().describe("Agent finishing the run"),
33331
+ apply: exports_external.boolean().optional().describe("Apply the transaction; false previews without mutating")
33332
+ }, async ({ run_id, key, task_id, status, summary, agent_id, apply }) => {
33333
+ try {
33334
+ if (run_id && !key && apply === undefined) {
33335
+ const result2 = finishTaskRunTransaction({ run_id, status: status || "completed", summary, agent_id, apply: true });
33336
+ const run = result2.run ? getTaskRunLedger(result2.run.id).run : null;
33337
+ return { content: [{ type: "text", text: JSON.stringify(run, null, 2) }] };
33338
+ }
33339
+ const result = finishTaskRunTransaction({
33340
+ run_id,
33341
+ key,
33342
+ task_id: task_id ? resolveId(task_id) : undefined,
33343
+ status: status || "completed",
33344
+ summary,
33345
+ agent_id,
33346
+ apply: apply !== false
33347
+ });
33348
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
33349
+ } catch (e) {
33350
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
33351
+ }
33352
+ });
33353
+ }
33354
+ if (shouldRegisterTool("upsert_task_finding")) {
33355
+ server.tool("upsert_task_finding", "Preview or apply an idempotent local finding upsert scoped by task and fingerprint.", {
33356
+ task_id: exports_external.string().describe("Task ID"),
33357
+ fingerprint: exports_external.string().describe("Stable finding fingerprint"),
33358
+ title: exports_external.string().describe("Finding title"),
33359
+ severity: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("Finding severity"),
33360
+ status: exports_external.enum(["open", "resolved", "ignored"]).optional().describe("Finding status"),
33361
+ source: exports_external.string().optional().describe("Loop/tool source name"),
33362
+ summary: exports_external.string().optional().describe("Bounded finding summary"),
33363
+ artifact_path: exports_external.string().optional().describe("Local artifact path/reference; content is not read"),
33364
+ run_id: exports_external.string().optional().describe("Run ledger ID or prefix"),
33365
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Additional local metadata"),
33366
+ apply: exports_external.boolean().optional().describe("Apply the upsert; false or omitted is dry-run")
33367
+ }, async ({ task_id, fingerprint: fingerprint3, title, severity, status, source: source3, summary, artifact_path, run_id, metadata, apply }) => {
32505
33368
  try {
32506
- const run = finishTaskRun({ run_id, status, summary, agent_id });
32507
- return { content: [{ type: "text", text: JSON.stringify(run, null, 2) }] };
33369
+ const result = upsertTaskFinding({
33370
+ task_id: resolveId(task_id),
33371
+ fingerprint: fingerprint3,
33372
+ title,
33373
+ severity,
33374
+ status,
33375
+ source: source3,
33376
+ summary,
33377
+ artifact_path,
33378
+ run_id,
33379
+ metadata,
33380
+ apply
33381
+ });
33382
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
33383
+ } catch (e) {
33384
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
33385
+ }
33386
+ });
33387
+ }
33388
+ if (shouldRegisterTool("list_task_findings")) {
33389
+ server.tool("list_task_findings", "List compact local findings with bounded output.", {
33390
+ task_id: exports_external.string().optional().describe("Filter by task"),
33391
+ run_id: exports_external.string().optional().describe("Filter by run"),
33392
+ status: exports_external.enum(["open", "resolved", "ignored"]).optional().describe("Filter by status"),
33393
+ source: exports_external.string().optional().describe("Filter by source"),
33394
+ limit: exports_external.number().optional().describe("Maximum findings to return")
33395
+ }, async ({ task_id, run_id, status, source: source3, limit }) => {
33396
+ try {
33397
+ const findings = listCompactTaskFindings({
33398
+ task_id: task_id ? resolveId(task_id) : undefined,
33399
+ run_id,
33400
+ status,
33401
+ source: source3,
33402
+ limit
33403
+ });
33404
+ return { content: [{ type: "text", text: JSON.stringify(findings) }] };
33405
+ } catch (e) {
33406
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
33407
+ }
33408
+ });
33409
+ }
33410
+ if (shouldRegisterTool("resolve_missing_task_findings")) {
33411
+ server.tool("resolve_missing_task_findings", "Resolve open findings absent from the latest loop finding set.", {
33412
+ task_id: exports_external.string().describe("Task ID"),
33413
+ fingerprints: exports_external.array(exports_external.string()).optional().describe("Fingerprints still present in the latest run"),
33414
+ source: exports_external.string().optional().describe("Only resolve findings from this source"),
33415
+ run_id: exports_external.string().optional().describe("Run ledger ID or prefix for audit metadata"),
33416
+ status: exports_external.enum(["resolved", "ignored"]).optional().describe("Resolution status"),
33417
+ agent_id: exports_external.string().optional().describe("Agent resolving findings"),
33418
+ reason: exports_external.string().optional().describe("Resolution reason"),
33419
+ limit: exports_external.number().optional().describe("Maximum findings returned"),
33420
+ apply: exports_external.boolean().optional().describe("Apply resolution; false or omitted is dry-run")
33421
+ }, async ({ task_id, fingerprints, source: source3, run_id, status, agent_id, reason, limit, apply }) => {
33422
+ try {
33423
+ const result = resolveMissingTaskFindings({
33424
+ task_id: resolveId(task_id),
33425
+ fingerprints: fingerprints || [],
33426
+ source: source3,
33427
+ run_id,
33428
+ status,
33429
+ agent_id,
33430
+ reason,
33431
+ limit,
33432
+ apply
33433
+ });
33434
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
32508
33435
  } catch (e) {
32509
33436
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
32510
33437
  }
@@ -32840,6 +33767,7 @@ var init_task_resources = __esm(() => {
32840
33767
  init_agents();
32841
33768
  init_task_commits();
32842
33769
  init_task_runs();
33770
+ init_findings();
32843
33771
  init_agent_run_dispatcher();
32844
33772
  init_verification_providers();
32845
33773
  init_release_notes();
@@ -37384,6 +38312,39 @@ async function handleCreateTask(req, ctx, json2, taskToSummary2) {
37384
38312
  return json2({ error: e instanceof Error ? e.message : "Failed to create task" }, 500);
37385
38313
  }
37386
38314
  }
38315
+ async function handleUpsertTask(req, ctx, json2, taskToSummary2) {
38316
+ try {
38317
+ const body = await req.json();
38318
+ if (typeof body["fingerprint"] !== "string" || body["fingerprint"].trim() === "") {
38319
+ return json2({ error: "Missing 'fingerprint'" }, 400);
38320
+ }
38321
+ if (typeof body["title"] !== "string" || body["title"].trim() === "") {
38322
+ return json2({ error: "Missing 'title'" }, 400);
38323
+ }
38324
+ const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? { ...body["metadata"] } : {};
38325
+ for (const key of ["expectation_id", "expectation_fingerprint", "evidence_paths", "origin_loop_id", "origin_run_id", "expected", "observed", "acceptance"]) {
38326
+ if (body[key] !== undefined)
38327
+ metadata[key] = body[key];
38328
+ }
38329
+ const result = upsertTaskByFingerprint({
38330
+ fingerprint: body["fingerprint"],
38331
+ title: body["title"],
38332
+ description: typeof body["description"] === "string" ? body["description"] : undefined,
38333
+ status: body["status"],
38334
+ priority: body["priority"],
38335
+ project_id: typeof body["project_id"] === "string" ? body["project_id"] : undefined,
38336
+ task_list_id: typeof body["task_list_id"] === "string" ? body["task_list_id"] : undefined,
38337
+ assigned_to: typeof body["assigned_to"] === "string" ? body["assigned_to"] : undefined,
38338
+ working_dir: typeof body["working_dir"] === "string" ? body["working_dir"] : undefined,
38339
+ tags: Array.isArray(body["tags"]) ? body["tags"].filter((tag) => typeof tag === "string") : undefined,
38340
+ metadata
38341
+ });
38342
+ 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 });
38343
+ return json2({ created: result.created, task: taskToSummary2(result.task) }, result.created ? 201 : 200);
38344
+ } catch (e) {
38345
+ return json2({ error: e instanceof Error ? e.message : "Failed to upsert task" }, 500);
38346
+ }
38347
+ }
37387
38348
  function handleTasksExport(_req, url, _ctx, _json, taskToSummary2) {
37388
38349
  const format = url.searchParams.get("format") || "json";
37389
38350
  const status = url.searchParams.get("status") || undefined;
@@ -38064,8 +39025,10 @@ function taskToSummary(task2, fields) {
38064
39025
  task_list_id: task2.task_list_id,
38065
39026
  agent_id: task2.agent_id,
38066
39027
  assigned_to: task2.assigned_to,
39028
+ working_dir: task2.working_dir,
38067
39029
  locked_by: task2.locked_by,
38068
39030
  tags: task2.tags,
39031
+ metadata: task2.metadata,
38069
39032
  version: task2.version,
38070
39033
  created_at: task2.created_at,
38071
39034
  updated_at: task2.updated_at,
@@ -38207,6 +39170,9 @@ Dashboard not found at: ${dashboardDir}`);
38207
39170
  if (path === "/api/tasks" && method === "POST") {
38208
39171
  return handleCreateTask(req, ctx, jsonWithCors, taskToSummary);
38209
39172
  }
39173
+ if (path === "/api/tasks/upsert" && method === "POST") {
39174
+ return handleUpsertTask(req, ctx, jsonWithCors, taskToSummary);
39175
+ }
38210
39176
  if (path === "/api/tasks/export" && method === "GET") {
38211
39177
  return handleTasksExport(req, url, ctx, jsonWithCors, taskToSummary);
38212
39178
  }
@@ -38402,7 +39368,7 @@ Dashboard not found at: ${dashboardDir}`);
38402
39368
  } catch {}
38403
39369
  }
38404
39370
  }
38405
- var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX = 120;
39371
+ var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX;
38406
39372
  var init_serve = __esm(() => {
38407
39373
  init_database();
38408
39374
  init_api_keys();
@@ -38427,6 +39393,7 @@ var init_serve = __esm(() => {
38427
39393
  "Permissions-Policy": "camera=, microphone=, geolocation="
38428
39394
  };
38429
39395
  rateLimitMap = new Map;
39396
+ RATE_LIMIT_MAX = Number.parseInt(process.env["TODOS_RATE_LIMIT_MAX"] || "120", 10);
38430
39397
  });
38431
39398
 
38432
39399
  // src/mcp/index.ts