@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
package/dist/storage.js CHANGED
@@ -1269,6 +1269,49 @@ var init_migrations = __esm(() => {
1269
1269
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1270
1270
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1271
1271
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1272
+ `,
1273
+ `
1274
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1275
+ id TEXT PRIMARY KEY,
1276
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1277
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1278
+ key TEXT NOT NULL,
1279
+ loop_id TEXT,
1280
+ loop_run_id TEXT,
1281
+ metadata TEXT DEFAULT '{}',
1282
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1283
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1284
+ UNIQUE(task_id, key)
1285
+ );
1286
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1287
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1288
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1289
+
1290
+ CREATE TABLE IF NOT EXISTS task_findings (
1291
+ id TEXT PRIMARY KEY,
1292
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1293
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1294
+ fingerprint TEXT NOT NULL,
1295
+ title TEXT NOT NULL,
1296
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1297
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1298
+ source TEXT,
1299
+ summary TEXT,
1300
+ artifact_path TEXT,
1301
+ metadata TEXT DEFAULT '{}',
1302
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1303
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1304
+ resolved_at TEXT,
1305
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1306
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1307
+ UNIQUE(task_id, fingerprint)
1308
+ );
1309
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1310
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1311
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1312
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1313
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1314
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1272
1315
  `
1273
1316
  ];
1274
1317
  });
@@ -1706,6 +1749,47 @@ function ensureSchema(db) {
1706
1749
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1707
1750
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1708
1751
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1752
+ ensureTable("task_run_transactions", `
1753
+ CREATE TABLE task_run_transactions (
1754
+ id TEXT PRIMARY KEY,
1755
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1756
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1757
+ key TEXT NOT NULL,
1758
+ loop_id TEXT,
1759
+ loop_run_id TEXT,
1760
+ metadata TEXT DEFAULT '{}',
1761
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1762
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1763
+ UNIQUE(task_id, key)
1764
+ )`);
1765
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1766
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1767
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1768
+ ensureTable("task_findings", `
1769
+ CREATE TABLE task_findings (
1770
+ id TEXT PRIMARY KEY,
1771
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1772
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1773
+ fingerprint TEXT NOT NULL,
1774
+ title TEXT NOT NULL,
1775
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1776
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1777
+ source TEXT,
1778
+ summary TEXT,
1779
+ artifact_path TEXT,
1780
+ metadata TEXT DEFAULT '{}',
1781
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1782
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1783
+ resolved_at TEXT,
1784
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1785
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1786
+ UNIQUE(task_id, fingerprint)
1787
+ )`);
1788
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1789
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1790
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1791
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1792
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1709
1793
  ensureTable("inbox_items", `
1710
1794
  CREATE TABLE inbox_items (
1711
1795
  id TEXT PRIMARY KEY,
@@ -4180,6 +4264,7 @@ var LOCAL_EVENT_TYPES = [
4180
4264
  "task.blocked",
4181
4265
  "task.started",
4182
4266
  "task.completed",
4267
+ "task.updated",
4183
4268
  "task.due",
4184
4269
  "task.due_soon",
4185
4270
  "task.failed",
@@ -4418,7 +4503,7 @@ async function testLocalEventHook(name, input) {
4418
4503
  return emitLocalEventHooks({ ...input, hooks: [hook] });
4419
4504
  }
4420
4505
 
4421
- // node_modules/.bun/@hasna+events@0.1.7/node_modules/@hasna/events/dist/index.js
4506
+ // node_modules/.bun/@hasna+events@0.1.9/node_modules/@hasna/events/dist/index.js
4422
4507
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
4423
4508
  import { existsSync as existsSync5 } from "fs";
4424
4509
  import { homedir } from "os";
@@ -4435,17 +4520,30 @@ function getPathValue(input, path) {
4435
4520
  return;
4436
4521
  }, input);
4437
4522
  }
4438
- function wildcardToRegExp(pattern) {
4439
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
4440
- return new RegExp(`^${escaped}$`);
4523
+ function wildcardToRegExp(pattern, options = {}) {
4524
+ let body = "";
4525
+ for (let index = 0;index < pattern.length; index += 1) {
4526
+ const char = pattern[index];
4527
+ if (char === "*") {
4528
+ if (pattern[index + 1] === "*") {
4529
+ body += ".*";
4530
+ index += 1;
4531
+ } else {
4532
+ body += options.segmentSafe ? "[^/]*" : ".*";
4533
+ }
4534
+ } else {
4535
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
4536
+ }
4537
+ }
4538
+ return new RegExp(`^${body}$`);
4441
4539
  }
4442
- function matchString(value, matcher) {
4540
+ function matchString(value, matcher, options = {}) {
4443
4541
  if (matcher === undefined)
4444
4542
  return true;
4445
4543
  if (value === undefined)
4446
4544
  return false;
4447
4545
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
4448
- return matchers.some((item) => wildcardToRegExp(item).test(value));
4546
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
4449
4547
  }
4450
4548
  function matchRecord(input, matcher) {
4451
4549
  if (!matcher)
@@ -4453,7 +4551,9 @@ function matchRecord(input, matcher) {
4453
4551
  return Object.entries(matcher).every(([path, expected]) => {
4454
4552
  const actual = getPathValue(input, path);
4455
4553
  if (typeof expected === "string" || Array.isArray(expected)) {
4456
- return matchString(actual === undefined ? undefined : String(actual), expected);
4554
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
4555
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
4556
+ });
4457
4557
  }
4458
4558
  return actual === expected;
4459
4559
  });
@@ -4473,7 +4573,6 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
4473
4573
  function getEventsDataDir(override) {
4474
4574
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join4(homedir(), ".hasna", "events");
4475
4575
  }
4476
-
4477
4576
  class JsonEventsStore {
4478
4577
  dataDir;
4479
4578
  channelsPath;
@@ -4810,7 +4909,7 @@ class EventsClient {
4810
4909
  }
4811
4910
  return deliveries;
4812
4911
  }
4813
- async testChannel(id, input = {}) {
4912
+ async matchChannel(id, input = {}) {
4814
4913
  const channel = await this.store.getChannel(id);
4815
4914
  if (!channel)
4816
4915
  throw new Error(`Channel not found: ${id}`);
@@ -4827,6 +4926,34 @@ class EventsClient {
4827
4926
  time: input.time,
4828
4927
  id: input.id
4829
4928
  });
4929
+ const matched = channelMatchesEvent(channel, event);
4930
+ return {
4931
+ channelId: channel.id,
4932
+ matched,
4933
+ event,
4934
+ filters: channel.filters,
4935
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
4936
+ };
4937
+ }
4938
+ async testChannel(id, input = {}, options = {}) {
4939
+ const channel = await this.store.getChannel(id);
4940
+ if (!channel)
4941
+ throw new Error(`Channel not found: ${id}`);
4942
+ const match = await this.matchChannel(id, input);
4943
+ const event = match.event;
4944
+ if (options.honorFilters && !match.matched) {
4945
+ const timestamp = new Date().toISOString();
4946
+ const result2 = createDeliveryResult(event, channel, [{
4947
+ attempt: 1,
4948
+ status: "skipped",
4949
+ startedAt: timestamp,
4950
+ completedAt: timestamp,
4951
+ error: match.reason
4952
+ }]);
4953
+ result2.metadata = { reason: "filter_mismatch" };
4954
+ await this.store.appendDelivery(result2);
4955
+ return result2;
4956
+ }
4830
4957
  const eventForChannel = await this.applyRedaction(event, channel);
4831
4958
  const result = await this.deliverWithRetry(eventForChannel, channel);
4832
4959
  await this.store.appendDelivery(result);
@@ -4926,6 +5053,90 @@ function normalizeRetryPolicy(policy) {
4926
5053
  };
4927
5054
  }
4928
5055
 
5056
+ // src/lib/shared-events.ts
5057
+ init_database();
5058
+
5059
+ // src/db/task-lists.ts
5060
+ init_types();
5061
+ init_database();
5062
+ function rowToTaskList(row) {
5063
+ return {
5064
+ ...row,
5065
+ metadata: JSON.parse(row.metadata || "{}")
5066
+ };
5067
+ }
5068
+ function createTaskList(input, db) {
5069
+ const d = db || getDatabase();
5070
+ const id = uuid();
5071
+ const timestamp = now();
5072
+ const slug = input.slug || slugify(input.name);
5073
+ if (!input.project_id) {
5074
+ const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
5075
+ if (existing) {
5076
+ throw new Error(`Standalone task list with slug "${slug}" already exists`);
5077
+ }
5078
+ }
5079
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at)
5080
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp]);
5081
+ return getTaskList(id, d);
5082
+ }
5083
+ function getTaskList(id, db) {
5084
+ const d = db || getDatabase();
5085
+ const row = d.query("SELECT * FROM task_lists WHERE id = ?").get(id);
5086
+ return row ? rowToTaskList(row) : null;
5087
+ }
5088
+ function getTaskListBySlug(slug, projectId, db) {
5089
+ const d = db || getDatabase();
5090
+ let row;
5091
+ if (projectId) {
5092
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id = ?").get(slug, projectId);
5093
+ } else {
5094
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id IS NULL").get(slug);
5095
+ }
5096
+ return row ? rowToTaskList(row) : null;
5097
+ }
5098
+ function listTaskLists(projectId, db) {
5099
+ const d = db || getDatabase();
5100
+ if (projectId) {
5101
+ return d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(projectId).map(rowToTaskList);
5102
+ }
5103
+ return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
5104
+ }
5105
+ function updateTaskList(id, input, db) {
5106
+ const d = db || getDatabase();
5107
+ const existing = getTaskList(id, d);
5108
+ if (!existing)
5109
+ throw new TaskListNotFoundError(id);
5110
+ const sets = ["updated_at = ?"];
5111
+ const params = [now()];
5112
+ if (input.name !== undefined) {
5113
+ sets.push("name = ?");
5114
+ params.push(input.name);
5115
+ }
5116
+ if (input.description !== undefined) {
5117
+ sets.push("description = ?");
5118
+ params.push(input.description);
5119
+ }
5120
+ if (input.metadata !== undefined) {
5121
+ sets.push("metadata = ?");
5122
+ params.push(JSON.stringify(input.metadata));
5123
+ }
5124
+ params.push(id);
5125
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
5126
+ return getTaskList(id, d);
5127
+ }
5128
+ function deleteTaskList(id, db) {
5129
+ const d = db || getDatabase();
5130
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
5131
+ }
5132
+ function ensureTaskList(name, slug, projectId, db) {
5133
+ const d = db || getDatabase();
5134
+ const existing = getTaskListBySlug(slug, projectId, d);
5135
+ if (existing)
5136
+ return existing;
5137
+ return createTaskList({ name, slug, project_id: projectId }, d);
5138
+ }
5139
+
4929
5140
  // src/lib/shared-events.ts
4930
5141
  var SOURCE = "todos";
4931
5142
  function taskEventData(task, extra = {}) {
@@ -4956,6 +5167,69 @@ function taskEventData(task, extra = {}) {
4956
5167
  ...extra
4957
5168
  };
4958
5169
  }
5170
+ function taskEventMetadata(task) {
5171
+ const metadata = {
5172
+ package: "@hasna/todos",
5173
+ todos_event_schema_version: 1,
5174
+ task_id: task.id,
5175
+ task_short_id: task.short_id,
5176
+ project_id: task.project_id,
5177
+ task_list_id: task.task_list_id,
5178
+ working_dir: task.working_dir
5179
+ };
5180
+ try {
5181
+ const project = task.project_id ? getProject(task.project_id) : null;
5182
+ const projectPath = project ? readMachineLocalPath(project) ?? project.path : task.working_dir;
5183
+ if (project) {
5184
+ metadata.project_id = project.id;
5185
+ metadata.project_name = project.name;
5186
+ metadata.project_path = projectPath;
5187
+ metadata.project_canonical_path = project.path;
5188
+ metadata.project_default_task_list_slug = project.task_list_id;
5189
+ metadata.root_project_id = inferRootProjectId(project);
5190
+ } else if (projectPath) {
5191
+ metadata.project_path = projectPath;
5192
+ metadata.project_canonical_path = projectPath;
5193
+ }
5194
+ if (projectPath) {
5195
+ metadata.project_kind = classifyProjectKind(projectPath);
5196
+ metadata.project_is_worktree = isWorktreePath(projectPath);
5197
+ if (typeof task.metadata.route_enabled === "boolean") {
5198
+ metadata.route_enabled = task.metadata.route_enabled;
5199
+ }
5200
+ metadata.working_dir = task.working_dir ?? projectPath;
5201
+ }
5202
+ 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;
5203
+ if (taskList) {
5204
+ metadata.task_list_id = taskList.id;
5205
+ metadata.task_list_slug = taskList.slug;
5206
+ metadata.task_list_name = taskList.name;
5207
+ metadata.task_list_project_id = taskList.project_id;
5208
+ metadata.task_list_is_project_default = Boolean(project?.task_list_id && taskList.slug === project.task_list_id);
5209
+ }
5210
+ } catch {}
5211
+ return metadata;
5212
+ }
5213
+ function classifyProjectKind(path) {
5214
+ return path.includes("/hasna/opensource/") ? "open-source" : "unknown";
5215
+ }
5216
+ function isWorktreePath(path) {
5217
+ return path.includes("/.codewith/worktrees/") || path.includes("/.worktrees/");
5218
+ }
5219
+ function inferRootProjectId(project) {
5220
+ return isWorktreePath(project.path) ? null : project.id;
5221
+ }
5222
+ function readMachineLocalPath(project) {
5223
+ const machineId = process.env["TODOS_MACHINE_ID"];
5224
+ if (!machineId)
5225
+ return null;
5226
+ try {
5227
+ const row = getDatabase().query("SELECT path FROM project_machine_paths WHERE project_id = ? AND machine_id = ?").get(project.id, machineId);
5228
+ return row?.path ?? null;
5229
+ } catch {
5230
+ return null;
5231
+ }
5232
+ }
4959
5233
  async function emitSharedTaskEvent(input) {
4960
5234
  const data = taskEventData(input.task, input.data);
4961
5235
  await new EventsClient().emit({
@@ -4966,12 +5240,7 @@ async function emitSharedTaskEvent(input) {
4966
5240
  message: input.message ?? `${input.type}: ${input.task.title}`,
4967
5241
  data,
4968
5242
  dedupeKey: input.dedupeKey ?? `${input.type}:${input.task.id}:${input.task.version}`,
4969
- metadata: {
4970
- package: "@hasna/todos",
4971
- task_id: input.task.id,
4972
- project_id: input.task.project_id,
4973
- task_list_id: input.task.task_list_id
4974
- }
5243
+ metadata: taskEventMetadata(input.task)
4975
5244
  }, { deliver: true, dedupe: true });
4976
5245
  }
4977
5246
  function emitSharedTaskEventQuiet(input) {
@@ -5334,6 +5603,17 @@ function replaceTaskTags(taskId, tags, db) {
5334
5603
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
5335
5604
  insertTaskTags(taskId, tags, db);
5336
5605
  }
5606
+ function addMetadataConditions(metadata, conditions, params) {
5607
+ if (!metadata)
5608
+ return;
5609
+ for (const [key, value] of Object.entries(metadata)) {
5610
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
5611
+ throw new Error(`Invalid metadata filter key: ${key}`);
5612
+ }
5613
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
5614
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
5615
+ }
5616
+ }
5337
5617
  function createTask(input, db) {
5338
5618
  const d = db || getDatabase();
5339
5619
  const timestamp = now();
@@ -5516,6 +5796,7 @@ function listTasks(filter = {}, db) {
5516
5796
  params.push(filter.task_type);
5517
5797
  }
5518
5798
  }
5799
+ addMetadataConditions(filter.metadata, conditions, params);
5519
5800
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
5520
5801
  if (filter.cursor) {
5521
5802
  try {
@@ -5540,6 +5821,54 @@ function listTasks(filter = {}, db) {
5540
5821
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
5541
5822
  return rows.map(rowToTask);
5542
5823
  }
5824
+ function getTaskByFingerprint(fingerprint, db) {
5825
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
5826
+ return tasks[0] ?? null;
5827
+ }
5828
+ function mergeTaskMetadata(current, next, fingerprint) {
5829
+ return {
5830
+ ...current,
5831
+ ...next ?? {},
5832
+ fingerprint
5833
+ };
5834
+ }
5835
+ function upsertTaskByFingerprint(input, db) {
5836
+ const d = db || getDatabase();
5837
+ const fingerprint = input.fingerprint.trim();
5838
+ if (!fingerprint)
5839
+ throw new Error("fingerprint is required");
5840
+ const existing = getTaskByFingerprint(fingerprint, d);
5841
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
5842
+ if (!existing) {
5843
+ const task2 = createTask({ ...input, metadata }, d);
5844
+ return { task: task2, created: true };
5845
+ }
5846
+ const task = updateTask(existing.id, {
5847
+ version: existing.version,
5848
+ title: input.title,
5849
+ description: input.description,
5850
+ status: input.status,
5851
+ priority: input.priority,
5852
+ project_id: input.project_id,
5853
+ assigned_to: input.assigned_to,
5854
+ working_dir: input.working_dir,
5855
+ plan_id: input.plan_id,
5856
+ task_list_id: input.task_list_id,
5857
+ tags: input.tags,
5858
+ metadata,
5859
+ due_at: input.due_at,
5860
+ estimated_minutes: input.estimated_minutes,
5861
+ sla_minutes: input.sla_minutes,
5862
+ confidence: input.confidence,
5863
+ retry_count: input.retry_count,
5864
+ max_retries: input.max_retries,
5865
+ retry_after: input.retry_after,
5866
+ requires_approval: input.requires_approval,
5867
+ recurrence_rule: input.recurrence_rule,
5868
+ task_type: input.task_type
5869
+ }, d);
5870
+ return { task, created: false };
5871
+ }
5543
5872
  function countTasks(filter = {}, db) {
5544
5873
  const d = db || getDatabase();
5545
5874
  const conditions = [];
@@ -5603,6 +5932,7 @@ function countTasks(filter = {}, db) {
5603
5932
  conditions.push("task_list_id = ?");
5604
5933
  params.push(filter.task_list_id);
5605
5934
  }
5935
+ addMetadataConditions(filter.metadata, conditions, params);
5606
5936
  if (!filter.include_archived) {
5607
5937
  conditions.push("archived_at IS NULL");
5608
5938
  }
@@ -5653,6 +5983,10 @@ function updateTask(id, input, db) {
5653
5983
  sets.push("assigned_to = ?");
5654
5984
  params.push(input.assigned_to);
5655
5985
  }
5986
+ if (input.working_dir !== undefined) {
5987
+ sets.push("working_dir = ?");
5988
+ params.push(input.working_dir);
5989
+ }
5656
5990
  if (input.tags !== undefined) {
5657
5991
  sets.push("tags = ?");
5658
5992
  params.push(JSON.stringify(input.tags));
@@ -5741,6 +6075,8 @@ function updateTask(id, input, db) {
5741
6075
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
5742
6076
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
5743
6077
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
6078
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
6079
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
5744
6080
  if (input.approved_by !== undefined)
5745
6081
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
5746
6082
  const updatedTask = {
@@ -5776,6 +6112,10 @@ function updateTask(id, input, db) {
5776
6112
  if (input.approved_by !== undefined) {
5777
6113
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
5778
6114
  }
6115
+ const updatePayload = taskEventData(updatedTask);
6116
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
6117
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
6118
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
5779
6119
  return updatedTask;
5780
6120
  }
5781
6121
  function deleteTask(id, db) {
@@ -8471,6 +8811,7 @@ function getTaskTraceability(taskId, db) {
8471
8811
 
8472
8812
  // src/db/task-runs.ts
8473
8813
  init_redaction();
8814
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
8474
8815
  function parseObject(value) {
8475
8816
  if (!value)
8476
8817
  return {};
@@ -8493,6 +8834,72 @@ function rowToArtifact(row) {
8493
8834
  function getRunRow(runId, db) {
8494
8835
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
8495
8836
  }
8837
+ function normalizeTransactionKey(input) {
8838
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
8839
+ if (!key)
8840
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
8841
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
8842
+ }
8843
+ function loopTransactionMetadata(record) {
8844
+ const value = record.metadata["loop_transaction"];
8845
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
8846
+ }
8847
+ function runKey(record) {
8848
+ const tx = loopTransactionMetadata(record);
8849
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
8850
+ return typeof key === "string" ? key : null;
8851
+ }
8852
+ function loopId(record) {
8853
+ const tx = loopTransactionMetadata(record);
8854
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
8855
+ return typeof value === "string" ? value : null;
8856
+ }
8857
+ function loopRunId(record) {
8858
+ const tx = loopTransactionMetadata(record);
8859
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
8860
+ return typeof value === "string" ? value : null;
8861
+ }
8862
+ function getTaskRunTransactionByKey(key, taskId, db) {
8863
+ if (taskId) {
8864
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
8865
+ }
8866
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
8867
+ if (rows.length > 1)
8868
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
8869
+ return rows[0] ?? null;
8870
+ }
8871
+ function summarizeTaskRun(run) {
8872
+ return {
8873
+ id: run.id,
8874
+ task_id: run.task_id,
8875
+ agent_id: run.agent_id,
8876
+ title: run.title,
8877
+ status: run.status,
8878
+ summary: run.summary,
8879
+ idempotency_key: runKey(run),
8880
+ loop_id: loopId(run),
8881
+ loop_run_id: loopRunId(run),
8882
+ metadata_keys: Object.keys(run.metadata).sort(),
8883
+ started_at: run.started_at,
8884
+ completed_at: run.completed_at,
8885
+ updated_at: run.updated_at
8886
+ };
8887
+ }
8888
+ function findTaskRunByTransactionKey(key, taskId, db) {
8889
+ const d = db || getDatabase();
8890
+ const normalized = normalizeTransactionKey({ key });
8891
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
8892
+ if (transaction?.run_id)
8893
+ return getTaskRun(transaction.run_id, d);
8894
+ return null;
8895
+ }
8896
+ function loopRunCommands(run, key) {
8897
+ return [
8898
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
8899
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
8900
+ `todos runs begin <task-id> --key ${key} --apply --json`
8901
+ ];
8902
+ }
8496
8903
  function resolveTaskRunId(idOrPrefix, db) {
8497
8904
  const d = db || getDatabase();
8498
8905
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -8511,7 +8918,7 @@ function startTaskRun(input, db) {
8511
8918
  const d = db || getDatabase();
8512
8919
  if (!getTask(input.task_id, d))
8513
8920
  throw new TaskNotFoundError(input.task_id);
8514
- const id = uuid();
8921
+ const id = input.id ?? uuid();
8515
8922
  const timestamp = input.started_at || now();
8516
8923
  if (input.claim && input.agent_id) {
8517
8924
  startTask(input.task_id, input.agent_id, d);
@@ -8549,6 +8956,97 @@ function startTaskRun(input, db) {
8549
8956
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
8550
8957
  return run;
8551
8958
  }
8959
+ function beginTaskRunTransaction(input, db) {
8960
+ const d = db || getDatabase();
8961
+ if (!getTask(input.task_id, d))
8962
+ throw new TaskNotFoundError(input.task_id);
8963
+ const timestamp = input.started_at || now();
8964
+ const key = normalizeTransactionKey(input);
8965
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
8966
+ const dryRun = !input.apply;
8967
+ if (existing) {
8968
+ return {
8969
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
8970
+ local_only: true,
8971
+ dry_run: dryRun,
8972
+ processed_at: timestamp,
8973
+ action: "matched",
8974
+ key,
8975
+ run: summarizeTaskRun(existing),
8976
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
8977
+ commands: loopRunCommands(existing, key)
8978
+ };
8979
+ }
8980
+ if (dryRun) {
8981
+ return {
8982
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
8983
+ local_only: true,
8984
+ dry_run: true,
8985
+ processed_at: timestamp,
8986
+ action: "preview",
8987
+ key,
8988
+ run: null,
8989
+ warnings: [],
8990
+ commands: loopRunCommands(null, key)
8991
+ };
8992
+ }
8993
+ const metadata = redactValue({
8994
+ ...input.metadata || {},
8995
+ loop_transaction: {
8996
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
8997
+ idempotency_key: key,
8998
+ loop_id: input.loop_id ?? null,
8999
+ loop_run_id: input.loop_run_id ?? null,
9000
+ first_seen_at: timestamp
9001
+ },
9002
+ idempotency_key: key
9003
+ });
9004
+ const created = d.transaction(() => {
9005
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
9006
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
9007
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
9008
+ uuid(),
9009
+ input.task_id,
9010
+ key,
9011
+ input.loop_id ?? null,
9012
+ input.loop_run_id ?? null,
9013
+ JSON.stringify(metadata),
9014
+ timestamp,
9015
+ timestamp
9016
+ ]);
9017
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
9018
+ if (!transaction)
9019
+ throw new Error(`Could not create run transaction for key: ${key}`);
9020
+ if (transaction.run_id) {
9021
+ const existingRun = getTaskRun(transaction.run_id, d);
9022
+ if (existingRun)
9023
+ return { run: existingRun, action: "matched" };
9024
+ }
9025
+ const run = startTaskRun({
9026
+ id: uuid(),
9027
+ task_id: input.task_id,
9028
+ agent_id: input.agent_id,
9029
+ title: input.title,
9030
+ summary: input.summary,
9031
+ metadata,
9032
+ claim: input.claim,
9033
+ started_at: timestamp
9034
+ }, d);
9035
+ 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]);
9036
+ return { run, action: "created" };
9037
+ })();
9038
+ return {
9039
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9040
+ local_only: true,
9041
+ dry_run: false,
9042
+ processed_at: timestamp,
9043
+ action: created.action,
9044
+ key,
9045
+ run: summarizeTaskRun(created.run),
9046
+ warnings: [],
9047
+ commands: loopRunCommands(created.run, key)
9048
+ };
9049
+ }
8552
9050
  function addTaskRunEvent(input, db) {
8553
9051
  const d = db || getDatabase();
8554
9052
  const runId = resolveTaskRunId(input.run_id, d);
@@ -8728,6 +9226,66 @@ function finishTaskRun(input, db) {
8728
9226
  });
8729
9227
  return updated;
8730
9228
  }
9229
+ function finishTaskRunTransaction(input, db) {
9230
+ const d = db || getDatabase();
9231
+ const timestamp = input.completed_at || now();
9232
+ const status = input.status || "completed";
9233
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
9234
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
9235
+ if (!run) {
9236
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
9237
+ }
9238
+ if (input.task_id && run.task_id !== input.task_id) {
9239
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
9240
+ }
9241
+ const resolvedKey = key || runKey(run) || run.id;
9242
+ const dryRun = input.apply === false;
9243
+ if (run.status !== "running") {
9244
+ const conflict = run.status !== status;
9245
+ return {
9246
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9247
+ local_only: true,
9248
+ dry_run: dryRun,
9249
+ processed_at: timestamp,
9250
+ action: conflict ? "conflict" : "matched",
9251
+ key: resolvedKey,
9252
+ run: summarizeTaskRun(run),
9253
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
9254
+ commands: loopRunCommands(run, resolvedKey)
9255
+ };
9256
+ }
9257
+ if (dryRun) {
9258
+ return {
9259
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9260
+ local_only: true,
9261
+ dry_run: true,
9262
+ processed_at: timestamp,
9263
+ action: "preview",
9264
+ key: resolvedKey,
9265
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
9266
+ warnings: [],
9267
+ commands: loopRunCommands(run, resolvedKey)
9268
+ };
9269
+ }
9270
+ const finished = finishTaskRun({
9271
+ run_id: run.id,
9272
+ status,
9273
+ summary: input.summary,
9274
+ agent_id: input.agent_id,
9275
+ completed_at: timestamp
9276
+ }, d);
9277
+ return {
9278
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
9279
+ local_only: true,
9280
+ dry_run: false,
9281
+ processed_at: timestamp,
9282
+ action: "finished",
9283
+ key: resolvedKey,
9284
+ run: summarizeTaskRun(finished),
9285
+ warnings: [],
9286
+ commands: loopRunCommands(finished, resolvedKey)
9287
+ };
9288
+ }
8731
9289
  function listTaskRuns(taskId, db) {
8732
9290
  const d = db || getDatabase();
8733
9291
  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();
@@ -9741,87 +10299,6 @@ function getCapableAgents(capabilities, opts, db) {
9741
10299
  return opts?.limit ? scored.slice(0, opts.limit) : scored;
9742
10300
  }
9743
10301
 
9744
- // src/db/task-lists.ts
9745
- init_types();
9746
- init_database();
9747
- function rowToTaskList(row) {
9748
- return {
9749
- ...row,
9750
- metadata: JSON.parse(row.metadata || "{}")
9751
- };
9752
- }
9753
- function createTaskList(input, db) {
9754
- const d = db || getDatabase();
9755
- const id = uuid();
9756
- const timestamp = now();
9757
- const slug = input.slug || slugify(input.name);
9758
- if (!input.project_id) {
9759
- const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
9760
- if (existing) {
9761
- throw new Error(`Standalone task list with slug "${slug}" already exists`);
9762
- }
9763
- }
9764
- d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at)
9765
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp]);
9766
- return getTaskList(id, d);
9767
- }
9768
- function getTaskList(id, db) {
9769
- const d = db || getDatabase();
9770
- const row = d.query("SELECT * FROM task_lists WHERE id = ?").get(id);
9771
- return row ? rowToTaskList(row) : null;
9772
- }
9773
- function getTaskListBySlug(slug, projectId, db) {
9774
- const d = db || getDatabase();
9775
- let row;
9776
- if (projectId) {
9777
- row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id = ?").get(slug, projectId);
9778
- } else {
9779
- row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id IS NULL").get(slug);
9780
- }
9781
- return row ? rowToTaskList(row) : null;
9782
- }
9783
- function listTaskLists(projectId, db) {
9784
- const d = db || getDatabase();
9785
- if (projectId) {
9786
- return d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(projectId).map(rowToTaskList);
9787
- }
9788
- return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
9789
- }
9790
- function updateTaskList(id, input, db) {
9791
- const d = db || getDatabase();
9792
- const existing = getTaskList(id, d);
9793
- if (!existing)
9794
- throw new TaskListNotFoundError(id);
9795
- const sets = ["updated_at = ?"];
9796
- const params = [now()];
9797
- if (input.name !== undefined) {
9798
- sets.push("name = ?");
9799
- params.push(input.name);
9800
- }
9801
- if (input.description !== undefined) {
9802
- sets.push("description = ?");
9803
- params.push(input.description);
9804
- }
9805
- if (input.metadata !== undefined) {
9806
- sets.push("metadata = ?");
9807
- params.push(JSON.stringify(input.metadata));
9808
- }
9809
- params.push(id);
9810
- d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
9811
- return getTaskList(id, d);
9812
- }
9813
- function deleteTaskList(id, db) {
9814
- const d = db || getDatabase();
9815
- return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
9816
- }
9817
- function ensureTaskList(name, slug, projectId, db) {
9818
- const d = db || getDatabase();
9819
- const existing = getTaskListBySlug(slug, projectId, d);
9820
- if (existing)
9821
- return existing;
9822
- return createTaskList({ name, slug, project_id: projectId }, d);
9823
- }
9824
-
9825
10302
  // src/storage/local-sqlite.ts
9826
10303
  init_database();
9827
10304