@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/registry.js CHANGED
@@ -1139,6 +1139,49 @@ var init_migrations = __esm(() => {
1139
1139
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_plan ON local_retrospectives(plan_id);
1140
1140
  CREATE INDEX IF NOT EXISTS idx_local_retrospectives_agent ON local_retrospectives(agent_id);
1141
1141
  INSERT OR IGNORE INTO _migrations (id) VALUES (61);
1142
+ `,
1143
+ `
1144
+ CREATE TABLE IF NOT EXISTS task_run_transactions (
1145
+ id TEXT PRIMARY KEY,
1146
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1147
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1148
+ key TEXT NOT NULL,
1149
+ loop_id TEXT,
1150
+ loop_run_id TEXT,
1151
+ metadata TEXT DEFAULT '{}',
1152
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1153
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1154
+ UNIQUE(task_id, key)
1155
+ );
1156
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key);
1157
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key);
1158
+ CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id);
1159
+
1160
+ CREATE TABLE IF NOT EXISTS task_findings (
1161
+ id TEXT PRIMARY KEY,
1162
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1163
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1164
+ fingerprint TEXT NOT NULL,
1165
+ title TEXT NOT NULL,
1166
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1167
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1168
+ source TEXT,
1169
+ summary TEXT,
1170
+ artifact_path TEXT,
1171
+ metadata TEXT DEFAULT '{}',
1172
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1173
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1174
+ resolved_at TEXT,
1175
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1176
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1177
+ UNIQUE(task_id, fingerprint)
1178
+ );
1179
+ CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id);
1180
+ CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id);
1181
+ CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status);
1182
+ CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source);
1183
+ CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint);
1184
+ INSERT OR IGNORE INTO _migrations (id) VALUES (62);
1142
1185
  `
1143
1186
  ];
1144
1187
  });
@@ -1576,6 +1619,47 @@ function ensureSchema(db) {
1576
1619
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_run ON task_run_artifacts(run_id)");
1577
1620
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_task ON task_run_artifacts(task_id)");
1578
1621
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_artifacts_path ON task_run_artifacts(path)");
1622
+ ensureTable("task_run_transactions", `
1623
+ CREATE TABLE task_run_transactions (
1624
+ id TEXT PRIMARY KEY,
1625
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1626
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1627
+ key TEXT NOT NULL,
1628
+ loop_id TEXT,
1629
+ loop_run_id TEXT,
1630
+ metadata TEXT DEFAULT '{}',
1631
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1632
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1633
+ UNIQUE(task_id, key)
1634
+ )`);
1635
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_task_key ON task_run_transactions(task_id, key)");
1636
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_key ON task_run_transactions(key)");
1637
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_run_transactions_run ON task_run_transactions(run_id)");
1638
+ ensureTable("task_findings", `
1639
+ CREATE TABLE task_findings (
1640
+ id TEXT PRIMARY KEY,
1641
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1642
+ run_id TEXT REFERENCES task_runs(id) ON DELETE SET NULL,
1643
+ fingerprint TEXT NOT NULL,
1644
+ title TEXT NOT NULL,
1645
+ severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
1646
+ status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'ignored')),
1647
+ source TEXT,
1648
+ summary TEXT,
1649
+ artifact_path TEXT,
1650
+ metadata TEXT DEFAULT '{}',
1651
+ first_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1652
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
1653
+ resolved_at TEXT,
1654
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1655
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
1656
+ UNIQUE(task_id, fingerprint)
1657
+ )`);
1658
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_task ON task_findings(task_id)");
1659
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_run ON task_findings(run_id)");
1660
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_status ON task_findings(status)");
1661
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_source ON task_findings(source)");
1662
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_task_findings_fingerprint ON task_findings(fingerprint)");
1579
1663
  ensureTable("inbox_items", `
1580
1664
  CREATE TABLE inbox_items (
1581
1665
  id TEXT PRIMARY KEY,
@@ -3576,6 +3660,7 @@ var MCP_TOOL_GROUPS = {
3576
3660
  "add_task_run_event",
3577
3661
  "add_task_run_file",
3578
3662
  "acknowledge_handoff",
3663
+ "begin_task_run_transaction",
3579
3664
  "build_local_report",
3580
3665
  "cancel_agent_run_dispatch",
3581
3666
  "finish_task_run",
@@ -3623,6 +3708,7 @@ var MCP_TOOL_GROUPS = {
3623
3708
  "list_local_snapshots",
3624
3709
  "list_retrospectives",
3625
3710
  "list_risks",
3711
+ "list_task_findings",
3626
3712
  "list_task_runs",
3627
3713
  "list_verification_providers",
3628
3714
  "merge_duplicate_task",
@@ -3631,6 +3717,7 @@ var MCP_TOOL_GROUPS = {
3631
3717
  "remove_review_routing_rule",
3632
3718
  "restore_local_backup",
3633
3719
  "retry_agent_run_dispatch",
3720
+ "resolve_missing_task_findings",
3634
3721
  "resolve_mentions",
3635
3722
  "run_next_agent_dispatch",
3636
3723
  "search_knowledge_records",
@@ -3673,9 +3760,17 @@ var MCP_TOOL_GROUPS = {
3673
3760
  "unlock_file",
3674
3761
  "unwatch_task",
3675
3762
  "update_comment",
3763
+ "upsert_task_finding",
3676
3764
  "update_risk",
3677
3765
  "watch_task"
3678
3766
  ],
3767
+ loops: [
3768
+ "begin_task_run_transaction",
3769
+ "finish_task_run",
3770
+ "list_task_findings",
3771
+ "resolve_missing_task_findings",
3772
+ "upsert_task_finding"
3773
+ ],
3679
3774
  agents: [
3680
3775
  "auto_assign_task",
3681
3776
  "delete_agent",
@@ -3757,7 +3852,7 @@ var MCP_TOOL_GROUPS = {
3757
3852
  maintenance: ["extract_todos", "get_sla_breaches", "notify_upcoming_deadlines", "run_doctor", "score_task", "watch_source_todos"]
3758
3853
  };
3759
3854
  var MCP_PROFILE_GROUPS = {
3760
- minimal: ["core"],
3855
+ minimal: ["core", "loops"],
3761
3856
  core: ["core"],
3762
3857
  standard: ["core", "tasks", "projects", "resources", "agents", "metadata"],
3763
3858
  agent: ["core", "tasks", "projects", "resources"],
@@ -4830,6 +4925,92 @@ var TODOS_JSON_CONTRACTS = [
4830
4925
  },
4831
4926
  optional: {}
4832
4927
  }),
4928
+ contract({
4929
+ id: "loop_run_transaction",
4930
+ name: "Loop Run Transaction",
4931
+ description: "Compact local result for idempotent loop run begin/finish transactions.",
4932
+ surfaces: ["cli", "mcp", "sdk"],
4933
+ stability: "stable",
4934
+ required: {
4935
+ schema_version: field("string", "Result schema version."),
4936
+ local_only: field("boolean", "Always true; loop run transactions use local state."),
4937
+ dry_run: field("boolean", "True when no run ledger mutation was applied."),
4938
+ processed_at: isoDateField,
4939
+ action: field("string", "preview, created, matched, finished, or conflict."),
4940
+ key: field("string", "Stable idempotency key used to dedupe the transaction."),
4941
+ run: field(["object", "null"], "Compact run summary or null for create previews.", true),
4942
+ warnings: field("array", "Non-fatal warnings such as terminal-status conflicts."),
4943
+ commands: field("array", "Follow-up CLI commands for agents and operators.")
4944
+ },
4945
+ optional: {}
4946
+ }),
4947
+ contract({
4948
+ id: "task_finding",
4949
+ name: "Task Finding",
4950
+ description: "Compact local finding record deduped by task and fingerprint.",
4951
+ surfaces: ["cli", "mcp", "sdk"],
4952
+ stability: "stable",
4953
+ required: {
4954
+ schema_version: field("string", "Finding schema version."),
4955
+ id: idField,
4956
+ task_id: idField,
4957
+ run_id: field(["string", "null"], "Optional run ledger ID.", true),
4958
+ fingerprint: field("string", "Stable finding fingerprint scoped to the task."),
4959
+ title: field("string", "Short redacted finding title."),
4960
+ severity: field("string", "low, medium, high, or critical."),
4961
+ status: field("string", "open, resolved, or ignored."),
4962
+ source: field(["string", "null"], "Optional loop/tool source.", true),
4963
+ summary: field(["string", "null"], "Bounded redacted finding summary.", true),
4964
+ artifact_path: field(["string", "null"], "Local artifact path/reference; raw content is not included.", true),
4965
+ first_seen_at: isoDateField,
4966
+ last_seen_at: isoDateField,
4967
+ resolved_at: field(["string", "null"], "Resolution timestamp when closed.", true),
4968
+ metadata_keys: field("array", "Sorted metadata keys; metadata values are intentionally omitted in compact output.")
4969
+ },
4970
+ optional: {}
4971
+ }),
4972
+ contract({
4973
+ id: "task_finding_upsert",
4974
+ name: "Task Finding Upsert Result",
4975
+ description: "Local-only dry-run or applied result from idempotently upserting a task finding.",
4976
+ surfaces: ["cli", "mcp", "sdk"],
4977
+ stability: "stable",
4978
+ required: {
4979
+ schema_version: field("string", "Result schema version."),
4980
+ local_only: field("boolean", "Always true; finding upserts use local state."),
4981
+ dry_run: field("boolean", "True when no finding row was created or updated."),
4982
+ processed_at: isoDateField,
4983
+ action: field("string", "preview, created, matched, updated, or reopened."),
4984
+ fingerprint: field("string", "Normalized finding fingerprint."),
4985
+ finding: field(["object", "null"], "Compact finding summary or null for create previews.", true),
4986
+ warnings: field("array", "Non-fatal warnings.")
4987
+ },
4988
+ optional: {}
4989
+ }),
4990
+ contract({
4991
+ id: "task_finding_resolve_missing",
4992
+ name: "Task Finding Resolve Missing Result",
4993
+ description: "Local-only dry-run or applied result from resolving open findings absent from the latest loop finding set.",
4994
+ surfaces: ["cli", "mcp", "sdk"],
4995
+ stability: "stable",
4996
+ required: {
4997
+ schema_version: field("string", "Result schema version."),
4998
+ local_only: field("boolean", "Always true; finding resolution uses local state."),
4999
+ dry_run: field("boolean", "True when no finding rows were changed."),
5000
+ processed_at: isoDateField,
5001
+ action: field("string", "preview, resolved, ignored, or noop."),
5002
+ task_id: idField,
5003
+ source: field(["string", "null"], "Optional source scope.", true),
5004
+ run_id: field(["string", "null"], "Optional run ledger ID used for audit metadata.", true),
5005
+ present_fingerprint_count: field("integer", "Number of fingerprints supplied as still present."),
5006
+ candidate_count: field("integer", "Open findings missing from the supplied set."),
5007
+ changed_count: field("integer", "Rows resolved or ignored by the applied transaction."),
5008
+ omitted_count: field("integer", "Matching findings omitted from bounded output."),
5009
+ findings: field("array", "Bounded compact finding summaries."),
5010
+ warnings: field("array", "Non-fatal warnings.")
5011
+ },
5012
+ optional: {}
5013
+ }),
4833
5014
  contract({
4834
5015
  id: "verification_provider",
4835
5016
  name: "Verification Provider",
@@ -6535,6 +6716,7 @@ var LOCAL_EVENT_TYPES = [
6535
6716
  "task.blocked",
6536
6717
  "task.started",
6537
6718
  "task.completed",
6719
+ "task.updated",
6538
6720
  "task.due",
6539
6721
  "task.due_soon",
6540
6722
  "task.failed",
@@ -6773,7 +6955,7 @@ async function testLocalEventHook(name, input) {
6773
6955
  return emitLocalEventHooks({ ...input, hooks: [hook] });
6774
6956
  }
6775
6957
 
6776
- // node_modules/.bun/@hasna+events@0.1.7/node_modules/@hasna/events/dist/index.js
6958
+ // node_modules/.bun/@hasna+events@0.1.9/node_modules/@hasna/events/dist/index.js
6777
6959
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6778
6960
  import { existsSync as existsSync6 } from "fs";
6779
6961
  import { homedir } from "os";
@@ -6790,17 +6972,30 @@ function getPathValue(input, path) {
6790
6972
  return;
6791
6973
  }, input);
6792
6974
  }
6793
- function wildcardToRegExp(pattern) {
6794
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
6795
- return new RegExp(`^${escaped}$`);
6975
+ function wildcardToRegExp(pattern, options = {}) {
6976
+ let body = "";
6977
+ for (let index = 0;index < pattern.length; index += 1) {
6978
+ const char = pattern[index];
6979
+ if (char === "*") {
6980
+ if (pattern[index + 1] === "*") {
6981
+ body += ".*";
6982
+ index += 1;
6983
+ } else {
6984
+ body += options.segmentSafe ? "[^/]*" : ".*";
6985
+ }
6986
+ } else {
6987
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
6988
+ }
6989
+ }
6990
+ return new RegExp(`^${body}$`);
6796
6991
  }
6797
- function matchString(value, matcher) {
6992
+ function matchString(value, matcher, options = {}) {
6798
6993
  if (matcher === undefined)
6799
6994
  return true;
6800
6995
  if (value === undefined)
6801
6996
  return false;
6802
6997
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
6803
- return matchers.some((item) => wildcardToRegExp(item).test(value));
6998
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
6804
6999
  }
6805
7000
  function matchRecord(input, matcher) {
6806
7001
  if (!matcher)
@@ -6808,7 +7003,9 @@ function matchRecord(input, matcher) {
6808
7003
  return Object.entries(matcher).every(([path, expected]) => {
6809
7004
  const actual = getPathValue(input, path);
6810
7005
  if (typeof expected === "string" || Array.isArray(expected)) {
6811
- return matchString(actual === undefined ? undefined : String(actual), expected);
7006
+ return matchString(actual === undefined ? undefined : String(actual), expected, {
7007
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
7008
+ });
6812
7009
  }
6813
7010
  return actual === expected;
6814
7011
  });
@@ -6828,7 +7025,6 @@ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
6828
7025
  function getEventsDataDir(override) {
6829
7026
  return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join5(homedir(), ".hasna", "events");
6830
7027
  }
6831
-
6832
7028
  class JsonEventsStore {
6833
7029
  dataDir;
6834
7030
  channelsPath;
@@ -7165,7 +7361,7 @@ class EventsClient {
7165
7361
  }
7166
7362
  return deliveries;
7167
7363
  }
7168
- async testChannel(id, input = {}) {
7364
+ async matchChannel(id, input = {}) {
7169
7365
  const channel = await this.store.getChannel(id);
7170
7366
  if (!channel)
7171
7367
  throw new Error(`Channel not found: ${id}`);
@@ -7182,6 +7378,34 @@ class EventsClient {
7182
7378
  time: input.time,
7183
7379
  id: input.id
7184
7380
  });
7381
+ const matched = channelMatchesEvent(channel, event);
7382
+ return {
7383
+ channelId: channel.id,
7384
+ matched,
7385
+ event,
7386
+ filters: channel.filters,
7387
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
7388
+ };
7389
+ }
7390
+ async testChannel(id, input = {}, options = {}) {
7391
+ const channel = await this.store.getChannel(id);
7392
+ if (!channel)
7393
+ throw new Error(`Channel not found: ${id}`);
7394
+ const match = await this.matchChannel(id, input);
7395
+ const event = match.event;
7396
+ if (options.honorFilters && !match.matched) {
7397
+ const timestamp = new Date().toISOString();
7398
+ const result2 = createDeliveryResult(event, channel, [{
7399
+ attempt: 1,
7400
+ status: "skipped",
7401
+ startedAt: timestamp,
7402
+ completedAt: timestamp,
7403
+ error: match.reason
7404
+ }]);
7405
+ result2.metadata = { reason: "filter_mismatch" };
7406
+ await this.store.appendDelivery(result2);
7407
+ return result2;
7408
+ }
7185
7409
  const eventForChannel = await this.applyRedaction(event, channel);
7186
7410
  const result = await this.deliverWithRetry(eventForChannel, channel);
7187
7411
  await this.store.appendDelivery(result);
@@ -7281,6 +7505,90 @@ function normalizeRetryPolicy(policy) {
7281
7505
  };
7282
7506
  }
7283
7507
 
7508
+ // src/lib/shared-events.ts
7509
+ init_database();
7510
+
7511
+ // src/db/task-lists.ts
7512
+ init_types();
7513
+ init_database();
7514
+ function rowToTaskList(row) {
7515
+ return {
7516
+ ...row,
7517
+ metadata: JSON.parse(row.metadata || "{}")
7518
+ };
7519
+ }
7520
+ function createTaskList(input, db) {
7521
+ const d = db || getDatabase();
7522
+ const id = uuid();
7523
+ const timestamp = now();
7524
+ const slug = input.slug || slugify(input.name);
7525
+ if (!input.project_id) {
7526
+ const existing = d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ?").get(slug);
7527
+ if (existing) {
7528
+ throw new Error(`Standalone task list with slug "${slug}" already exists`);
7529
+ }
7530
+ }
7531
+ d.run(`INSERT INTO task_lists (id, project_id, slug, name, description, metadata, created_at, updated_at)
7532
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, input.project_id || null, slug, input.name, input.description || null, JSON.stringify(input.metadata || {}), timestamp, timestamp]);
7533
+ return getTaskList(id, d);
7534
+ }
7535
+ function getTaskList(id, db) {
7536
+ const d = db || getDatabase();
7537
+ const row = d.query("SELECT * FROM task_lists WHERE id = ?").get(id);
7538
+ return row ? rowToTaskList(row) : null;
7539
+ }
7540
+ function getTaskListBySlug(slug, projectId, db) {
7541
+ const d = db || getDatabase();
7542
+ let row;
7543
+ if (projectId) {
7544
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id = ?").get(slug, projectId);
7545
+ } else {
7546
+ row = d.query("SELECT * FROM task_lists WHERE slug = ? AND project_id IS NULL").get(slug);
7547
+ }
7548
+ return row ? rowToTaskList(row) : null;
7549
+ }
7550
+ function listTaskLists(projectId, db) {
7551
+ const d = db || getDatabase();
7552
+ if (projectId) {
7553
+ return d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(projectId).map(rowToTaskList);
7554
+ }
7555
+ return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
7556
+ }
7557
+ function updateTaskList(id, input, db) {
7558
+ const d = db || getDatabase();
7559
+ const existing = getTaskList(id, d);
7560
+ if (!existing)
7561
+ throw new TaskListNotFoundError(id);
7562
+ const sets = ["updated_at = ?"];
7563
+ const params = [now()];
7564
+ if (input.name !== undefined) {
7565
+ sets.push("name = ?");
7566
+ params.push(input.name);
7567
+ }
7568
+ if (input.description !== undefined) {
7569
+ sets.push("description = ?");
7570
+ params.push(input.description);
7571
+ }
7572
+ if (input.metadata !== undefined) {
7573
+ sets.push("metadata = ?");
7574
+ params.push(JSON.stringify(input.metadata));
7575
+ }
7576
+ params.push(id);
7577
+ d.run(`UPDATE task_lists SET ${sets.join(", ")} WHERE id = ?`, params);
7578
+ return getTaskList(id, d);
7579
+ }
7580
+ function deleteTaskList(id, db) {
7581
+ const d = db || getDatabase();
7582
+ return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
7583
+ }
7584
+ function ensureTaskList(name, slug, projectId, db) {
7585
+ const d = db || getDatabase();
7586
+ const existing = getTaskListBySlug(slug, projectId, d);
7587
+ if (existing)
7588
+ return existing;
7589
+ return createTaskList({ name, slug, project_id: projectId }, d);
7590
+ }
7591
+
7284
7592
  // src/lib/shared-events.ts
7285
7593
  var SOURCE = "todos";
7286
7594
  function taskEventData(task, extra = {}) {
@@ -7311,6 +7619,69 @@ function taskEventData(task, extra = {}) {
7311
7619
  ...extra
7312
7620
  };
7313
7621
  }
7622
+ function taskEventMetadata(task) {
7623
+ const metadata = {
7624
+ package: "@hasna/todos",
7625
+ todos_event_schema_version: 1,
7626
+ task_id: task.id,
7627
+ task_short_id: task.short_id,
7628
+ project_id: task.project_id,
7629
+ task_list_id: task.task_list_id,
7630
+ working_dir: task.working_dir
7631
+ };
7632
+ try {
7633
+ const project = task.project_id ? getProject(task.project_id) : null;
7634
+ const projectPath = project ? readMachineLocalPath(project) ?? project.path : task.working_dir;
7635
+ if (project) {
7636
+ metadata.project_id = project.id;
7637
+ metadata.project_name = project.name;
7638
+ metadata.project_path = projectPath;
7639
+ metadata.project_canonical_path = project.path;
7640
+ metadata.project_default_task_list_slug = project.task_list_id;
7641
+ metadata.root_project_id = inferRootProjectId(project);
7642
+ } else if (projectPath) {
7643
+ metadata.project_path = projectPath;
7644
+ metadata.project_canonical_path = projectPath;
7645
+ }
7646
+ if (projectPath) {
7647
+ metadata.project_kind = classifyProjectKind(projectPath);
7648
+ metadata.project_is_worktree = isWorktreePath(projectPath);
7649
+ if (typeof task.metadata.route_enabled === "boolean") {
7650
+ metadata.route_enabled = task.metadata.route_enabled;
7651
+ }
7652
+ metadata.working_dir = task.working_dir ?? projectPath;
7653
+ }
7654
+ 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;
7655
+ if (taskList) {
7656
+ metadata.task_list_id = taskList.id;
7657
+ metadata.task_list_slug = taskList.slug;
7658
+ metadata.task_list_name = taskList.name;
7659
+ metadata.task_list_project_id = taskList.project_id;
7660
+ metadata.task_list_is_project_default = Boolean(project?.task_list_id && taskList.slug === project.task_list_id);
7661
+ }
7662
+ } catch {}
7663
+ return metadata;
7664
+ }
7665
+ function classifyProjectKind(path) {
7666
+ return path.includes("/hasna/opensource/") ? "open-source" : "unknown";
7667
+ }
7668
+ function isWorktreePath(path) {
7669
+ return path.includes("/.codewith/worktrees/") || path.includes("/.worktrees/");
7670
+ }
7671
+ function inferRootProjectId(project) {
7672
+ return isWorktreePath(project.path) ? null : project.id;
7673
+ }
7674
+ function readMachineLocalPath(project) {
7675
+ const machineId = process.env["TODOS_MACHINE_ID"];
7676
+ if (!machineId)
7677
+ return null;
7678
+ try {
7679
+ const row = getDatabase().query("SELECT path FROM project_machine_paths WHERE project_id = ? AND machine_id = ?").get(project.id, machineId);
7680
+ return row?.path ?? null;
7681
+ } catch {
7682
+ return null;
7683
+ }
7684
+ }
7314
7685
  async function emitSharedTaskEvent(input) {
7315
7686
  const data = taskEventData(input.task, input.data);
7316
7687
  await new EventsClient().emit({
@@ -7321,12 +7692,7 @@ async function emitSharedTaskEvent(input) {
7321
7692
  message: input.message ?? `${input.type}: ${input.task.title}`,
7322
7693
  data,
7323
7694
  dedupeKey: input.dedupeKey ?? `${input.type}:${input.task.id}:${input.task.version}`,
7324
- metadata: {
7325
- package: "@hasna/todos",
7326
- task_id: input.task.id,
7327
- project_id: input.task.project_id,
7328
- task_list_id: input.task.task_list_id
7329
- }
7695
+ metadata: taskEventMetadata(input.task)
7330
7696
  }, { deliver: true, dedupe: true });
7331
7697
  }
7332
7698
  function emitSharedTaskEventQuiet(input) {
@@ -7689,6 +8055,17 @@ function replaceTaskTags(taskId, tags, db) {
7689
8055
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
7690
8056
  insertTaskTags(taskId, tags, db);
7691
8057
  }
8058
+ function addMetadataConditions(metadata, conditions, params) {
8059
+ if (!metadata)
8060
+ return;
8061
+ for (const [key, value] of Object.entries(metadata)) {
8062
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
8063
+ throw new Error(`Invalid metadata filter key: ${key}`);
8064
+ }
8065
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
8066
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
8067
+ }
8068
+ }
7692
8069
  function createTask(input, db) {
7693
8070
  const d = db || getDatabase();
7694
8071
  const timestamp = now();
@@ -7871,6 +8248,7 @@ function listTasks(filter = {}, db) {
7871
8248
  params.push(filter.task_type);
7872
8249
  }
7873
8250
  }
8251
+ addMetadataConditions(filter.metadata, conditions, params);
7874
8252
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
7875
8253
  if (filter.cursor) {
7876
8254
  try {
@@ -7895,6 +8273,54 @@ function listTasks(filter = {}, db) {
7895
8273
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
7896
8274
  return rows.map(rowToTask);
7897
8275
  }
8276
+ function getTaskByFingerprint(fingerprint, db) {
8277
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
8278
+ return tasks[0] ?? null;
8279
+ }
8280
+ function mergeTaskMetadata(current, next, fingerprint) {
8281
+ return {
8282
+ ...current,
8283
+ ...next ?? {},
8284
+ fingerprint
8285
+ };
8286
+ }
8287
+ function upsertTaskByFingerprint(input, db) {
8288
+ const d = db || getDatabase();
8289
+ const fingerprint = input.fingerprint.trim();
8290
+ if (!fingerprint)
8291
+ throw new Error("fingerprint is required");
8292
+ const existing = getTaskByFingerprint(fingerprint, d);
8293
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
8294
+ if (!existing) {
8295
+ const task2 = createTask({ ...input, metadata }, d);
8296
+ return { task: task2, created: true };
8297
+ }
8298
+ const task = updateTask(existing.id, {
8299
+ version: existing.version,
8300
+ title: input.title,
8301
+ description: input.description,
8302
+ status: input.status,
8303
+ priority: input.priority,
8304
+ project_id: input.project_id,
8305
+ assigned_to: input.assigned_to,
8306
+ working_dir: input.working_dir,
8307
+ plan_id: input.plan_id,
8308
+ task_list_id: input.task_list_id,
8309
+ tags: input.tags,
8310
+ metadata,
8311
+ due_at: input.due_at,
8312
+ estimated_minutes: input.estimated_minutes,
8313
+ sla_minutes: input.sla_minutes,
8314
+ confidence: input.confidence,
8315
+ retry_count: input.retry_count,
8316
+ max_retries: input.max_retries,
8317
+ retry_after: input.retry_after,
8318
+ requires_approval: input.requires_approval,
8319
+ recurrence_rule: input.recurrence_rule,
8320
+ task_type: input.task_type
8321
+ }, d);
8322
+ return { task, created: false };
8323
+ }
7898
8324
  function countTasks(filter = {}, db) {
7899
8325
  const d = db || getDatabase();
7900
8326
  const conditions = [];
@@ -7958,6 +8384,7 @@ function countTasks(filter = {}, db) {
7958
8384
  conditions.push("task_list_id = ?");
7959
8385
  params.push(filter.task_list_id);
7960
8386
  }
8387
+ addMetadataConditions(filter.metadata, conditions, params);
7961
8388
  if (!filter.include_archived) {
7962
8389
  conditions.push("archived_at IS NULL");
7963
8390
  }
@@ -8008,6 +8435,10 @@ function updateTask(id, input, db) {
8008
8435
  sets.push("assigned_to = ?");
8009
8436
  params.push(input.assigned_to);
8010
8437
  }
8438
+ if (input.working_dir !== undefined) {
8439
+ sets.push("working_dir = ?");
8440
+ params.push(input.working_dir);
8441
+ }
8011
8442
  if (input.tags !== undefined) {
8012
8443
  sets.push("tags = ?");
8013
8444
  params.push(JSON.stringify(input.tags));
@@ -8096,6 +8527,8 @@ function updateTask(id, input, db) {
8096
8527
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
8097
8528
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
8098
8529
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
8530
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
8531
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
8099
8532
  if (input.approved_by !== undefined)
8100
8533
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
8101
8534
  const updatedTask = {
@@ -8131,6 +8564,10 @@ function updateTask(id, input, db) {
8131
8564
  if (input.approved_by !== undefined) {
8132
8565
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
8133
8566
  }
8567
+ const updatePayload = taskEventData(updatedTask);
8568
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
8569
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
8570
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
8134
8571
  return updatedTask;
8135
8572
  }
8136
8573
  function deleteTask(id, db) {
@@ -10826,6 +11263,7 @@ function getTaskTraceability(taskId, db) {
10826
11263
 
10827
11264
  // src/db/task-runs.ts
10828
11265
  init_redaction();
11266
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
10829
11267
  function parseObject(value) {
10830
11268
  if (!value)
10831
11269
  return {};
@@ -10848,6 +11286,72 @@ function rowToArtifact(row) {
10848
11286
  function getRunRow(runId, db) {
10849
11287
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
10850
11288
  }
11289
+ function normalizeTransactionKey(input) {
11290
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
11291
+ if (!key)
11292
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
11293
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
11294
+ }
11295
+ function loopTransactionMetadata(record) {
11296
+ const value = record.metadata["loop_transaction"];
11297
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11298
+ }
11299
+ function runKey(record) {
11300
+ const tx = loopTransactionMetadata(record);
11301
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
11302
+ return typeof key === "string" ? key : null;
11303
+ }
11304
+ function loopId(record) {
11305
+ const tx = loopTransactionMetadata(record);
11306
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
11307
+ return typeof value === "string" ? value : null;
11308
+ }
11309
+ function loopRunId(record) {
11310
+ const tx = loopTransactionMetadata(record);
11311
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
11312
+ return typeof value === "string" ? value : null;
11313
+ }
11314
+ function getTaskRunTransactionByKey(key, taskId, db) {
11315
+ if (taskId) {
11316
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
11317
+ }
11318
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
11319
+ if (rows.length > 1)
11320
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
11321
+ return rows[0] ?? null;
11322
+ }
11323
+ function summarizeTaskRun(run) {
11324
+ return {
11325
+ id: run.id,
11326
+ task_id: run.task_id,
11327
+ agent_id: run.agent_id,
11328
+ title: run.title,
11329
+ status: run.status,
11330
+ summary: run.summary,
11331
+ idempotency_key: runKey(run),
11332
+ loop_id: loopId(run),
11333
+ loop_run_id: loopRunId(run),
11334
+ metadata_keys: Object.keys(run.metadata).sort(),
11335
+ started_at: run.started_at,
11336
+ completed_at: run.completed_at,
11337
+ updated_at: run.updated_at
11338
+ };
11339
+ }
11340
+ function findTaskRunByTransactionKey(key, taskId, db) {
11341
+ const d = db || getDatabase();
11342
+ const normalized = normalizeTransactionKey({ key });
11343
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
11344
+ if (transaction?.run_id)
11345
+ return getTaskRun(transaction.run_id, d);
11346
+ return null;
11347
+ }
11348
+ function loopRunCommands(run, key) {
11349
+ return [
11350
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
11351
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
11352
+ `todos runs begin <task-id> --key ${key} --apply --json`
11353
+ ];
11354
+ }
10851
11355
  function resolveTaskRunId(idOrPrefix, db) {
10852
11356
  const d = db || getDatabase();
10853
11357
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -10866,7 +11370,7 @@ function startTaskRun(input, db) {
10866
11370
  const d = db || getDatabase();
10867
11371
  if (!getTask(input.task_id, d))
10868
11372
  throw new TaskNotFoundError(input.task_id);
10869
- const id = uuid();
11373
+ const id = input.id ?? uuid();
10870
11374
  const timestamp = input.started_at || now();
10871
11375
  if (input.claim && input.agent_id) {
10872
11376
  startTask(input.task_id, input.agent_id, d);
@@ -10904,6 +11408,97 @@ function startTaskRun(input, db) {
10904
11408
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
10905
11409
  return run;
10906
11410
  }
11411
+ function beginTaskRunTransaction(input, db) {
11412
+ const d = db || getDatabase();
11413
+ if (!getTask(input.task_id, d))
11414
+ throw new TaskNotFoundError(input.task_id);
11415
+ const timestamp = input.started_at || now();
11416
+ const key = normalizeTransactionKey(input);
11417
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
11418
+ const dryRun = !input.apply;
11419
+ if (existing) {
11420
+ return {
11421
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11422
+ local_only: true,
11423
+ dry_run: dryRun,
11424
+ processed_at: timestamp,
11425
+ action: "matched",
11426
+ key,
11427
+ run: summarizeTaskRun(existing),
11428
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
11429
+ commands: loopRunCommands(existing, key)
11430
+ };
11431
+ }
11432
+ if (dryRun) {
11433
+ return {
11434
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11435
+ local_only: true,
11436
+ dry_run: true,
11437
+ processed_at: timestamp,
11438
+ action: "preview",
11439
+ key,
11440
+ run: null,
11441
+ warnings: [],
11442
+ commands: loopRunCommands(null, key)
11443
+ };
11444
+ }
11445
+ const metadata = redactValue({
11446
+ ...input.metadata || {},
11447
+ loop_transaction: {
11448
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11449
+ idempotency_key: key,
11450
+ loop_id: input.loop_id ?? null,
11451
+ loop_run_id: input.loop_run_id ?? null,
11452
+ first_seen_at: timestamp
11453
+ },
11454
+ idempotency_key: key
11455
+ });
11456
+ const created = d.transaction(() => {
11457
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
11458
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
11459
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
11460
+ uuid(),
11461
+ input.task_id,
11462
+ key,
11463
+ input.loop_id ?? null,
11464
+ input.loop_run_id ?? null,
11465
+ JSON.stringify(metadata),
11466
+ timestamp,
11467
+ timestamp
11468
+ ]);
11469
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
11470
+ if (!transaction)
11471
+ throw new Error(`Could not create run transaction for key: ${key}`);
11472
+ if (transaction.run_id) {
11473
+ const existingRun = getTaskRun(transaction.run_id, d);
11474
+ if (existingRun)
11475
+ return { run: existingRun, action: "matched" };
11476
+ }
11477
+ const run = startTaskRun({
11478
+ id: uuid(),
11479
+ task_id: input.task_id,
11480
+ agent_id: input.agent_id,
11481
+ title: input.title,
11482
+ summary: input.summary,
11483
+ metadata,
11484
+ claim: input.claim,
11485
+ started_at: timestamp
11486
+ }, d);
11487
+ d.run("UPDATE task_run_transactions SET run_id = ?, loop_id = COALESCE(?, loop_id), loop_run_id = COALESCE(?, loop_run_id), metadata = ?, updated_at = ? WHERE id = ?", [run.id, input.loop_id ?? null, input.loop_run_id ?? null, JSON.stringify(metadata), timestamp, transaction.id]);
11488
+ return { run, action: "created" };
11489
+ })();
11490
+ return {
11491
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11492
+ local_only: true,
11493
+ dry_run: false,
11494
+ processed_at: timestamp,
11495
+ action: created.action,
11496
+ key,
11497
+ run: summarizeTaskRun(created.run),
11498
+ warnings: [],
11499
+ commands: loopRunCommands(created.run, key)
11500
+ };
11501
+ }
10907
11502
  function addTaskRunEvent(input, db) {
10908
11503
  const d = db || getDatabase();
10909
11504
  const runId = resolveTaskRunId(input.run_id, d);
@@ -11083,6 +11678,66 @@ function finishTaskRun(input, db) {
11083
11678
  });
11084
11679
  return updated;
11085
11680
  }
11681
+ function finishTaskRunTransaction(input, db) {
11682
+ const d = db || getDatabase();
11683
+ const timestamp = input.completed_at || now();
11684
+ const status = input.status || "completed";
11685
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
11686
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
11687
+ if (!run) {
11688
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
11689
+ }
11690
+ if (input.task_id && run.task_id !== input.task_id) {
11691
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
11692
+ }
11693
+ const resolvedKey = key || runKey(run) || run.id;
11694
+ const dryRun = input.apply === false;
11695
+ if (run.status !== "running") {
11696
+ const conflict = run.status !== status;
11697
+ return {
11698
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11699
+ local_only: true,
11700
+ dry_run: dryRun,
11701
+ processed_at: timestamp,
11702
+ action: conflict ? "conflict" : "matched",
11703
+ key: resolvedKey,
11704
+ run: summarizeTaskRun(run),
11705
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
11706
+ commands: loopRunCommands(run, resolvedKey)
11707
+ };
11708
+ }
11709
+ if (dryRun) {
11710
+ return {
11711
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11712
+ local_only: true,
11713
+ dry_run: true,
11714
+ processed_at: timestamp,
11715
+ action: "preview",
11716
+ key: resolvedKey,
11717
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
11718
+ warnings: [],
11719
+ commands: loopRunCommands(run, resolvedKey)
11720
+ };
11721
+ }
11722
+ const finished = finishTaskRun({
11723
+ run_id: run.id,
11724
+ status,
11725
+ summary: input.summary,
11726
+ agent_id: input.agent_id,
11727
+ completed_at: timestamp
11728
+ }, d);
11729
+ return {
11730
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11731
+ local_only: true,
11732
+ dry_run: false,
11733
+ processed_at: timestamp,
11734
+ action: "finished",
11735
+ key: resolvedKey,
11736
+ run: summarizeTaskRun(finished),
11737
+ warnings: [],
11738
+ commands: loopRunCommands(finished, resolvedKey)
11739
+ };
11740
+ }
11086
11741
  function listTaskRuns(taskId, db) {
11087
11742
  const d = db || getDatabase();
11088
11743
  const rows = taskId ? d.query("SELECT * FROM task_runs WHERE task_id = ? ORDER BY started_at DESC, created_at DESC").all(taskId) : d.query("SELECT * FROM task_runs ORDER BY started_at DESC, created_at DESC LIMIT 100").all();
@@ -11644,7 +12299,7 @@ function rowToTask2(row) {
11644
12299
  requires_approval: Boolean(row.requires_approval)
11645
12300
  };
11646
12301
  }
11647
- function rowToTaskList(row) {
12302
+ function rowToTaskList2(row) {
11648
12303
  return { ...row, metadata: parseJsonObject3(row.metadata) };
11649
12304
  }
11650
12305
  function rowWithMetadata(row) {
@@ -11680,7 +12335,7 @@ function createLocalBridgeBundle(options = {}, db) {
11680
12335
  const project = options.project_id ? d.query("SELECT * FROM projects WHERE id = ?").get(options.project_id) : null;
11681
12336
  const data = redactValue({
11682
12337
  projects: options.project_id ? project ? [project] : [] : d.query("SELECT * FROM projects ORDER BY name").all(),
11683
- task_lists: (options.project_id ? d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(options.project_id) : d.query("SELECT * FROM task_lists ORDER BY name").all()).map(rowToTaskList),
12338
+ task_lists: (options.project_id ? d.query("SELECT * FROM task_lists WHERE project_id = ? ORDER BY name").all(options.project_id) : d.query("SELECT * FROM task_lists ORDER BY name").all()).map(rowToTaskList2),
11684
12339
  plans: options.project_id ? d.query("SELECT * FROM plans WHERE project_id = ? ORDER BY created_at").all(options.project_id) : d.query("SELECT * FROM plans ORDER BY created_at").all(),
11685
12340
  tasks: queryByTaskIds(d, "SELECT * FROM tasks WHERE id IN (__TASK_IDS__) ORDER BY created_at", taskIds).map(rowToTask2),
11686
12341
  task_dependencies: queryByTaskIds(d, "SELECT task_id, depends_on, external_project_id, external_task_id FROM task_dependencies WHERE task_id IN (__TASK_IDS__) ORDER BY task_id, depends_on", taskIds),
@@ -17263,7 +17918,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
17263
17918
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
17264
17919
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
17265
17920
  }
17266
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
17921
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
17267
17922
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
17268
17923
  mergedDuplicates.push({
17269
17924
  id: duplicate.id,
@@ -17324,7 +17979,7 @@ function mergeDuplicateTask(input, db) {
17324
17979
  updateTask(primary.id, {
17325
17980
  version: primary.version,
17326
17981
  tags: mergedTags,
17327
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
17982
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
17328
17983
  description: mergeTaskDescription(primary, duplicate) ?? undefined
17329
17984
  }, d);
17330
17985
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -19795,6 +20450,33 @@ var TODOS_API_ROUTES = [
19795
20450
  tags: ["tasks", "mutation"],
19796
20451
  stability: "stable"
19797
20452
  },
20453
+ {
20454
+ id: "tasks.upsert",
20455
+ method: "POST",
20456
+ path: "/api/tasks/upsert",
20457
+ description: "Create or update a task by stable metadata fingerprint, merging metadata on updates.",
20458
+ auth: "optional-api-key",
20459
+ requestSchema: {
20460
+ type: "object",
20461
+ properties: {
20462
+ fingerprint: { type: "string" },
20463
+ title: { type: "string" },
20464
+ description: { type: "string" },
20465
+ priority: { type: "string", enum: TASK_PRIORITIES },
20466
+ status: { type: "string", enum: TASK_STATUSES },
20467
+ project_id: { type: "string" },
20468
+ task_list_id: { type: "string" },
20469
+ working_dir: { type: "string" },
20470
+ tags: { type: "array", items: { type: "string" } },
20471
+ metadata: objectSchema
20472
+ },
20473
+ required: ["fingerprint", "title"],
20474
+ additionalProperties: true
20475
+ },
20476
+ responseSchema: objectSchema,
20477
+ tags: ["tasks", "mutation", "dedupe"],
20478
+ stability: "stable"
20479
+ },
19798
20480
  {
19799
20481
  id: "tasks.read",
19800
20482
  method: "GET",