@hasna/todos 0.11.57 → 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 +1622 -285
  5. package/dist/contracts.d.ts.map +1 -1
  6. package/dist/contracts.js +703 -21
  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 +1071 -103
  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 +1186 -115
  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 +703 -21
  32. package/dist/release-provenance.json +3 -3
  33. package/dist/server/index.js +1186 -115
  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 +574 -97
  39. package/dist/types/index.d.ts +11 -0
  40. package/dist/types/index.d.ts.map +1 -1
  41. package/package.json +2 -2
@@ -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",
@@ -4014,7 +4099,7 @@ var init_event_hooks = __esm(() => {
4014
4099
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
4015
4100
  });
4016
4101
 
4017
- // node_modules/.bun/@hasna+events@0.1.7/node_modules/@hasna/events/dist/index.js
4102
+ // node_modules/.bun/@hasna+events@0.1.9/node_modules/@hasna/events/dist/index.js
4018
4103
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
4019
4104
  import { existsSync as existsSync6 } from "fs";
4020
4105
  import { homedir } from "os";
@@ -4031,17 +4116,30 @@ function getPathValue(input, path) {
4031
4116
  return;
4032
4117
  }, input);
4033
4118
  }
4034
- function wildcardToRegExp(pattern) {
4035
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
4036
- return new RegExp(`^${escaped}$`);
4119
+ function wildcardToRegExp(pattern, options = {}) {
4120
+ let body = "";
4121
+ for (let index = 0;index < pattern.length; index += 1) {
4122
+ const char = pattern[index];
4123
+ if (char === "*") {
4124
+ if (pattern[index + 1] === "*") {
4125
+ body += ".*";
4126
+ index += 1;
4127
+ } else {
4128
+ body += options.segmentSafe ? "[^/]*" : ".*";
4129
+ }
4130
+ } else {
4131
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
4132
+ }
4133
+ }
4134
+ return new RegExp(`^${body}$`);
4037
4135
  }
4038
- function matchString(value, matcher) {
4136
+ function matchString(value, matcher, options = {}) {
4039
4137
  if (matcher === undefined)
4040
4138
  return true;
4041
4139
  if (value === undefined)
4042
4140
  return false;
4043
4141
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
4044
- return matchers.some((item) => wildcardToRegExp(item).test(value));
4142
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
4045
4143
  }
4046
4144
  function matchRecord(input, matcher) {
4047
4145
  if (!matcher)
@@ -4049,7 +4147,9 @@ function matchRecord(input, matcher) {
4049
4147
  return Object.entries(matcher).every(([path, expected]) => {
4050
4148
  const actual = getPathValue(input, path);
4051
4149
  if (typeof expected === "string" || Array.isArray(expected)) {
4052
- return matchString(actual === undefined ? undefined : String(actual), expected);
4150
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
4151
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
4152
+ });
4053
4153
  }
4054
4154
  return actual === expected;
4055
4155
  });
@@ -4403,7 +4503,7 @@ class EventsClient {
4403
4503
  }
4404
4504
  return deliveries;
4405
4505
  }
4406
- async testChannel(id, input = {}) {
4506
+ async matchChannel(id, input = {}) {
4407
4507
  const channel = await this.store.getChannel(id);
4408
4508
  if (!channel)
4409
4509
  throw new Error(`Channel not found: ${id}`);
@@ -4420,6 +4520,34 @@ class EventsClient {
4420
4520
  time: input.time,
4421
4521
  id: input.id
4422
4522
  });
4523
+ const matched = channelMatchesEvent(channel, event);
4524
+ return {
4525
+ channelId: channel.id,
4526
+ matched,
4527
+ event,
4528
+ filters: channel.filters,
4529
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
4530
+ };
4531
+ }
4532
+ async testChannel(id, input = {}, options = {}) {
4533
+ const channel = await this.store.getChannel(id);
4534
+ if (!channel)
4535
+ throw new Error(`Channel not found: ${id}`);
4536
+ const match = await this.matchChannel(id, input);
4537
+ const event = match.event;
4538
+ if (options.honorFilters && !match.matched) {
4539
+ const timestamp = new Date().toISOString();
4540
+ const result2 = createDeliveryResult(event, channel, [{
4541
+ attempt: 1,
4542
+ status: "skipped",
4543
+ startedAt: timestamp,
4544
+ completedAt: timestamp,
4545
+ error: match.reason
4546
+ }]);
4547
+ result2.metadata = { reason: "filter_mismatch" };
4548
+ await this.store.appendDelivery(result2);
4549
+ return result2;
4550
+ }
4423
4551
  const eventForChannel = await this.applyRedaction(event, channel);
4424
4552
  const result = await this.deliverWithRetry(eventForChannel, channel);
4425
4553
  await this.store.appendDelivery(result);
@@ -4523,6 +4651,90 @@ var init_dist = __esm(() => {
4523
4651
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
4524
4652
  });
4525
4653
 
4654
+ // src/db/task-lists.ts
4655
+ function rowToTaskList(row) {
4656
+ return {
4657
+ ...row,
4658
+ metadata: JSON.parse(row.metadata || "{}")
4659
+ };
4660
+ }
4661
+ function createTaskList(input, db) {
4662
+ const d = db || getDatabase();
4663
+ const id = uuid();
4664
+ const timestamp = now();
4665
+ const slug = input.slug || slugify(input.name);
4666
+ if (!input.project_id) {
4667
+ const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
4668
+ if (existing) {
4669
+ throw new Error(`Standalone task list with slug "${slug}" already exists`);
4670
+ }
4671
+ }
4672
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at)
4673
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp]);
4674
+ return getTaskList(id, d);
4675
+ }
4676
+ function getTaskList(id, db) {
4677
+ const d = db || getDatabase();
4678
+ const row = d.query("SELECT * FROM task_lists WHERE id = ?").get(id);
4679
+ return row ? rowToTaskList(row) : null;
4680
+ }
4681
+ function getTaskListBySlug(slug, projectId, db) {
4682
+ const d = db || getDatabase();
4683
+ let row;
4684
+ if (projectId) {
4685
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id = ?").get(slug, projectId);
4686
+ } else {
4687
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id IS NULL").get(slug);
4688
+ }
4689
+ return row ? rowToTaskList(row) : null;
4690
+ }
4691
+ function listTaskLists(projectId, db) {
4692
+ const d = db || getDatabase();
4693
+ if (projectId) {
4694
+ return d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(projectId).map(rowToTaskList);
4695
+ }
4696
+ return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
4697
+ }
4698
+ function updateTaskList(id, input, db) {
4699
+ const d = db || getDatabase();
4700
+ const existing = getTaskList(id, d);
4701
+ if (!existing)
4702
+ throw new TaskListNotFoundError(id);
4703
+ const sets = ["updated_at = ?"];
4704
+ const params = [now()];
4705
+ if (input.name !== undefined) {
4706
+ sets.push("name = ?");
4707
+ params.push(input.name);
4708
+ }
4709
+ if (input.description !== undefined) {
4710
+ sets.push("description = ?");
4711
+ params.push(input.description);
4712
+ }
4713
+ if (input.metadata !== undefined) {
4714
+ sets.push("metadata = ?");
4715
+ params.push(JSON.stringify(input.metadata));
4716
+ }
4717
+ params.push(id);
4718
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
4719
+ return getTaskList(id, d);
4720
+ }
4721
+ function deleteTaskList(id, db) {
4722
+ const d = db || getDatabase();
4723
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
4724
+ }
4725
+ function ensureTaskList(name, slug, projectId, db) {
4726
+ const d = db || getDatabase();
4727
+ const existing = getTaskListBySlug(slug, projectId, d);
4728
+ if (existing)
4729
+ return existing;
4730
+ return createTaskList({ name, slug, project_id: projectId }, d);
4731
+ }
4732
+ var init_task_lists = __esm(() => {
4733
+ init_types();
4734
+ init_database();
4735
+ init_projects();
4736
+ });
4737
+
4526
4738
  // src/lib/shared-events.ts
4527
4739
  function taskEventData(task, extra = {}) {
4528
4740
  return {
@@ -4552,6 +4764,69 @@ function taskEventData(task, extra = {}) {
4552
4764
  ...extra
4553
4765
  };
4554
4766
  }
4767
+ function taskEventMetadata(task) {
4768
+ const metadata = {
4769
+ package: "@hasna/todos",
4770
+ todos_event_schema_version: 1,
4771
+ task_id: task.id,
4772
+ task_short_id: task.short_id,
4773
+ project_id: task.project_id,
4774
+ task_list_id: task.task_list_id,
4775
+ working_dir: task.working_dir
4776
+ };
4777
+ try {
4778
+ const project = task.project_id ? getProject(task.project_id) : null;
4779
+ const projectPath = project ? readMachineLocalPath(project) ?? project.path : task.working_dir;
4780
+ if (project) {
4781
+ metadata.project_id = project.id;
4782
+ metadata.project_name = project.name;
4783
+ metadata.project_path = projectPath;
4784
+ metadata.project_canonical_path = project.path;
4785
+ metadata.project_default_task_list_slug = project.task_list_id;
4786
+ metadata.root_project_id = inferRootProjectId(project);
4787
+ } else if (projectPath) {
4788
+ metadata.project_path = projectPath;
4789
+ metadata.project_canonical_path = projectPath;
4790
+ }
4791
+ if (projectPath) {
4792
+ metadata.project_kind = classifyProjectKind(projectPath);
4793
+ metadata.project_is_worktree = isWorktreePath(projectPath);
4794
+ if (typeof task.metadata.route_enabled === "boolean") {
4795
+ metadata.route_enabled = task.metadata.route_enabled;
4796
+ }
4797
+ metadata.working_dir = task.working_dir ?? projectPath;
4798
+ }
4799
+ const taskList = task.task_list_id ? getTaskList(task.task_list_id) ?? (project ? getTaskListBySlug(task.task_list_id, project.id) : null) : project?.task_list_id ? getTaskListBySlug(project.task_list_id, project.id) : null;
4800
+ if (taskList) {
4801
+ metadata.task_list_id = taskList.id;
4802
+ metadata.task_list_slug = taskList.slug;
4803
+ metadata.task_list_name = taskList.name;
4804
+ metadata.task_list_project_id = taskList.project_id;
4805
+ metadata.task_list_is_project_default = Boolean(project?.task_list_id && taskList.slug === project.task_list_id);
4806
+ }
4807
+ } catch {}
4808
+ return metadata;
4809
+ }
4810
+ function classifyProjectKind(path) {
4811
+ return path.includes("/hasna/opensource/") ? "open-source" : "unknown";
4812
+ }
4813
+ function isWorktreePath(path) {
4814
+ return path.includes("/.codewith/worktrees/") || path.includes("/.worktrees/");
4815
+ }
4816
+ function inferRootProjectId(project) {
4817
+ return isWorktreePath(project.path) ? null : project.id;
4818
+ }
4819
+ function readMachineLocalPath(project) {
4820
+ const machineId = process.env["TODOS_MACHINE_ID"];
4821
+ if (!machineId)
4822
+ return null;
4823
+ try {
4824
+ const row = getDatabase().query("SELECT path FROM project_machine_paths WHERE project_id = ? AND machine_id = ?").get(project.id, machineId);
4825
+ return row?.path ?? null;
4826
+ } catch {
4827
+ return null;
4828
+ }
4829
+ }
4555
4830
  async function emitSharedTaskEvent(input) {
4556
4831
  const data = taskEventData(input.task, input.data);
4557
4832
  await new EventsClient().emit({
@@ -4562,12 +4837,7 @@ async function emitSharedTaskEvent(input) {
4562
4837
  message: input.message ?? `${input.type}: ${input.task.title}`,
4563
4838
  data,
4564
4839
  dedupeKey: input.dedupeKey ?? `${input.type}:${input.task.id}:${input.task.version}`,
4565
- metadata: {
4566
- package: "@hasna/todos",
4567
- task_id: input.task.id,
4568
- project_id: input.task.project_id,
4569
- task_list_id: input.task.task_list_id
4570
- }
4840
+ metadata: taskEventMetadata(input.task)
4571
4841
  }, { deliver: true, dedupe: true });
4572
4842
  }
4573
4843
  function emitSharedTaskEventQuiet(input) {
@@ -4578,6 +4848,9 @@ function emitSharedTaskEventQuiet(input) {
4578
4848
  var SOURCE = "todos";
4579
4849
  var init_shared_events = __esm(() => {
4580
4850
  init_dist();
4851
+ init_database();
4852
+ init_projects();
4853
+ init_task_lists();
4581
4854
  });
4582
4855
 
4583
4856
  // src/lib/secret-redaction.ts
@@ -5102,6 +5375,17 @@ function replaceTaskTags(taskId, tags, db) {
5102
5375
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
5103
5376
  insertTaskTags(taskId, tags, db);
5104
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
+ }
5105
5389
  function createTask(input, db) {
5106
5390
  const d = db || getDatabase();
5107
5391
  const timestamp = now();
@@ -5284,6 +5568,7 @@ function listTasks(filter = {}, db) {
5284
5568
  params.push(filter.task_type);
5285
5569
  }
5286
5570
  }
5571
+ addMetadataConditions(filter.metadata, conditions, params);
5287
5572
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
5288
5573
  if (filter.cursor) {
5289
5574
  try {
@@ -5308,6 +5593,54 @@ function listTasks(filter = {}, db) {
5308
5593
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
5309
5594
  return rows.map(rowToTask);
5310
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
+ }
5311
5644
  function countTasks(filter = {}, db) {
5312
5645
  const d = db || getDatabase();
5313
5646
  const conditions = [];
@@ -5371,6 +5704,7 @@ function countTasks(filter = {}, db) {
5371
5704
  conditions.push("task_list_id = ?");
5372
5705
  params.push(filter.task_list_id);
5373
5706
  }
5707
+ addMetadataConditions(filter.metadata, conditions, params);
5374
5708
  if (!filter.include_archived) {
5375
5709
  conditions.push("archived_at IS NULL");
5376
5710
  }
@@ -5421,6 +5755,10 @@ function updateTask(id, input, db) {
5421
5755
  sets.push("assigned_to = ?");
5422
5756
  params.push(input.assigned_to);
5423
5757
  }
5758
+ if (input.working_dir !== undefined) {
5759
+ sets.push("working_dir = ?");
5760
+ params.push(input.working_dir);
5761
+ }
5424
5762
  if (input.tags !== undefined) {
5425
5763
  sets.push("tags = ?");
5426
5764
  params.push(JSON.stringify(input.tags));
@@ -5509,6 +5847,8 @@ function updateTask(id, input, db) {
5509
5847
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
5510
5848
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
5511
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);
5512
5852
  if (input.approved_by !== undefined)
5513
5853
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
5514
5854
  const updatedTask = {
@@ -5544,6 +5884,10 @@ function updateTask(id, input, db) {
5544
5884
  if (input.approved_by !== undefined) {
5545
5885
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
5546
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 });
5547
5891
  return updatedTask;
5548
5892
  }
5549
5893
  function deleteTask(id, db) {
@@ -8561,6 +8905,72 @@ function rowToArtifact(row) {
8561
8905
  function getRunRow(runId, db) {
8562
8906
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
8563
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
+ }
8564
8974
  function resolveTaskRunId(idOrPrefix, db) {
8565
8975
  const d = db || getDatabase();
8566
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}%`);
@@ -8579,7 +8989,7 @@ function startTaskRun(input, db) {
8579
8989
  const d = db || getDatabase();
8580
8990
  if (!getTask(input.task_id, d))
8581
8991
  throw new TaskNotFoundError(input.task_id);
8582
- const id = uuid();
8992
+ const id = input.id ?? uuid();
8583
8993
  const timestamp = input.started_at || now();
8584
8994
  if (input.claim && input.agent_id) {
8585
8995
  startTask(input.task_id, input.agent_id, d);
@@ -8617,6 +9027,97 @@ function startTaskRun(input, db) {
8617
9027
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
8618
9028
  return run;
8619
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
+ }
8620
9121
  function addTaskRunEvent(input, db) {
8621
9122
  const d = db || getDatabase();
8622
9123
  const runId = resolveTaskRunId(input.run_id, d);
@@ -8796,6 +9297,66 @@ function finishTaskRun(input, db) {
8796
9297
  });
8797
9298
  return updated;
8798
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
+ }
8799
9360
  function listTaskRuns(taskId, db) {
8800
9361
  const d = db || getDatabase();
8801
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();
@@ -8813,6 +9374,7 @@ function getTaskRunLedger(runId, db) {
8813
9374
  const files = d.query("SELECT * FROM task_files WHERE task_id = ? ORDER BY updated_at DESC, path").all(run.task_id);
8814
9375
  return { run, events, commands, artifacts, files };
8815
9376
  }
9377
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
8816
9378
  var init_task_runs = __esm(() => {
8817
9379
  init_artifact_store();
8818
9380
  init_event_hooks();
@@ -9201,6 +9763,7 @@ var init_calendar = __esm(() => {
9201
9763
  var exports_tasks = {};
9202
9764
  __export(exports_tasks, {
9203
9765
  watchTask: () => watchTask,
9766
+ upsertTaskByFingerprint: () => upsertTaskByFingerprint,
9204
9767
  updateTaskBoard: () => updateTaskBoard,
9205
9768
  updateTask: () => updateTask,
9206
9769
  unwatchTask: () => unwatchTask,
@@ -9244,6 +9807,7 @@ __export(exports_tasks, {
9244
9807
  getTaskGraph: () => getTaskGraph,
9245
9808
  getTaskDependents: () => getTaskDependents,
9246
9809
  getTaskDependencies: () => getTaskDependencies,
9810
+ getTaskByFingerprint: () => getTaskByFingerprint,
9247
9811
  getTaskBoard: () => getTaskBoard,
9248
9812
  getTask: () => getTask,
9249
9813
  getStatus: () => getStatus,
@@ -10637,6 +11201,39 @@ async function handleCreateTask(req, ctx, json2, taskToSummary2) {
10637
11201
  return json2({ error: e instanceof Error ? e.message : "Failed to create task" }, 500);
10638
11202
  }
10639
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
+ }
10640
11237
  function handleTasksExport(_req, url, _ctx, _json, taskToSummary2) {
10641
11238
  const format = url.searchParams.get("format") || "json";
10642
11239
  const status = url.searchParams.get("status") || undefined;
@@ -31823,90 +32420,6 @@ var init_dispatch = __esm(() => {
31823
32420
  init_tmux();
31824
32421
  });
31825
32422
 
31826
- // src/db/task-lists.ts
31827
- function rowToTaskList(row) {
31828
- return {
31829
- ...row,
31830
- metadata: JSON.parse(row.metadata || "{}")
31831
- };
31832
- }
31833
- function createTaskList(input, db) {
31834
- const d = db || getDatabase();
31835
- const id = uuid();
31836
- const timestamp = now();
31837
- const slug = input.slug || slugify(input.name);
31838
- if (!input.project_id) {
31839
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
31840
- if (existing) {
31841
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
31842
- }
31843
- }
31844
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at)
31845
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp]);
31846
- return getTaskList(id, d);
31847
- }
31848
- function getTaskList(id, db) {
31849
- const d = db || getDatabase();
31850
- const row = d.query("SELECT * FROM task_lists WHERE id = ?").get(id);
31851
- return row ? rowToTaskList(row) : null;
31852
- }
31853
- function getTaskListBySlug(slug, projectId, db) {
31854
- const d = db || getDatabase();
31855
- let row;
31856
- if (projectId) {
31857
- row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id = ?").get(slug, projectId);
31858
- } else {
31859
- row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id IS NULL").get(slug);
31860
- }
31861
- return row ? rowToTaskList(row) : null;
31862
- }
31863
- function listTaskLists(projectId, db) {
31864
- const d = db || getDatabase();
31865
- if (projectId) {
31866
- return d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(projectId).map(rowToTaskList);
31867
- }
31868
- return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
31869
- }
31870
- function updateTaskList(id, input, db) {
31871
- const d = db || getDatabase();
31872
- const existing = getTaskList(id, d);
31873
- if (!existing)
31874
- throw new TaskListNotFoundError(id);
31875
- const sets = ["updated_at = ?"];
31876
- const params = [now()];
31877
- if (input.name !== undefined) {
31878
- sets.push("name = ?");
31879
- params.push(input.name);
31880
- }
31881
- if (input.description !== undefined) {
31882
- sets.push("description = ?");
31883
- params.push(input.description);
31884
- }
31885
- if (input.metadata !== undefined) {
31886
- sets.push("metadata = ?");
31887
- params.push(JSON.stringify(input.metadata));
31888
- }
31889
- params.push(id);
31890
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
31891
- return getTaskList(id, d);
31892
- }
31893
- function deleteTaskList(id, db) {
31894
- const d = db || getDatabase();
31895
- return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
31896
- }
31897
- function ensureTaskList(name, slug, projectId, db) {
31898
- const d = db || getDatabase();
31899
- const existing = getTaskListBySlug(slug, projectId, d);
31900
- if (existing)
31901
- return existing;
31902
- return createTaskList({ name, slug, project_id: projectId }, d);
31903
- }
31904
- var init_task_lists = __esm(() => {
31905
- init_types();
31906
- init_database();
31907
- init_projects();
31908
- });
31909
-
31910
32423
  // src/mcp/tools/dispatch.ts
31911
32424
  function registerDispatchTools(server, { shouldRegisterTool, resolveId, formatError: formatError2 }) {
31912
32425
  if (shouldRegisterTool("dispatch_tasks")) {
@@ -32465,6 +32978,7 @@ var init_token_utils = __esm(() => {
32465
32978
  "add_task_run_event",
32466
32979
  "add_task_run_file",
32467
32980
  "acknowledge_handoff",
32981
+ "begin_task_run_transaction",
32468
32982
  "build_local_report",
32469
32983
  "cancel_agent_run_dispatch",
32470
32984
  "finish_task_run",
@@ -32512,6 +33026,7 @@ var init_token_utils = __esm(() => {
32512
33026
  "list_local_snapshots",
32513
33027
  "list_retrospectives",
32514
33028
  "list_risks",
33029
+ "list_task_findings",
32515
33030
  "list_task_runs",
32516
33031
  "list_verification_providers",
32517
33032
  "merge_duplicate_task",
@@ -32520,6 +33035,7 @@ var init_token_utils = __esm(() => {
32520
33035
  "remove_review_routing_rule",
32521
33036
  "restore_local_backup",
32522
33037
  "retry_agent_run_dispatch",
33038
+ "resolve_missing_task_findings",
32523
33039
  "resolve_mentions",
32524
33040
  "run_next_agent_dispatch",
32525
33041
  "search_knowledge_records",
@@ -32562,9 +33078,17 @@ var init_token_utils = __esm(() => {
32562
33078
  "unlock_file",
32563
33079
  "unwatch_task",
32564
33080
  "update_comment",
33081
+ "upsert_task_finding",
32565
33082
  "update_risk",
32566
33083
  "watch_task"
32567
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
+ ],
32568
33092
  agents: [
32569
33093
  "auto_assign_task",
32570
33094
  "delete_agent",
@@ -32646,7 +33170,7 @@ var init_token_utils = __esm(() => {
32646
33170
  maintenance: ["extract_todos", "get_sla_breaches", "notify_upcoming_deadlines", "run_doctor", "score_task", "watch_source_todos"]
32647
33171
  };
32648
33172
  MCP_PROFILE_GROUPS = {
32649
- minimal: ["core"],
33173
+ minimal: ["core", "loops"],
32650
33174
  core: ["core"],
32651
33175
  standard: ["core", "tasks", "projects", "resources", "agents", "metadata"],
32652
33176
  agent: ["core", "tasks", "projects", "resources"],
@@ -32726,6 +33250,61 @@ function registerTaskCrudTools(server, ctx) {
32726
33250
  }
32727
33251
  });
32728
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
+ }
32729
33308
  if (shouldRegisterTool("list_tasks")) {
32730
33309
  server.tool("list_tasks", "List tasks with optional filters. Pass empty arrays for multi-value filters (e.g. status=[] shows all).", {
32731
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"),
@@ -32737,7 +33316,8 @@ function registerTaskCrudTools(server, ctx) {
32737
33316
  created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
32738
33317
  created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
32739
33318
  limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
32740
- 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")
32741
33321
  }, async (params) => {
32742
33322
  try {
32743
33323
  const resolved = { ...params };
@@ -36278,7 +36858,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
36278
36858
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
36279
36859
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
36280
36860
  }
36281
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
36861
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
36282
36862
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
36283
36863
  mergedDuplicates.push({
36284
36864
  id: duplicate.id,
@@ -36339,7 +36919,7 @@ function mergeDuplicateTask(input, db) {
36339
36919
  updateTask(primary.id, {
36340
36920
  version: primary.version,
36341
36921
  tags: mergedTags,
36342
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
36922
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
36343
36923
  description: mergeTaskDescription(primary, duplicate) ?? undefined
36344
36924
  }, d);
36345
36925
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -66516,6 +67096,356 @@ var init_task_meta_tools = __esm(() => {
66516
67096
  init_zod();
66517
67097
  });
66518
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
+
66519
67449
  // src/lib/agent-run-dispatcher.ts
66520
67450
  function dispatcherFromRun(run) {
66521
67451
  const value = run.metadata["agent_run_dispatcher"];
@@ -67176,7 +68106,7 @@ function parseArray2(value) {
67176
68106
  return [];
67177
68107
  }
67178
68108
  }
67179
- function parseObject2(value) {
68109
+ function parseObject3(value) {
67180
68110
  try {
67181
68111
  const parsed = JSON.parse(value || "{}");
67182
68112
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -67217,7 +68147,7 @@ function rowToKnowledgeRecord(row) {
67217
68147
  agent_id: row.agent_id,
67218
68148
  snapshot_id: row.snapshot_id,
67219
68149
  tags: parseArray2(row.tags),
67220
- metadata: redactValue(parseObject2(row.metadata)),
68150
+ metadata: redactValue(parseObject3(row.metadata)),
67221
68151
  created_at: row.created_at,
67222
68152
  updated_at: row.updated_at
67223
68153
  };
@@ -67436,7 +68366,7 @@ function parseArray3(value) {
67436
68366
  return [];
67437
68367
  }
67438
68368
  }
67439
- function parseObject3(value) {
68369
+ function parseObject4(value) {
67440
68370
  try {
67441
68371
  const parsed = JSON.parse(value || "{}");
67442
68372
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
@@ -67485,7 +68415,7 @@ function rowToRisk(row) {
67485
68415
  plan_id: row.plan_id,
67486
68416
  task_id: row.task_id,
67487
68417
  tags: parseArray3(row.tags),
67488
- metadata: redactValue(parseObject3(row.metadata)),
68418
+ metadata: redactValue(parseObject4(row.metadata)),
67489
68419
  created_at: row.created_at,
67490
68420
  updated_at: row.updated_at,
67491
68421
  closed_at: row.closed_at
@@ -73303,6 +74233,38 @@ ${lines.join(`
73303
74233
  }
73304
74234
  });
73305
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
+ }
73306
74268
  if (shouldRegisterTool("list_task_runs")) {
73307
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 }) => {
73308
74270
  try {
@@ -73400,15 +74362,117 @@ ${lines.join(`
73400
74362
  });
73401
74363
  }
73402
74364
  if (shouldRegisterTool("finish_task_run")) {
73403
- server.tool("finish_task_run", "Finish a local run ledger entry as completed, failed, or cancelled.", {
73404
- run_id: exports_external.string().describe("Run ID or prefix"),
73405
- 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"),
73406
74370
  summary: exports_external.string().optional().describe("Final summary"),
73407
- agent_id: exports_external.string().optional().describe("Agent finishing the run")
73408
- }, 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 }) => {
73409
74374
  try {
73410
- const run = finishTaskRun({ run_id, status, summary, agent_id });
73411
- 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) }] };
73412
74476
  } catch (e) {
73413
74477
  return { content: [{ type: "text", text: formatError2(e) }], isError: true };
73414
74478
  }
@@ -73744,6 +74808,7 @@ var init_task_resources = __esm(() => {
73744
74808
  init_agents();
73745
74809
  init_task_commits();
73746
74810
  init_task_runs();
74811
+ init_findings();
73747
74812
  init_agent_run_dispatcher();
73748
74813
  init_verification_providers();
73749
74814
  init_release_notes();
@@ -78319,8 +79384,10 @@ function taskToSummary(task2, fields) {
78319
79384
  task_list_id: task2.task_list_id,
78320
79385
  agent_id: task2.agent_id,
78321
79386
  assigned_to: task2.assigned_to,
79387
+ working_dir: task2.working_dir,
78322
79388
  locked_by: task2.locked_by,
78323
79389
  tags: task2.tags,
79390
+ metadata: task2.metadata,
78324
79391
  version: task2.version,
78325
79392
  created_at: task2.created_at,
78326
79393
  updated_at: task2.updated_at,
@@ -78462,6 +79529,9 @@ Dashboard not found at: ${dashboardDir}`);
78462
79529
  if (path === "/api/tasks" && method === "POST") {
78463
79530
  return handleCreateTask(req, ctx, jsonWithCors, taskToSummary);
78464
79531
  }
79532
+ if (path === "/api/tasks/upsert" && method === "POST") {
79533
+ return handleUpsertTask(req, ctx, jsonWithCors, taskToSummary);
79534
+ }
78465
79535
  if (path === "/api/tasks/export" && method === "GET") {
78466
79536
  return handleTasksExport(req, url, ctx, jsonWithCors, taskToSummary);
78467
79537
  }
@@ -78657,7 +79727,7 @@ Dashboard not found at: ${dashboardDir}`);
78657
79727
  } catch {}
78658
79728
  }
78659
79729
  }
78660
- 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;
78661
79731
  var init_serve = __esm(() => {
78662
79732
  init_database();
78663
79733
  init_api_keys();
@@ -78682,6 +79752,7 @@ var init_serve = __esm(() => {
78682
79752
  "Permissions-Policy": "camera=, microphone=, geolocation="
78683
79753
  };
78684
79754
  rateLimitMap = new Map;
79755
+ RATE_LIMIT_MAX = Number.parseInt(process.env["TODOS_RATE_LIMIT_MAX"] || "120", 10);
78685
79756
  });
78686
79757
 
78687
79758
  // src/server/index.ts