@hasna/todos 0.11.58 → 0.11.60

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 +66 -2
  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 +1518 -197
  5. package/dist/contracts.d.ts.map +1 -1
  6. package/dist/contracts.js +600 -14
  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 +968 -15
  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 +1082 -27
  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 +600 -14
  32. package/dist/release-provenance.json +3 -3
  33. package/dist/server/index.js +1082 -27
  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 +473 -11
  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/contracts.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.9/node_modules/@hasna/events/dist/index.js
6958
+ // node_modules/.bun/@hasna+events@0.1.10/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";
@@ -6820,14 +7002,40 @@ function matchRecord(input, matcher) {
6820
7002
  return true;
6821
7003
  return Object.entries(matcher).every(([path, expected]) => {
6822
7004
  const actual = getPathValue(input, path);
6823
- if (typeof expected === "string" || Array.isArray(expected)) {
6824
- return matchString(actual === undefined ? undefined : String(actual), expected, {
6825
- segmentSafe: path.endsWith("_path") || path.endsWith(".path")
6826
- });
6827
- }
6828
- return actual === expected;
7005
+ return matchField(actual, expected, path);
6829
7006
  });
6830
7007
  }
7008
+ function matchField(actual, expected, path) {
7009
+ if (isNegativeMatcher(expected)) {
7010
+ return !matchPositiveField(actual, expected.not, path);
7011
+ }
7012
+ return matchPositiveField(actual, expected, path);
7013
+ }
7014
+ function matchPositiveField(actual, expected, path) {
7015
+ if (typeof expected === "string" || Array.isArray(expected)) {
7016
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
7017
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
7018
+ }));
7019
+ }
7020
+ if (Array.isArray(actual)) {
7021
+ return actual.some((item) => item === expected);
7022
+ }
7023
+ return actual === expected;
7024
+ }
7025
+ function stringCandidates(actual) {
7026
+ if (actual === undefined)
7027
+ return [];
7028
+ if (Array.isArray(actual)) {
7029
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
7030
+ }
7031
+ return [String(actual)];
7032
+ }
7033
+ function isPrimitiveFieldValue(value) {
7034
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
7035
+ }
7036
+ function isNegativeMatcher(value) {
7037
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
7038
+ }
6831
7039
  function eventMatchesFilter(event, filter) {
6832
7040
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
6833
7041
  }
@@ -7434,9 +7642,66 @@ function taskEventData(task, extra = {}) {
7434
7642
  started_at: task.started_at,
7435
7643
  completed_at: task.completed_at,
7436
7644
  due_at: task.due_at,
7645
+ requires_approval: task.requires_approval,
7646
+ approved_by: task.approved_by,
7647
+ approved_at: task.approved_at,
7437
7648
  ...extra
7438
7649
  };
7439
7650
  }
7651
+ function booleanField(value) {
7652
+ if (typeof value === "boolean")
7653
+ return value;
7654
+ if (typeof value === "number") {
7655
+ if (value === 1)
7656
+ return true;
7657
+ if (value === 0)
7658
+ return false;
7659
+ }
7660
+ if (typeof value === "string") {
7661
+ const normalized = value.trim().toLowerCase();
7662
+ if (["true", "1", "yes", "on"].includes(normalized))
7663
+ return true;
7664
+ if (["false", "0", "no", "off"].includes(normalized))
7665
+ return false;
7666
+ }
7667
+ return;
7668
+ }
7669
+ function objectField(value) {
7670
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
7671
+ }
7672
+ function firstBoolean(records, keys) {
7673
+ for (const record of records) {
7674
+ for (const key of keys) {
7675
+ const value = booleanField(record[key]);
7676
+ if (value !== undefined)
7677
+ return value;
7678
+ }
7679
+ }
7680
+ return;
7681
+ }
7682
+ function routingAutomationMetadata(task) {
7683
+ const automation = objectField(task.metadata.automation);
7684
+ const records = [task.metadata];
7685
+ if (automation)
7686
+ records.push(automation);
7687
+ const result = {};
7688
+ const aliases = [
7689
+ ["allowed", ["allowed", "automation_allowed", "automationAllowed"]],
7690
+ ["no_auto", ["no_auto", "noAuto"]],
7691
+ ["manual", ["manual"]],
7692
+ ["manual_required", ["manual_required", "manualRequired"]],
7693
+ ["requires_approval", ["requires_approval", "requiresApproval"]],
7694
+ ["approval_required", ["approval_required", "approvalRequired"]]
7695
+ ];
7696
+ for (const [canonical, keys] of aliases) {
7697
+ const value = firstBoolean(records, keys);
7698
+ if (value !== undefined)
7699
+ result[canonical] = value;
7700
+ }
7701
+ if (task.requires_approval)
7702
+ result.requires_approval = true;
7703
+ return Object.keys(result).length > 0 ? result : undefined;
7704
+ }
7440
7705
  function taskEventMetadata(task) {
7441
7706
  const metadata = {
7442
7707
  package: "@hasna/todos",
@@ -7447,6 +7712,14 @@ function taskEventMetadata(task) {
7447
7712
  task_list_id: task.task_list_id,
7448
7713
  working_dir: task.working_dir
7449
7714
  };
7715
+ const routeEnabled = booleanField(task.metadata.route_enabled);
7716
+ if (routeEnabled !== undefined) {
7717
+ metadata.route_enabled = routeEnabled;
7718
+ }
7719
+ const automation = routingAutomationMetadata(task);
7720
+ if (automation) {
7721
+ metadata.automation = automation;
7722
+ }
7450
7723
  try {
7451
7724
  const project = task.project_id ? getProject(task.project_id) : null;
7452
7725
  const projectPath = project ? readMachineLocalPath(project) ?? project.path : task.working_dir;
@@ -7464,9 +7737,6 @@ function taskEventMetadata(task) {
7464
7737
  if (projectPath) {
7465
7738
  metadata.project_kind = classifyProjectKind(projectPath);
7466
7739
  metadata.project_is_worktree = isWorktreePath(projectPath);
7467
- if (typeof task.metadata.route_enabled === "boolean") {
7468
- metadata.route_enabled = task.metadata.route_enabled;
7469
- }
7470
7740
  metadata.working_dir = task.working_dir ?? projectPath;
7471
7741
  }
7472
7742
  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;
@@ -7873,6 +8143,17 @@ function replaceTaskTags(taskId, tags, db) {
7873
8143
  db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
7874
8144
  insertTaskTags(taskId, tags, db);
7875
8145
  }
8146
+ function addMetadataConditions(metadata, conditions, params) {
8147
+ if (!metadata)
8148
+ return;
8149
+ for (const [key, value] of Object.entries(metadata)) {
8150
+ if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
8151
+ throw new Error(`Invalid metadata filter key: ${key}`);
8152
+ }
8153
+ conditions.push(`json_extract(metadata, '$."${key}"') = ?`);
8154
+ params.push(value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value));
8155
+ }
8156
+ }
7876
8157
  function createTask(input, db) {
7877
8158
  const d = db || getDatabase();
7878
8159
  const timestamp = now();
@@ -8055,6 +8336,7 @@ function listTasks(filter = {}, db) {
8055
8336
  params.push(filter.task_type);
8056
8337
  }
8057
8338
  }
8339
+ addMetadataConditions(filter.metadata, conditions, params);
8058
8340
  const PRIORITY_RANK = `CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END`;
8059
8341
  if (filter.cursor) {
8060
8342
  try {
@@ -8079,6 +8361,54 @@ function listTasks(filter = {}, db) {
8079
8361
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC${limitClause}`).all(...params);
8080
8362
  return rows.map(rowToTask);
8081
8363
  }
8364
+ function getTaskByFingerprint(fingerprint, db) {
8365
+ const tasks = listTasks({ metadata: { fingerprint }, limit: 1 }, db);
8366
+ return tasks[0] ?? null;
8367
+ }
8368
+ function mergeTaskMetadata(current, next, fingerprint) {
8369
+ return {
8370
+ ...current,
8371
+ ...next ?? {},
8372
+ fingerprint
8373
+ };
8374
+ }
8375
+ function upsertTaskByFingerprint(input, db) {
8376
+ const d = db || getDatabase();
8377
+ const fingerprint = input.fingerprint.trim();
8378
+ if (!fingerprint)
8379
+ throw new Error("fingerprint is required");
8380
+ const existing = getTaskByFingerprint(fingerprint, d);
8381
+ const metadata = mergeTaskMetadata(existing?.metadata ?? {}, input.metadata, fingerprint);
8382
+ if (!existing) {
8383
+ const task2 = createTask({ ...input, metadata }, d);
8384
+ return { task: task2, created: true };
8385
+ }
8386
+ const task = updateTask(existing.id, {
8387
+ version: existing.version,
8388
+ title: input.title,
8389
+ description: input.description,
8390
+ status: input.status,
8391
+ priority: input.priority,
8392
+ project_id: input.project_id,
8393
+ assigned_to: input.assigned_to,
8394
+ working_dir: input.working_dir,
8395
+ plan_id: input.plan_id,
8396
+ task_list_id: input.task_list_id,
8397
+ tags: input.tags,
8398
+ metadata,
8399
+ due_at: input.due_at,
8400
+ estimated_minutes: input.estimated_minutes,
8401
+ sla_minutes: input.sla_minutes,
8402
+ confidence: input.confidence,
8403
+ retry_count: input.retry_count,
8404
+ max_retries: input.max_retries,
8405
+ retry_after: input.retry_after,
8406
+ requires_approval: input.requires_approval,
8407
+ recurrence_rule: input.recurrence_rule,
8408
+ task_type: input.task_type
8409
+ }, d);
8410
+ return { task, created: false };
8411
+ }
8082
8412
  function countTasks(filter = {}, db) {
8083
8413
  const d = db || getDatabase();
8084
8414
  const conditions = [];
@@ -8142,6 +8472,7 @@ function countTasks(filter = {}, db) {
8142
8472
  conditions.push("task_list_id = ?");
8143
8473
  params.push(filter.task_list_id);
8144
8474
  }
8475
+ addMetadataConditions(filter.metadata, conditions, params);
8145
8476
  if (!filter.include_archived) {
8146
8477
  conditions.push("archived_at IS NULL");
8147
8478
  }
@@ -8192,6 +8523,10 @@ function updateTask(id, input, db) {
8192
8523
  sets.push("assigned_to = ?");
8193
8524
  params.push(input.assigned_to);
8194
8525
  }
8526
+ if (input.working_dir !== undefined) {
8527
+ sets.push("working_dir = ?");
8528
+ params.push(input.working_dir);
8529
+ }
8195
8530
  if (input.tags !== undefined) {
8196
8531
  sets.push("tags = ?");
8197
8532
  params.push(JSON.stringify(input.tags));
@@ -8280,6 +8615,8 @@ function updateTask(id, input, db) {
8280
8615
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
8281
8616
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
8282
8617
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
8618
+ if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
8619
+ logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
8283
8620
  if (input.approved_by !== undefined)
8284
8621
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
8285
8622
  const updatedTask = {
@@ -8315,6 +8652,10 @@ function updateTask(id, input, db) {
8315
8652
  if (input.approved_by !== undefined) {
8316
8653
  emitLocalEventHooksQuiet({ type: "approval.decided", payload: { id, approved_by: input.approved_by, title: task.title } });
8317
8654
  }
8655
+ const updatePayload = taskEventData(updatedTask);
8656
+ dispatchWebhook2("task.updated", updatePayload, d).catch(() => {});
8657
+ emitLocalEventHooksQuiet({ type: "task.updated", payload: updatePayload });
8658
+ emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask });
8318
8659
  return updatedTask;
8319
8660
  }
8320
8661
  function deleteTask(id, db) {
@@ -11010,6 +11351,7 @@ function getTaskTraceability(taskId, db) {
11010
11351
 
11011
11352
  // src/db/task-runs.ts
11012
11353
  init_redaction();
11354
+ var LOOP_RUN_TRANSACTION_SCHEMA_VERSION = "todos.loop_run_transaction.v1";
11013
11355
  function parseObject(value) {
11014
11356
  if (!value)
11015
11357
  return {};
@@ -11032,6 +11374,72 @@ function rowToArtifact(row) {
11032
11374
  function getRunRow(runId, db) {
11033
11375
  return db.query("SELECT * FROM task_runs WHERE id = ?").get(runId);
11034
11376
  }
11377
+ function normalizeTransactionKey(input) {
11378
+ const key = (input.key || input.loop_run_id || input.loop_id || "").trim();
11379
+ if (!key)
11380
+ throw new Error("idempotent run transactions require --key, --loop-run-id, or --loop-id");
11381
+ return key.toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 240);
11382
+ }
11383
+ function loopTransactionMetadata(record) {
11384
+ const value = record.metadata["loop_transaction"];
11385
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
11386
+ }
11387
+ function runKey(record) {
11388
+ const tx = loopTransactionMetadata(record);
11389
+ const key = tx["idempotency_key"] ?? record.metadata["idempotency_key"];
11390
+ return typeof key === "string" ? key : null;
11391
+ }
11392
+ function loopId(record) {
11393
+ const tx = loopTransactionMetadata(record);
11394
+ const value = tx["loop_id"] ?? record.metadata["loop_id"];
11395
+ return typeof value === "string" ? value : null;
11396
+ }
11397
+ function loopRunId(record) {
11398
+ const tx = loopTransactionMetadata(record);
11399
+ const value = tx["loop_run_id"] ?? record.metadata["loop_run_id"];
11400
+ return typeof value === "string" ? value : null;
11401
+ }
11402
+ function getTaskRunTransactionByKey(key, taskId, db) {
11403
+ if (taskId) {
11404
+ return db.query("SELECT * FROM task_run_transactions WHERE task_id = ? AND key = ?").get(taskId, key);
11405
+ }
11406
+ const rows = db.query("SELECT * FROM task_run_transactions WHERE key = ? ORDER BY created_at DESC LIMIT 2").all(key);
11407
+ if (rows.length > 1)
11408
+ throw new Error(`Run transaction key is ambiguous across tasks: ${key}. Pass task_id.`);
11409
+ return rows[0] ?? null;
11410
+ }
11411
+ function summarizeTaskRun(run) {
11412
+ return {
11413
+ id: run.id,
11414
+ task_id: run.task_id,
11415
+ agent_id: run.agent_id,
11416
+ title: run.title,
11417
+ status: run.status,
11418
+ summary: run.summary,
11419
+ idempotency_key: runKey(run),
11420
+ loop_id: loopId(run),
11421
+ loop_run_id: loopRunId(run),
11422
+ metadata_keys: Object.keys(run.metadata).sort(),
11423
+ started_at: run.started_at,
11424
+ completed_at: run.completed_at,
11425
+ updated_at: run.updated_at
11426
+ };
11427
+ }
11428
+ function findTaskRunByTransactionKey(key, taskId, db) {
11429
+ const d = db || getDatabase();
11430
+ const normalized = normalizeTransactionKey({ key });
11431
+ const transaction = getTaskRunTransactionByKey(normalized, taskId, d);
11432
+ if (transaction?.run_id)
11433
+ return getTaskRun(transaction.run_id, d);
11434
+ return null;
11435
+ }
11436
+ function loopRunCommands(run, key) {
11437
+ return [
11438
+ run ? `todos runs show ${run.id.slice(0, 8)}` : "todos runs list",
11439
+ run ? `todos findings list --task ${run.task_id.slice(0, 8)} --json` : "todos findings list --json",
11440
+ `todos runs begin <task-id> --key ${key} --apply --json`
11441
+ ];
11442
+ }
11035
11443
  function resolveTaskRunId(idOrPrefix, db) {
11036
11444
  const d = db || getDatabase();
11037
11445
  const rows = d.query("SELECT id FROM task_runs WHERE id = ? OR id LIKE ? ORDER BY created_at DESC LIMIT 2").all(idOrPrefix, `${idOrPrefix}%`);
@@ -11050,7 +11458,7 @@ function startTaskRun(input, db) {
11050
11458
  const d = db || getDatabase();
11051
11459
  if (!getTask(input.task_id, d))
11052
11460
  throw new TaskNotFoundError(input.task_id);
11053
- const id = uuid();
11461
+ const id = input.id ?? uuid();
11054
11462
  const timestamp = input.started_at || now();
11055
11463
  if (input.claim && input.agent_id) {
11056
11464
  startTask(input.task_id, input.agent_id, d);
@@ -11088,6 +11496,97 @@ function startTaskRun(input, db) {
11088
11496
  emitLocalEventHooksQuiet({ type: "run.started", payload: { id: run.id, task_id: run.task_id, agent_id: run.agent_id, title: run.title } });
11089
11497
  return run;
11090
11498
  }
11499
+ function beginTaskRunTransaction(input, db) {
11500
+ const d = db || getDatabase();
11501
+ if (!getTask(input.task_id, d))
11502
+ throw new TaskNotFoundError(input.task_id);
11503
+ const timestamp = input.started_at || now();
11504
+ const key = normalizeTransactionKey(input);
11505
+ const existing = findTaskRunByTransactionKey(key, input.task_id, d);
11506
+ const dryRun = !input.apply;
11507
+ if (existing) {
11508
+ return {
11509
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11510
+ local_only: true,
11511
+ dry_run: dryRun,
11512
+ processed_at: timestamp,
11513
+ action: "matched",
11514
+ key,
11515
+ run: summarizeTaskRun(existing),
11516
+ warnings: existing.status === "running" ? [] : [`matched ${existing.status} run`],
11517
+ commands: loopRunCommands(existing, key)
11518
+ };
11519
+ }
11520
+ if (dryRun) {
11521
+ return {
11522
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11523
+ local_only: true,
11524
+ dry_run: true,
11525
+ processed_at: timestamp,
11526
+ action: "preview",
11527
+ key,
11528
+ run: null,
11529
+ warnings: [],
11530
+ commands: loopRunCommands(null, key)
11531
+ };
11532
+ }
11533
+ const metadata = redactValue({
11534
+ ...input.metadata || {},
11535
+ loop_transaction: {
11536
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11537
+ idempotency_key: key,
11538
+ loop_id: input.loop_id ?? null,
11539
+ loop_run_id: input.loop_run_id ?? null,
11540
+ first_seen_at: timestamp
11541
+ },
11542
+ idempotency_key: key
11543
+ });
11544
+ const created = d.transaction(() => {
11545
+ d.run(`INSERT OR IGNORE INTO task_run_transactions (
11546
+ id, task_id, run_id, key, loop_id, loop_run_id, metadata, created_at, updated_at
11547
+ ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`, [
11548
+ uuid(),
11549
+ input.task_id,
11550
+ key,
11551
+ input.loop_id ?? null,
11552
+ input.loop_run_id ?? null,
11553
+ JSON.stringify(metadata),
11554
+ timestamp,
11555
+ timestamp
11556
+ ]);
11557
+ const transaction = getTaskRunTransactionByKey(key, input.task_id, d);
11558
+ if (!transaction)
11559
+ throw new Error(`Could not create run transaction for key: ${key}`);
11560
+ if (transaction.run_id) {
11561
+ const existingRun = getTaskRun(transaction.run_id, d);
11562
+ if (existingRun)
11563
+ return { run: existingRun, action: "matched" };
11564
+ }
11565
+ const run = startTaskRun({
11566
+ id: uuid(),
11567
+ task_id: input.task_id,
11568
+ agent_id: input.agent_id,
11569
+ title: input.title,
11570
+ summary: input.summary,
11571
+ metadata,
11572
+ claim: input.claim,
11573
+ started_at: timestamp
11574
+ }, d);
11575
+ 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]);
11576
+ return { run, action: "created" };
11577
+ })();
11578
+ return {
11579
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11580
+ local_only: true,
11581
+ dry_run: false,
11582
+ processed_at: timestamp,
11583
+ action: created.action,
11584
+ key,
11585
+ run: summarizeTaskRun(created.run),
11586
+ warnings: [],
11587
+ commands: loopRunCommands(created.run, key)
11588
+ };
11589
+ }
11091
11590
  function addTaskRunEvent(input, db) {
11092
11591
  const d = db || getDatabase();
11093
11592
  const runId = resolveTaskRunId(input.run_id, d);
@@ -11267,6 +11766,66 @@ function finishTaskRun(input, db) {
11267
11766
  });
11268
11767
  return updated;
11269
11768
  }
11769
+ function finishTaskRunTransaction(input, db) {
11770
+ const d = db || getDatabase();
11771
+ const timestamp = input.completed_at || now();
11772
+ const status = input.status || "completed";
11773
+ const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
11774
+ const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
11775
+ if (!run) {
11776
+ throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
11777
+ }
11778
+ if (input.task_id && run.task_id !== input.task_id) {
11779
+ throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
11780
+ }
11781
+ const resolvedKey = key || runKey(run) || run.id;
11782
+ const dryRun = input.apply === false;
11783
+ if (run.status !== "running") {
11784
+ const conflict = run.status !== status;
11785
+ return {
11786
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11787
+ local_only: true,
11788
+ dry_run: dryRun,
11789
+ processed_at: timestamp,
11790
+ action: conflict ? "conflict" : "matched",
11791
+ key: resolvedKey,
11792
+ run: summarizeTaskRun(run),
11793
+ warnings: conflict ? [`run is already ${run.status}; requested ${status}`] : [],
11794
+ commands: loopRunCommands(run, resolvedKey)
11795
+ };
11796
+ }
11797
+ if (dryRun) {
11798
+ return {
11799
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11800
+ local_only: true,
11801
+ dry_run: true,
11802
+ processed_at: timestamp,
11803
+ action: "preview",
11804
+ key: resolvedKey,
11805
+ run: summarizeTaskRun({ ...run, status, summary: input.summary ?? run.summary, completed_at: timestamp, updated_at: timestamp }),
11806
+ warnings: [],
11807
+ commands: loopRunCommands(run, resolvedKey)
11808
+ };
11809
+ }
11810
+ const finished = finishTaskRun({
11811
+ run_id: run.id,
11812
+ status,
11813
+ summary: input.summary,
11814
+ agent_id: input.agent_id,
11815
+ completed_at: timestamp
11816
+ }, d);
11817
+ return {
11818
+ schema_version: LOOP_RUN_TRANSACTION_SCHEMA_VERSION,
11819
+ local_only: true,
11820
+ dry_run: false,
11821
+ processed_at: timestamp,
11822
+ action: "finished",
11823
+ key: resolvedKey,
11824
+ run: summarizeTaskRun(finished),
11825
+ warnings: [],
11826
+ commands: loopRunCommands(finished, resolvedKey)
11827
+ };
11828
+ }
11270
11829
  function listTaskRuns(taskId, db) {
11271
11830
  const d = db || getDatabase();
11272
11831
  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();
@@ -17447,7 +18006,7 @@ function countMovedStandaloneVerifications(db, duplicateId) {
17447
18006
  const rows = db.query("SELECT command FROM task_verifications WHERE task_id = ?").all(duplicateId);
17448
18007
  return rows.filter((row) => !generatedCommands.has(row.command)).length;
17449
18008
  }
17450
- function mergeTaskMetadata(primary, duplicate, input, mergedAt) {
18009
+ function mergeTaskMetadata2(primary, duplicate, input, mergedAt) {
17451
18010
  const mergedDuplicates = Array.isArray(primary.metadata["merged_duplicates"]) ? [...primary.metadata["merged_duplicates"]] : [];
17452
18011
  mergedDuplicates.push({
17453
18012
  id: duplicate.id,
@@ -17508,7 +18067,7 @@ function mergeDuplicateTask(input, db) {
17508
18067
  updateTask(primary.id, {
17509
18068
  version: primary.version,
17510
18069
  tags: mergedTags,
17511
- metadata: mergeTaskMetadata(primary, duplicate, input, mergedAt),
18070
+ metadata: mergeTaskMetadata2(primary, duplicate, input, mergedAt),
17512
18071
  description: mergeTaskDescription(primary, duplicate) ?? undefined
17513
18072
  }, d);
17514
18073
  moved.comments = updateRows(d, "task_comments", "task_id", duplicate.id, primary.id);
@@ -19979,6 +20538,33 @@ var TODOS_API_ROUTES = [
19979
20538
  tags: ["tasks", "mutation"],
19980
20539
  stability: "stable"
19981
20540
  },
20541
+ {
20542
+ id: "tasks.upsert",
20543
+ method: "POST",
20544
+ path: "/api/tasks/upsert",
20545
+ description: "Create or update a task by stable metadata fingerprint, merging metadata on updates.",
20546
+ auth: "optional-api-key",
20547
+ requestSchema: {
20548
+ type: "object",
20549
+ properties: {
20550
+ fingerprint: { type: "string" },
20551
+ title: { type: "string" },
20552
+ description: { type: "string" },
20553
+ priority: { type: "string", enum: TASK_PRIORITIES },
20554
+ status: { type: "string", enum: TASK_STATUSES },
20555
+ project_id: { type: "string" },
20556
+ task_list_id: { type: "string" },
20557
+ working_dir: { type: "string" },
20558
+ tags: { type: "array", items: { type: "string" } },
20559
+ metadata: objectSchema
20560
+ },
20561
+ required: ["fingerprint", "title"],
20562
+ additionalProperties: true
20563
+ },
20564
+ responseSchema: objectSchema,
20565
+ tags: ["tasks", "mutation", "dedupe"],
20566
+ stability: "stable"
20567
+ },
19982
20568
  {
19983
20569
  id: "tasks.read",
19984
20570
  method: "GET",