@hasna/todos 0.15.7 → 0.15.10

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 (78) hide show
  1. package/dist/cli/cloud-router.d.ts +26 -1
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +3836 -1465
  7. package/dist/contracts.js +143 -10
  8. package/dist/db/migrations.d.ts.map +1 -1
  9. package/dist/db/plan-project-links.d.ts +8 -0
  10. package/dist/db/plan-project-links.d.ts.map +1 -0
  11. package/dist/db/plan-row-serialization.d.ts +8 -0
  12. package/dist/db/plan-row-serialization.d.ts.map +1 -0
  13. package/dist/db/plans.d.ts.map +1 -1
  14. package/dist/db/task-crud.d.ts.map +1 -1
  15. package/dist/db/task-lists.d.ts +5 -0
  16. package/dist/db/task-lists.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +8037 -1112
  20. package/dist/lib/assignee-validation.d.ts +44 -0
  21. package/dist/lib/assignee-validation.d.ts.map +1 -1
  22. package/dist/lib/plan-project-link-contract.d.ts +17 -0
  23. package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
  24. package/dist/lib/plan-project-link.d.ts +14 -0
  25. package/dist/lib/plan-project-link.d.ts.map +1 -0
  26. package/dist/lib/project-task-list-ensure.d.ts +19 -0
  27. package/dist/lib/project-task-list-ensure.d.ts.map +1 -0
  28. package/dist/mcp/index.js +2234 -31
  29. package/dist/mcp.js +7 -3
  30. package/dist/project-registration/sqlite.d.ts.map +1 -1
  31. package/dist/project-registration.js +1335 -60
  32. package/dist/registry.d.ts +1 -1
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +150 -10
  35. package/dist/release-provenance.json +5 -5
  36. package/dist/sdk/index.d.ts +1 -1
  37. package/dist/sdk/index.d.ts.map +1 -1
  38. package/dist/sdk/index.js +42 -0
  39. package/dist/sdk/v1.generated.d.ts +96 -0
  40. package/dist/sdk/v1.generated.d.ts.map +1 -1
  41. package/dist/server/index.js +2419 -216
  42. package/dist/server/openapi.d.ts +705 -24
  43. package/dist/server/openapi.d.ts.map +1 -1
  44. package/dist/server/v1.d.ts.map +1 -1
  45. package/dist/storage/interfaces.d.ts +37 -1
  46. package/dist/storage/interfaces.d.ts.map +1 -1
  47. package/dist/storage/local-sqlite.d.ts.map +1 -1
  48. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  49. package/dist/storage/postgres-sync.d.ts.map +1 -1
  50. package/dist/storage.js +870 -20
  51. package/dist/task-manifest/authority.d.ts +19 -0
  52. package/dist/task-manifest/authority.d.ts.map +1 -0
  53. package/dist/task-manifest/backend.d.ts +27 -0
  54. package/dist/task-manifest/backend.d.ts.map +1 -0
  55. package/dist/task-manifest/canonical.d.ts +4 -0
  56. package/dist/task-manifest/canonical.d.ts.map +1 -0
  57. package/dist/task-manifest/http.d.ts +16 -0
  58. package/dist/task-manifest/http.d.ts.map +1 -0
  59. package/dist/task-manifest/index.d.ts +10 -0
  60. package/dist/task-manifest/index.d.ts.map +1 -0
  61. package/dist/task-manifest/postgres.d.ts +18 -0
  62. package/dist/task-manifest/postgres.d.ts.map +1 -0
  63. package/dist/task-manifest/reference-guard.d.ts +27 -0
  64. package/dist/task-manifest/reference-guard.d.ts.map +1 -0
  65. package/dist/task-manifest/schema-sql.d.ts +3 -0
  66. package/dist/task-manifest/schema-sql.d.ts.map +1 -0
  67. package/dist/task-manifest/schema.d.ts +15 -0
  68. package/dist/task-manifest/schema.d.ts.map +1 -0
  69. package/dist/task-manifest/sqlite.d.ts +15 -0
  70. package/dist/task-manifest/sqlite.d.ts.map +1 -0
  71. package/dist/task-manifest/types.d.ts +166 -0
  72. package/dist/task-manifest/types.d.ts.map +1 -0
  73. package/dist/task-manifest.d.ts +2 -0
  74. package/dist/task-manifest.d.ts.map +1 -0
  75. package/dist/task-manifest.js +6816 -0
  76. package/dist/types/index.d.ts +89 -2
  77. package/dist/types/index.d.ts.map +1 -1
  78. package/package.json +7 -3
@@ -2421,6 +2421,29 @@ var init_migrations = __esm(() => {
2421
2421
  ${sqliteTodosProjectRegistrationSchemaSql()}
2422
2422
  INSERT OR IGNORE INTO _migrations (id) VALUES (69);
2423
2423
  COMMIT;
2424
+ `,
2425
+ `BEGIN;
2426
+ CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
2427
+ receipt_id TEXT PRIMARY KEY,
2428
+ idempotency_key TEXT NOT NULL UNIQUE,
2429
+ plan_id TEXT NOT NULL,
2430
+ project_id TEXT NOT NULL,
2431
+ payload_hash TEXT NOT NULL,
2432
+ payload TEXT NOT NULL,
2433
+ created_at TEXT NOT NULL
2434
+ );
2435
+ CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
2436
+ ON plan_project_link_receipts(plan_id, created_at);
2437
+
2438
+ CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
2439
+ rollback_receipt_id TEXT PRIMARY KEY,
2440
+ accepted_receipt_id TEXT NOT NULL UNIQUE
2441
+ REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
2442
+ payload TEXT NOT NULL,
2443
+ created_at TEXT NOT NULL
2444
+ );
2445
+ INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2446
+ COMMIT;
2424
2447
  `
2425
2448
  ];
2426
2449
  });
@@ -5801,7 +5824,7 @@ var init_runner_sandbox = __esm(() => {
5801
5824
  });
5802
5825
 
5803
5826
  // src/lib/event-hooks.ts
5804
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
5827
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
5805
5828
  import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
5806
5829
  import { dirname as dirname3, resolve as resolve6 } from "path";
5807
5830
  import { createConnection } from "net";
@@ -5868,7 +5891,7 @@ function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
5868
5891
  payload: redactValue(payload ?? {}),
5869
5892
  source: { package: "@hasna/todos", local_only: true }
5870
5893
  };
5871
- const digest = createHash2("sha256").update(canonicalEvent(base)).digest("hex");
5894
+ const digest = createHash3("sha256").update(canonicalEvent(base)).digest("hex");
5872
5895
  return { ...base, integrity: { algorithm: "sha256", digest } };
5873
5896
  }
5874
5897
  function summarize(value) {
@@ -6755,6 +6778,40 @@ function deleteTaskList(id, db) {
6755
6778
  return d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6756
6779
  })();
6757
6780
  }
6781
+ function deleteTaskListIfUnchangedAndUnused(id, expected, db) {
6782
+ const d = db || getDatabase();
6783
+ return d.transaction(() => {
6784
+ const current = getTaskList(id, d);
6785
+ if (!current) {
6786
+ return { status: "not_found", task_dependents: 0, plan_dependents: 0 };
6787
+ }
6788
+ const changed = current.project_id !== expected.project_id || current.slug !== expected.slug || current.name !== expected.name || current.description !== expected.description || current.updated_at !== expected.updated_at || JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata);
6789
+ if (changed) {
6790
+ return { status: "changed", task_dependents: 0, plan_dependents: 0 };
6791
+ }
6792
+ const taskDependents = Number(d.query("SELECT COUNT(*) AS count FROM tasks WHERE task_list_id = ?").get(id).count);
6793
+ const planDependents = Number(d.query("SELECT COUNT(*) AS count FROM plans WHERE task_list_id = ?").get(id).count);
6794
+ if (taskDependents > 0 || planDependents > 0) {
6795
+ return {
6796
+ status: "has_dependents",
6797
+ task_dependents: taskDependents,
6798
+ plan_dependents: planDependents
6799
+ };
6800
+ }
6801
+ recordStorageTombstone({
6802
+ object_type: "task_lists",
6803
+ object_id: id,
6804
+ payload: current
6805
+ }, d);
6806
+ releaseCanonicalSlugClaims("task_list", id, d);
6807
+ const deleted = d.run("DELETE FROM task_lists WHERE id = ?", [id]).changes > 0;
6808
+ return {
6809
+ status: deleted ? "deleted" : "not_found",
6810
+ task_dependents: 0,
6811
+ plan_dependents: 0
6812
+ };
6813
+ })();
6814
+ }
6758
6815
  function ensureTaskList(name, slug, projectId, db) {
6759
6816
  const d = db || getDatabase();
6760
6817
  const existing = getTaskListBySlug(slug, projectId, d);
@@ -7782,6 +7839,14 @@ var init_checklists = __esm(() => {
7782
7839
  init_database();
7783
7840
  });
7784
7841
 
7842
+ // src/db/plan-row-serialization.ts
7843
+ function guardPlanRowsSqlite(planIds, db) {
7844
+ const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
7845
+ for (const id of ids) {
7846
+ db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
7847
+ }
7848
+ }
7849
+
7785
7850
  // src/lib/recurrence.ts
7786
7851
  function parseRecurrenceRule(rule) {
7787
7852
  const normalized = rule.trim().toLowerCase();
@@ -9100,9 +9165,23 @@ function sanitizeUpdateTaskInput(input) {
9100
9165
  metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
9101
9166
  };
9102
9167
  }
9103
- function createTask2(input, db) {
9104
- input = sanitizeCreateTaskInput(input);
9105
- const d = db || getDatabase();
9168
+ function linkedPlanProjectId(planId, db) {
9169
+ if (!planId)
9170
+ return null;
9171
+ const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
9172
+ return row?.project_id ?? null;
9173
+ }
9174
+ function resolveCreateProjectForPlan(input, db) {
9175
+ const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
9176
+ if (!linkedProjectId)
9177
+ return input.project_id || null;
9178
+ if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
9179
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
9180
+ }
9181
+ return linkedProjectId;
9182
+ }
9183
+ function createTaskStored(input, d) {
9184
+ const effectiveProjectId = resolveCreateProjectForPlan(input, d);
9106
9185
  const timestamp2 = now();
9107
9186
  const tags = input.tags || [];
9108
9187
  const machineId = currentStorageMachineId(d);
@@ -9116,7 +9195,7 @@ function createTask2(input, db) {
9116
9195
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
9117
9196
  id,
9118
9197
  null,
9119
- input.project_id || null,
9198
+ effectiveProjectId,
9120
9199
  input.parent_id || null,
9121
9200
  input.plan_id || null,
9122
9201
  input.task_list_id || null,
@@ -9166,7 +9245,15 @@ function createTask2(input, db) {
9166
9245
  if (tags.length > 0) {
9167
9246
  insertTaskTags(id, tags, d);
9168
9247
  }
9169
- const task = getTask(id, d);
9248
+ return getTask(id, d);
9249
+ }
9250
+ function createTask2(input, db) {
9251
+ input = sanitizeCreateTaskInput(input);
9252
+ const d = db || getDatabase();
9253
+ const task = input.plan_id ? d.transaction(() => {
9254
+ guardPlanRowsSqlite([input.plan_id], d);
9255
+ return createTaskStored(input, d);
9256
+ })() : createTaskStored(input, d);
9170
9257
  const payload = taskEventData(task);
9171
9258
  const databasePath = databasePathFromDatabase(d);
9172
9259
  dispatchWebhook2("task.created", payload, d).catch(() => {});
@@ -9266,6 +9353,10 @@ function listTasks2(filter = {}, db) {
9266
9353
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
9267
9354
  params.push(filter.not_created_by);
9268
9355
  }
9356
+ if (filter.updated_after) {
9357
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
9358
+ params.push(filter.updated_after);
9359
+ }
9269
9360
  if (filter.session_id) {
9270
9361
  conditions.push("session_id = ?");
9271
9362
  params.push(filter.session_id);
@@ -9426,6 +9517,10 @@ function countTasks(filter = {}, db) {
9426
9517
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
9427
9518
  params.push(filter.not_created_by);
9428
9519
  }
9520
+ if (filter.updated_after) {
9521
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
9522
+ params.push(filter.updated_after);
9523
+ }
9429
9524
  if (filter.session_id) {
9430
9525
  conditions.push("session_id = ?");
9431
9526
  params.push(filter.session_id);
@@ -9465,7 +9560,7 @@ function countTasks(filter = {}, db) {
9465
9560
  const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
9466
9561
  return row.count;
9467
9562
  }
9468
- function updateTask2(id, input, db) {
9563
+ function updateTaskStored(id, input, db) {
9469
9564
  const d = db || getDatabase();
9470
9565
  const task = getTask(id, d);
9471
9566
  if (!task)
@@ -9474,6 +9569,18 @@ function updateTask2(id, input, db) {
9474
9569
  throw new VersionConflictError(id, input.version, task.version);
9475
9570
  }
9476
9571
  input = sanitizeUpdateTaskInput(input);
9572
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
9573
+ const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
9574
+ if (linkedProjectId) {
9575
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
9576
+ if (effectiveProjectId !== linkedProjectId) {
9577
+ if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
9578
+ input = { ...input, project_id: linkedProjectId };
9579
+ } else {
9580
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
9581
+ }
9582
+ }
9583
+ }
9477
9584
  const timestamp2 = now();
9478
9585
  const completionTimestamp = input.completed_at ?? timestamp2;
9479
9586
  const sets = ["version = version + 1", "updated_at = ?"];
@@ -9681,6 +9788,21 @@ function updateTask2(id, input, db) {
9681
9788
  emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
9682
9789
  return updatedTask;
9683
9790
  }
9791
+ function updateTask2(id, input, db) {
9792
+ const d = db || getDatabase();
9793
+ const before = getTask(id, d);
9794
+ if (!before)
9795
+ throw new TaskNotFoundError(id);
9796
+ const guardedPlanIds = [before.plan_id, input.plan_id];
9797
+ if (!guardedPlanIds.some(Boolean))
9798
+ return updateTaskStored(id, input, d);
9799
+ return d.transaction(() => {
9800
+ guardPlanRowsSqlite(guardedPlanIds, d);
9801
+ const current = getTask(id, d);
9802
+ guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
9803
+ return updateTaskStored(id, input, d);
9804
+ })();
9805
+ }
9684
9806
  function deleteTask(id, db) {
9685
9807
  const d = db || getDatabase();
9686
9808
  const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
@@ -10346,7 +10468,7 @@ function listPlans(projectId, db) {
10346
10468
  }
10347
10469
  return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
10348
10470
  }
10349
- function updatePlan2(id, input, db) {
10471
+ function updatePlanStored(id, input, db) {
10350
10472
  const d = db || getDatabase();
10351
10473
  const plan = getPlan(id, d);
10352
10474
  if (!plan)
@@ -10391,6 +10513,13 @@ function updatePlan2(id, input, db) {
10391
10513
  });
10392
10514
  return updated;
10393
10515
  }
10516
+ function updatePlan2(id, input, db) {
10517
+ const d = db || getDatabase();
10518
+ return d.transaction(() => {
10519
+ guardPlanRowsSqlite([id], d);
10520
+ return updatePlanStored(id, input, d);
10521
+ })();
10522
+ }
10394
10523
  function deletePlan(id, db) {
10395
10524
  const d = db || getDatabase();
10396
10525
  const plan = getPlan(id, d);
@@ -10815,7 +10944,7 @@ var init_boards = __esm(() => {
10815
10944
  });
10816
10945
 
10817
10946
  // src/lib/artifact-store.ts
10818
- import { createHash as createHash3 } from "crypto";
10947
+ import { createHash as createHash4 } from "crypto";
10819
10948
  import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
10820
10949
  import { basename, dirname as dirname4, join as join5, resolve as resolve7 } from "path";
10821
10950
  import { tmpdir as tmpdir2 } from "os";
@@ -10840,7 +10969,7 @@ function artifactStorePath(relativePath) {
10840
10969
  return join5(artifactStoreRoot(), normalized);
10841
10970
  }
10842
10971
  function sha256(buffer) {
10843
- return createHash3("sha256").update(buffer).digest("hex");
10972
+ return createHash4("sha256").update(buffer).digest("hex");
10844
10973
  }
10845
10974
  function isTextLike(buffer, path) {
10846
10975
  if (buffer.includes(0))
@@ -12249,11 +12378,11 @@ var init_tasks = __esm(() => {
12249
12378
  });
12250
12379
 
12251
12380
  // src/project-registration/authority.ts
12252
- import { createHash as createHash4 } from "crypto";
12381
+ import { createHash as createHash5 } from "crypto";
12253
12382
  // package.json
12254
12383
  var package_default = {
12255
12384
  name: "@hasna/todos",
12256
- version: "0.15.7",
12385
+ version: "0.15.10",
12257
12386
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12258
12387
  type: "module",
12259
12388
  main: "dist/index.js",
@@ -12295,6 +12424,10 @@ var package_default = {
12295
12424
  "./project-registration": {
12296
12425
  types: "./dist/project-registration.d.ts",
12297
12426
  import: "./dist/project-registration.js"
12427
+ },
12428
+ "./task-manifest": {
12429
+ types: "./dist/task-manifest.d.ts",
12430
+ import: "./dist/task-manifest.js"
12298
12431
  }
12299
12432
  },
12300
12433
  workspaces: [
@@ -12307,8 +12440,8 @@ var package_default = {
12307
12440
  "README.md"
12308
12441
  ],
12309
12442
  scripts: {
12310
- build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
12311
- "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
12443
+ build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
12444
+ "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
12312
12445
  migrate: "bun run src/server/index.ts migrate",
12313
12446
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
12314
12447
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -12391,6 +12524,61 @@ function normalizeAgentNameInput(name) {
12391
12524
  // src/storage/postgres-adapter.ts
12392
12525
  init_creator_identity();
12393
12526
 
12527
+ // src/lib/plan-project-link-contract.ts
12528
+ import { createHash as createHash2 } from "crypto";
12529
+ var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1";
12530
+
12531
+ class PlanProjectLinkError extends Error {
12532
+ code;
12533
+ details;
12534
+ constructor(code, message, details = {}) {
12535
+ super(message);
12536
+ this.code = code;
12537
+ this.details = details;
12538
+ this.name = "PlanProjectLinkError";
12539
+ }
12540
+ }
12541
+ function canonicalPlanProjectLinkJson(value) {
12542
+ if (value === null || typeof value !== "object")
12543
+ return JSON.stringify(value);
12544
+ if (Array.isArray(value))
12545
+ return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
12546
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
12547
+ }
12548
+ function planProjectLinkDigest(value) {
12549
+ return createHash2("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
12550
+ }
12551
+ function normalizePlanProjectLinkIdempotencyKey(value) {
12552
+ const key = value?.trim() ?? "";
12553
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
12554
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
12555
+ }
12556
+ return key;
12557
+ }
12558
+ function planProjectLinkReceiptId(idempotencyKey) {
12559
+ return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
12560
+ }
12561
+ function planProjectLinkRollbackReceiptId(receiptId) {
12562
+ return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
12563
+ }
12564
+ function planProjectLinkRequestHash(planId, projectId) {
12565
+ return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
12566
+ }
12567
+ function planProjectLinkResultDigest(plan, tasks) {
12568
+ return planProjectLinkDigest({
12569
+ plan_id: plan.id,
12570
+ plan_project_id: plan.project_id,
12571
+ tasks: tasks.map((task) => ({ id: task.id, plan_id: task.plan_id, project_id: task.project_id })).sort((left, right) => left.id.localeCompare(right.id))
12572
+ });
12573
+ }
12574
+ function assertPlanProjectLinkReceipt(value) {
12575
+ const receipt = value;
12576
+ if (!receipt || typeof receipt !== "object" || receipt.schema_version !== PLAN_PROJECT_LINK_SCHEMA_VERSION || typeof receipt.receipt_id !== "string" || typeof receipt.idempotency_key !== "string" || typeof receipt.plan_id !== "string" || typeof receipt.project_id !== "string" || !Array.isArray(receipt.task_ids) || receipt.task_ids.some((id) => typeof id !== "string") || !receipt.prior_task_project_ids || typeof receipt.prior_task_project_ids !== "object" || typeof receipt.result_digest !== "string") {
12577
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
12578
+ }
12579
+ return receipt;
12580
+ }
12581
+
12394
12582
  // src/storage/postgres-sync.ts
12395
12583
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
12396
12584
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -12419,6 +12607,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
12419
12607
  RETURNS text
12420
12608
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
12421
12609
  AS $$ SELECT unaccent('unaccent', $1) $$`,
12610
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
12611
+ RETURNS timestamptz
12612
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
12613
+ SET DateStyle TO 'ISO, YMD'
12614
+ AS $$
12615
+ BEGIN
12616
+ RETURN CASE
12617
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
12618
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
12619
+ END;
12620
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
12621
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
12622
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
12623
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
12624
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
12422
12625
  `ALTER TABLE ${tableName}
12423
12626
  ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
12424
12627
  GENERATED ALWAYS AS (
@@ -13085,6 +13288,12 @@ function createPostgresTodosStorageAdapter(options) {
13085
13288
  update: (id, input) => updatePlan(id, input, store),
13086
13289
  delete: (id, context) => store.deletePlan(id, context)
13087
13290
  },
13291
+ planProjectLinks: {
13292
+ apply: (input, context) => store.applyPlanProjectLink(input, context),
13293
+ rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
13294
+ getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
13295
+ getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
13296
+ },
13088
13297
  agents: {
13089
13298
  register: (input, context) => registerAgent(input, store, context),
13090
13299
  get: (id) => store.get("agents", id),
@@ -13267,6 +13476,9 @@ class PostgresJsonRecordStore {
13267
13476
  conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
13268
13477
  if (filter.not_created_by !== undefined)
13269
13478
  conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
13479
+ if (filter.updated_after !== undefined) {
13480
+ conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
13481
+ }
13270
13482
  if (filter.session_id !== undefined)
13271
13483
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
13272
13484
  if (filter.tags?.length) {
@@ -13451,6 +13663,115 @@ class PostgresJsonRecordStore {
13451
13663
  }
13452
13664
  return value;
13453
13665
  }
13666
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
13667
+ const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
13668
+ if (planIds.length === 0)
13669
+ return this.upsert("tasks", value, context);
13670
+ await this.ensureSchema();
13671
+ const updatedAt = value.updated_at;
13672
+ const targetPlanId = value.plan_id;
13673
+ const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
13674
+ locked_plans AS MATERIALIZED (
13675
+ SELECT object_id, payload FROM ${this.tableName}
13676
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
13677
+ AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
13678
+ ORDER BY object_id
13679
+ FOR UPDATE
13680
+ ), validation AS (
13681
+ SELECT
13682
+ (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
13683
+ ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
13684
+ (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
13685
+ ), guarded AS (
13686
+ SELECT
13687
+ validation.*,
13688
+ ($9::boolean AND validation.target_project_id IS NOT NULL
13689
+ AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
13690
+ CASE
13691
+ WHEN validation.target_project_id IS NULL THEN $3::jsonb
13692
+ ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
13693
+ END AS payload
13694
+ FROM validation
13695
+ ), stored AS (
13696
+ INSERT INTO ${this.tableName} (
13697
+ service, object_type, object_id, payload, updated_at,
13698
+ deleted_at, source_machine_id, version
13699
+ )
13700
+ SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
13701
+ FROM guarded
13702
+ WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
13703
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
13704
+ payload = EXCLUDED.payload,
13705
+ updated_at = EXCLUDED.updated_at,
13706
+ deleted_at = NULL,
13707
+ source_machine_id = EXCLUDED.source_machine_id,
13708
+ version = EXCLUDED.version
13709
+ WHERE ${this.tableName}.updated_at IS NULL
13710
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
13711
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
13712
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
13713
+ RETURNING payload
13714
+ )
13715
+ SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
13716
+ (SELECT payload FROM stored) AS payload
13717
+ FROM guarded`, [
13718
+ this.service,
13719
+ value.id,
13720
+ jsonbParam(value),
13721
+ updatedAt,
13722
+ context.requestId ?? this.sourceMachineId ?? null,
13723
+ numberValue2(value.version),
13724
+ jsonbParam(planIds),
13725
+ targetPlanId,
13726
+ explicitProject
13727
+ ]);
13728
+ const row = result.rows[0];
13729
+ if (!row?.all_plans_found || !row.target_plan_found) {
13730
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan membership changed through a missing plan: ${targetPlanId ?? planIds.join(", ")}`, { plan_ids: planIds, target_plan_id: targetPlanId });
13731
+ }
13732
+ if (row.project_conflict) {
13733
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
13734
+ }
13735
+ if (!row.payload) {
13736
+ return await requireRecord("tasks", value.id, this);
13737
+ }
13738
+ return payloadRecord2(row.payload);
13739
+ }
13740
+ async updatePlanWithProjectLinkGuard(value, context = {}) {
13741
+ await this.ensureSchema();
13742
+ const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
13743
+ SELECT payload FROM ${this.tableName}
13744
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
13745
+ FOR UPDATE
13746
+ ), stored AS (
13747
+ UPDATE ${this.tableName} r SET
13748
+ payload = jsonb_set(
13749
+ $3::jsonb,
13750
+ '{project_id}',
13751
+ COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
13752
+ true
13753
+ ),
13754
+ updated_at = $4::timestamptz,
13755
+ deleted_at = NULL,
13756
+ source_machine_id = COALESCE($5, r.source_machine_id),
13757
+ version = COALESCE(r.version, 0) + 1
13758
+ FROM locked_plan
13759
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
13760
+ RETURNING r.payload
13761
+ )
13762
+ SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
13763
+ (SELECT payload FROM stored) AS payload`, [
13764
+ this.service,
13765
+ value.id,
13766
+ jsonbParam(value),
13767
+ value.updated_at,
13768
+ context.requestId ?? this.sourceMachineId ?? null
13769
+ ]);
13770
+ const row = result.rows[0];
13771
+ if (!row?.plan_found || !row.payload)
13772
+ throw new PlanNotFoundError(value.id);
13773
+ return payloadRecord2(row.payload);
13774
+ }
13454
13775
  async createTemplateWithTasks(template, tasks, context = {}) {
13455
13776
  await this.ensureSchema();
13456
13777
  const records = [
@@ -13689,6 +14010,333 @@ class PostgresJsonRecordStore {
13689
14010
  version: numberValue2(existing["version"])
13690
14011
  }, context);
13691
14012
  }
14013
+ async getPlanProjectLinkReceipt(receiptId) {
14014
+ const value = await this.get("plan_project_link_receipts", receiptId);
14015
+ return value ? assertPlanProjectLinkReceipt(value) : null;
14016
+ }
14017
+ async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
14018
+ await this.ensureSchema();
14019
+ const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
14020
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
14021
+ AND payload->>'idempotency_key' = $2
14022
+ LIMIT 2`, [this.service, idempotencyKey]);
14023
+ if (result.rows.length > 1) {
14024
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
14025
+ }
14026
+ return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
14027
+ }
14028
+ async currentPlanProjectLinkResult(receipt, action) {
14029
+ const [plan, project, tasks] = await Promise.all([
14030
+ this.get("plans", receipt.plan_id),
14031
+ this.get("projects", receipt.project_id),
14032
+ this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
14033
+ ]);
14034
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14035
+ if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
14036
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
14037
+ }
14038
+ return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
14039
+ }
14040
+ async applyPlanProjectLink(input, context = {}) {
14041
+ await this.ensureSchema();
14042
+ const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
14043
+ if (existing) {
14044
+ if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
14045
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key, receipt_id: existing.receipt_id });
14046
+ }
14047
+ const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
14048
+ if (rolledBack) {
14049
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
14050
+ }
14051
+ return this.currentPlanProjectLinkResult(existing, "already_linked");
14052
+ }
14053
+ const [plan, project, tasks, scopedPlans] = await Promise.all([
14054
+ this.get("plans", input.plan_id),
14055
+ this.get("projects", input.project_id),
14056
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
14057
+ this.list("plans")
14058
+ ]);
14059
+ if (!plan)
14060
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
14061
+ if (!project)
14062
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
14063
+ if (plan.updated_at !== input.expected_plan_revision) {
14064
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
14065
+ }
14066
+ if (project.updated_at !== input.expected_project_revision) {
14067
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
14068
+ }
14069
+ const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
14070
+ if (collision) {
14071
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
14072
+ }
14073
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14074
+ const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
14075
+ const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
14076
+ const projectedTasks = sortedTasks.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
14077
+ const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
14078
+ const receipt = {
14079
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
14080
+ receipt_id: input.receipt_id,
14081
+ idempotency_key: input.idempotency_key,
14082
+ plan_id: plan.id,
14083
+ project_id: project.id,
14084
+ prior_plan_project_id: plan.project_id,
14085
+ prior_task_project_ids: priorTaskProjectIds,
14086
+ task_ids: sortedTasks.map((task) => task.id),
14087
+ task_count: sortedTasks.length,
14088
+ result_plan_revision: projectedPlan.updated_at,
14089
+ result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
14090
+ rollback_supported: true,
14091
+ created_at: input.created_at
14092
+ };
14093
+ let mutation;
14094
+ try {
14095
+ mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
14096
+ target_plan AS MATERIALIZED (
14097
+ SELECT payload FROM ${this.tableName}
14098
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
14099
+ FOR UPDATE
14100
+ ), target_project AS (
14101
+ SELECT payload FROM ${this.tableName}
14102
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
14103
+ FOR UPDATE
14104
+ ), member_tasks AS MATERIALIZED (
14105
+ SELECT object_id, payload FROM ${this.tableName}
14106
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
14107
+ AND payload->>'plan_id' = $2
14108
+ AND EXISTS (SELECT 1 FROM target_plan)
14109
+ FOR UPDATE
14110
+ ), existing AS (
14111
+ SELECT payload FROM ${this.tableName}
14112
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts'
14113
+ AND object_id = $6 AND deleted_at IS NULL
14114
+ FOR UPDATE
14115
+ ), collision AS (
14116
+ SELECT 1 FROM ${this.tableName} r, target_plan p
14117
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
14118
+ AND r.object_id <> $2 AND r.payload->>'project_id' = $3
14119
+ AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
14120
+ AND p.payload->>'slug' IS NOT NULL
14121
+ LIMIT 1
14122
+ ), checks AS (
14123
+ SELECT
14124
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
14125
+ EXISTS (SELECT 1 FROM target_project) AS project_found,
14126
+ COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
14127
+ COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
14128
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
14129
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
14130
+ EXISTS (SELECT 1 FROM collision) AS collision,
14131
+ EXISTS (SELECT 1 FROM existing) AS has_existing
14132
+ ), updated_plan AS (
14133
+ UPDATE ${this.tableName} r SET
14134
+ payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
14135
+ updated_at = $10::timestamptz,
14136
+ version = COALESCE(r.version, 0) + 1,
14137
+ source_machine_id = COALESCE($11, r.source_machine_id)
14138
+ FROM checks
14139
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
14140
+ AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
14141
+ AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
14142
+ RETURNING r.payload
14143
+ ), updated_tasks AS (
14144
+ UPDATE ${this.tableName} r SET
14145
+ payload = r.payload || jsonb_build_object(
14146
+ 'project_id', $3::text,
14147
+ 'updated_at', $10::text,
14148
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
14149
+ ),
14150
+ updated_at = $10::timestamptz,
14151
+ version = COALESCE(r.version, 0) + 1,
14152
+ source_machine_id = COALESCE($11, r.source_machine_id)
14153
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
14154
+ AND r.payload->>'plan_id' = $2
14155
+ AND r.payload->>'project_id' IS DISTINCT FROM $3
14156
+ AND EXISTS (SELECT 1 FROM updated_plan)
14157
+ RETURNING 1
14158
+ ), task_gate AS (
14159
+ SELECT count(*) AS count FROM updated_tasks
14160
+ ), inserted AS (
14161
+ INSERT INTO ${this.tableName}
14162
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
14163
+ SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
14164
+ FROM checks, task_gate
14165
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
14166
+ RETURNING payload
14167
+ ) SELECT
14168
+ checks.plan_found,
14169
+ checks.project_found,
14170
+ checks.plan_revision_ok,
14171
+ checks.project_revision_ok,
14172
+ checks.membership_ok,
14173
+ checks.collision,
14174
+ (SELECT payload FROM existing) AS existing_receipt,
14175
+ (SELECT payload FROM inserted) AS inserted_receipt
14176
+ FROM checks`, [
14177
+ this.service,
14178
+ plan.id,
14179
+ project.id,
14180
+ input.expected_plan_revision,
14181
+ input.expected_project_revision,
14182
+ receipt.receipt_id,
14183
+ jsonbParam(receipt),
14184
+ jsonbParam(priorTaskProjectIds),
14185
+ jsonbParam(receipt.task_ids),
14186
+ input.created_at,
14187
+ this.machineId(context)
14188
+ ]);
14189
+ } catch (error) {
14190
+ if (isPostgresUniqueViolation(error)) {
14191
+ const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
14192
+ if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
14193
+ return this.currentPlanProjectLinkResult(raced, "already_linked");
14194
+ }
14195
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
14196
+ }
14197
+ throw error;
14198
+ }
14199
+ const row = mutation.rows[0];
14200
+ if (!row?.plan_found)
14201
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
14202
+ if (!row.project_found)
14203
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
14204
+ if (!row.plan_revision_ok)
14205
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
14206
+ if (!row.project_revision_ok)
14207
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
14208
+ if (!row.membership_ok)
14209
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
14210
+ if (row.collision)
14211
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
14212
+ const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
14213
+ if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
14214
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
14215
+ }
14216
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
14217
+ }
14218
+ async rollbackPlanProjectLink(input, context = {}) {
14219
+ await this.ensureSchema();
14220
+ const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
14221
+ if (existingRollback)
14222
+ return existingRollback;
14223
+ const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
14224
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
14225
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
14226
+ }
14227
+ const [plan, tasks] = await Promise.all([
14228
+ this.get("plans", input.plan_id),
14229
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
14230
+ ]);
14231
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14232
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
14233
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
14234
+ }
14235
+ if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
14236
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
14237
+ }
14238
+ const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
14239
+ const projectedTasks = sortedTasks.map((task) => ({
14240
+ ...task,
14241
+ project_id: receipt.prior_task_project_ids[task.id] ?? null,
14242
+ updated_at: input.restored_at,
14243
+ version: task.version + 1
14244
+ }));
14245
+ const rollback = {
14246
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
14247
+ action: "restored",
14248
+ plan: projectedPlan,
14249
+ tasks: projectedTasks,
14250
+ accepted_receipt_id: receipt.receipt_id,
14251
+ rollback_receipt_id: input.rollback_receipt_id,
14252
+ restored_at: input.restored_at
14253
+ };
14254
+ const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
14255
+ const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
14256
+ target_plan AS MATERIALIZED (
14257
+ SELECT payload FROM ${this.tableName}
14258
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
14259
+ FOR UPDATE
14260
+ ), member_tasks AS MATERIALIZED (
14261
+ SELECT object_id, payload FROM ${this.tableName}
14262
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
14263
+ AND payload->>'plan_id' = $2
14264
+ AND EXISTS (SELECT 1 FROM target_plan)
14265
+ FOR UPDATE
14266
+ ), existing AS (
14267
+ SELECT payload FROM ${this.tableName}
14268
+ WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
14269
+ AND object_id = $5 AND deleted_at IS NULL
14270
+ FOR UPDATE
14271
+ ), checks AS (
14272
+ SELECT
14273
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
14274
+ COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
14275
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
14276
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
14277
+ EXISTS (SELECT 1 FROM existing) AS has_existing
14278
+ ), updated_plan AS (
14279
+ UPDATE ${this.tableName} r SET
14280
+ payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
14281
+ updated_at = $10::timestamptz,
14282
+ version = COALESCE(r.version, 0) + 1,
14283
+ source_machine_id = COALESCE($11, r.source_machine_id)
14284
+ FROM checks
14285
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
14286
+ AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
14287
+ RETURNING 1
14288
+ ), updated_tasks AS (
14289
+ UPDATE ${this.tableName} r SET
14290
+ payload = r.payload || jsonb_build_object(
14291
+ 'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
14292
+ 'updated_at', $10::text,
14293
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
14294
+ ),
14295
+ updated_at = $10::timestamptz,
14296
+ version = COALESCE(r.version, 0) + 1,
14297
+ source_machine_id = COALESCE($11, r.source_machine_id)
14298
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
14299
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
14300
+ RETURNING 1
14301
+ ), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
14302
+ INSERT INTO ${this.tableName}
14303
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
14304
+ SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
14305
+ FROM checks, task_gate
14306
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
14307
+ RETURNING payload
14308
+ ) SELECT
14309
+ checks.plan_found,
14310
+ checks.plan_revision_ok,
14311
+ checks.membership_ok,
14312
+ (SELECT payload FROM existing) AS existing_rollback,
14313
+ (SELECT payload FROM inserted) AS inserted_rollback
14314
+ FROM checks`, [
14315
+ this.service,
14316
+ input.plan_id,
14317
+ input.expected_plan_revision,
14318
+ input.receipt_id,
14319
+ input.rollback_receipt_id,
14320
+ jsonbParam(rollback),
14321
+ jsonbParam(currentTaskProjects),
14322
+ jsonbParam(receipt.task_ids),
14323
+ jsonbParam(receipt.prior_plan_project_id),
14324
+ input.restored_at,
14325
+ this.machineId(context),
14326
+ jsonbParam(receipt.prior_task_project_ids)
14327
+ ]);
14328
+ const row = result.rows[0];
14329
+ if (!row?.plan_found)
14330
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
14331
+ if (!row.plan_revision_ok)
14332
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
14333
+ if (!row.membership_ok)
14334
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
14335
+ const accepted = row.existing_rollback ?? row.inserted_rollback;
14336
+ if (!accepted)
14337
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
14338
+ return accepted;
14339
+ }
13692
14340
  async deletePlan(id, context = {}) {
13693
14341
  await this.ensureSchema();
13694
14342
  const timestamp = new Date().toISOString();
@@ -13783,11 +14431,17 @@ class PostgresJsonRecordStore {
13783
14431
  }
13784
14432
  async function createTask(input, store, context) {
13785
14433
  const timestamp = new Date().toISOString();
13786
- const shortId = input.project_id ? await nextTaskShortId(input.project_id, store, context) : null;
14434
+ const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
14435
+ const requestedProjectId = input.project_id ?? context?.projectId ?? null;
14436
+ if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
14437
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
14438
+ }
14439
+ const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
14440
+ const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
13787
14441
  const task = {
13788
14442
  id: randomUUID(),
13789
14443
  short_id: shortId,
13790
- project_id: input.project_id ?? context?.projectId ?? null,
14444
+ project_id: effectiveProjectId,
13791
14445
  parent_id: input.parent_id ?? null,
13792
14446
  plan_id: input.plan_id ?? null,
13793
14447
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -13841,15 +14495,27 @@ async function createTask(input, store, context) {
13841
14495
  synced_at: null,
13842
14496
  archived_at: null
13843
14497
  };
13844
- await store.upsert("tasks", task, context);
13845
- await logTaskChange(task.id, "created", "status", null, task.status, task.assigned_by ?? task.agent_id, store, context);
13846
- return task;
14498
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
14499
+ await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
14500
+ return storedTask;
13847
14501
  }
13848
14502
  async function updateTask(id, input, store) {
13849
14503
  const existing = await requireRecord("tasks", id, store);
13850
14504
  if (existing.version !== input.version) {
13851
14505
  throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
13852
14506
  }
14507
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
14508
+ const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
14509
+ if (linkedPlan?.project_id) {
14510
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
14511
+ if (effectiveProjectId !== linkedPlan.project_id) {
14512
+ if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
14513
+ input = { ...input, project_id: linkedPlan.project_id };
14514
+ } else {
14515
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
14516
+ }
14517
+ }
14518
+ }
13853
14519
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
13854
14520
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
13855
14521
  const task = {
@@ -13865,8 +14531,7 @@ async function updateTask(id, input, store) {
13865
14531
  created_by: existing.created_by,
13866
14532
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
13867
14533
  };
13868
- await store.upsert("tasks", task);
13869
- return task;
14534
+ return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
13870
14535
  }
13871
14536
  async function startTask(id, agentId, store) {
13872
14537
  const task = await requireRecord("tasks", id, store);
@@ -13930,8 +14595,7 @@ async function patchTask(task, patch, store) {
13930
14595
  version: task.version + 1,
13931
14596
  updated_at: new Date().toISOString()
13932
14597
  };
13933
- await store.upsert("tasks", updated);
13934
- return updated;
14598
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
13935
14599
  }
13936
14600
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
13937
14601
  function sameCloudLockHolder(stored, incoming) {
@@ -14196,7 +14860,12 @@ async function updatePlan(id, input, store) {
14196
14860
  excludeId: id
14197
14861
  });
14198
14862
  }
14199
- return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
14863
+ return store.updatePlanWithProjectLinkGuard({
14864
+ ...plan,
14865
+ ...patch,
14866
+ project_id: plan.project_id,
14867
+ updated_at: new Date().toISOString()
14868
+ });
14200
14869
  }
14201
14870
  function matchAgentByName(agents, name, options) {
14202
14871
  const target = normalizeAgentNameInput(name);
@@ -14975,6 +15644,10 @@ class PostgresTodosProjectRegistrationBackend {
14975
15644
  }
14976
15645
  }
14977
15646
 
15647
+ // src/project-registration/sqlite.ts
15648
+ init_database();
15649
+ init_storage_tombstones();
15650
+
14978
15651
  // src/storage/local-sqlite.ts
14979
15652
  init_types();
14980
15653
 
@@ -15812,6 +16485,184 @@ function scanSqliteIntegrity(db = getDatabase()) {
15812
16485
  return buildIntegrityReport(conditions, now());
15813
16486
  }
15814
16487
 
16488
+ // src/db/plan-project-links.ts
16489
+ init_database();
16490
+ init_plans();
16491
+ init_projects();
16492
+ init_tasks();
16493
+ function getPlanProjectLinkReceipt(receiptId, db) {
16494
+ const d = db || getDatabase();
16495
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
16496
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
16497
+ }
16498
+ function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
16499
+ const d = db || getDatabase();
16500
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
16501
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
16502
+ }
16503
+ function exactTasks(planId, db) {
16504
+ return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
16505
+ }
16506
+ function currentResult(planId, projectId, receipt, db, action) {
16507
+ const plan = getPlan(planId, db);
16508
+ const project = getProject(projectId, db);
16509
+ if (!plan || !project) {
16510
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "An accepted plan-project-link target no longer resolves", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
16511
+ }
16512
+ const tasks = exactTasks(planId, db);
16513
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
16514
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { plan_id: planId, project_id: projectId, receipt_id: receipt.receipt_id });
16515
+ }
16516
+ return { mode: "apply", action, plan, project, tasks, receipt };
16517
+ }
16518
+ function applyPlanProjectLinkSqlite(input, db) {
16519
+ const d = db || getDatabase();
16520
+ const mutate = d.transaction(() => {
16521
+ guardPlanRowsSqlite([input.plan_id], d);
16522
+ const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
16523
+ const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
16524
+ if (existingRow) {
16525
+ if (existingRow.payload_hash !== requestHash) {
16526
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was already accepted for a different plan-project link", { idempotency_key: input.idempotency_key });
16527
+ }
16528
+ const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
16529
+ if (rolledBack) {
16530
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
16531
+ }
16532
+ return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
16533
+ }
16534
+ const plan = getPlan(input.plan_id, d);
16535
+ if (!plan) {
16536
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
16537
+ }
16538
+ const project = getProject(input.project_id, d);
16539
+ if (!project) {
16540
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
16541
+ }
16542
+ if (plan.updated_at !== input.expected_plan_revision) {
16543
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the link plan; fetch a fresh plan before applying", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan.updated_at });
16544
+ }
16545
+ if (project.updated_at !== input.expected_project_revision) {
16546
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Destination project changed after the link plan; fetch a fresh plan before applying", { expected_project_revision: input.expected_project_revision, current_project_revision: project.updated_at });
16547
+ }
16548
+ if (plan.slug) {
16549
+ const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
16550
+ if (collision) {
16551
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { conflicting_plan_id: collision.id, slug: plan.slug });
16552
+ }
16553
+ }
16554
+ const beforeTasks = exactTasks(plan.id, d);
16555
+ const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
16556
+ const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
16557
+ const timestamp2 = input.created_at;
16558
+ if (plan.project_id !== project.id) {
16559
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
16560
+ project.id,
16561
+ timestamp2,
16562
+ plan.id,
16563
+ input.expected_plan_revision
16564
+ ]);
16565
+ }
16566
+ for (const task of beforeTasks) {
16567
+ if (task.project_id === project.id)
16568
+ continue;
16569
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
16570
+ }
16571
+ const linkedPlan = getPlan(plan.id, d);
16572
+ const linkedTasks = exactTasks(plan.id, d);
16573
+ if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
16574
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Atomic plan-project link readback did not preserve the exact plan membership", { plan_id: plan.id, project_id: project.id });
16575
+ }
16576
+ const receipt = {
16577
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
16578
+ receipt_id: input.receipt_id,
16579
+ idempotency_key: input.idempotency_key,
16580
+ plan_id: plan.id,
16581
+ project_id: project.id,
16582
+ prior_plan_project_id: plan.project_id,
16583
+ prior_task_project_ids: priorTaskProjectIds,
16584
+ task_ids: beforeTasks.map((task) => task.id),
16585
+ task_count: beforeTasks.length,
16586
+ result_plan_revision: linkedPlan.updated_at,
16587
+ result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
16588
+ rollback_supported: true,
16589
+ created_at: input.created_at
16590
+ };
16591
+ d.run(`INSERT INTO plan_project_link_receipts
16592
+ (receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
16593
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
16594
+ receipt.receipt_id,
16595
+ receipt.idempotency_key,
16596
+ receipt.plan_id,
16597
+ receipt.project_id,
16598
+ requestHash,
16599
+ JSON.stringify(receipt),
16600
+ receipt.created_at
16601
+ ]);
16602
+ return {
16603
+ mode: "apply",
16604
+ action: alreadyLinked ? "already_linked" : "linked",
16605
+ plan: linkedPlan,
16606
+ project,
16607
+ tasks: linkedTasks,
16608
+ receipt
16609
+ };
16610
+ });
16611
+ return mutate();
16612
+ }
16613
+ function rollbackPlanProjectLinkSqlite(input, db) {
16614
+ const d = db || getDatabase();
16615
+ return d.transaction(() => {
16616
+ guardPlanRowsSqlite([input.plan_id], d);
16617
+ const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
16618
+ if (priorRollback)
16619
+ return JSON.parse(priorRollback.payload);
16620
+ const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
16621
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
16622
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id, plan_id: input.plan_id, project_id: input.project_id });
16623
+ }
16624
+ const plan = getPlan(input.plan_id, d);
16625
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
16626
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback", { expected_plan_revision: input.expected_plan_revision, current_plan_revision: plan?.updated_at ?? null });
16627
+ }
16628
+ const tasks = exactTasks(plan.id, d);
16629
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
16630
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
16631
+ }
16632
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
16633
+ receipt.prior_plan_project_id,
16634
+ input.restored_at,
16635
+ plan.id,
16636
+ input.expected_plan_revision
16637
+ ]);
16638
+ for (const task of tasks) {
16639
+ const priorProjectId = receipt.prior_task_project_ids[task.id];
16640
+ if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
16641
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Receipt does not contain the exact prior project for every member task", { task_id: task.id, receipt_id: receipt.receipt_id });
16642
+ }
16643
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [priorProjectId, input.restored_at, task.id, plan.id]);
16644
+ }
16645
+ const restoredPlan = getPlan(plan.id, d);
16646
+ const restoredTasks = exactTasks(plan.id, d);
16647
+ if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
16648
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
16649
+ }
16650
+ const result = {
16651
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
16652
+ action: "restored",
16653
+ plan: restoredPlan,
16654
+ tasks: restoredTasks,
16655
+ accepted_receipt_id: receipt.receipt_id,
16656
+ rollback_receipt_id: input.rollback_receipt_id,
16657
+ restored_at: input.restored_at
16658
+ };
16659
+ d.run(`INSERT INTO plan_project_link_rollback_receipts
16660
+ (rollback_receipt_id, accepted_receipt_id, payload, created_at)
16661
+ VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
16662
+ return result;
16663
+ })();
16664
+ }
16665
+
15815
16666
  // src/storage/sqlite-snapshot.ts
15816
16667
  init_database();
15817
16668
  init_audit();
@@ -16315,6 +17166,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
16315
17166
  update: (id, input) => updatePlan2(id, input, database()),
16316
17167
  delete: (id) => deletePlan(id, database())
16317
17168
  },
17169
+ planProjectLinks: {
17170
+ apply: (input) => applyPlanProjectLinkSqlite(input, database()),
17171
+ rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
17172
+ getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
17173
+ getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
17174
+ },
16318
17175
  agents: {
16319
17176
  register: (input) => registerAgent2(input, database()),
16320
17177
  get: (id) => getAgent(id, database()),
@@ -16328,7 +17185,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
16328
17185
  getBySlug: (slug, projectId) => getTaskListBySlug(slug, projectId, database()),
16329
17186
  list: (projectId) => listTaskLists(projectId, database()),
16330
17187
  update: (id, input) => updateTaskList2(id, input, database()),
16331
- delete: (id) => deleteTaskList(id, database())
17188
+ delete: (id) => deleteTaskList(id, database()),
17189
+ deleteIfUnchangedAndUnused: (id, expected) => deleteTaskListIfUnchangedAndUnused(id, expected, database())
16332
17190
  },
16333
17191
  templates: {
16334
17192
  create: (input) => createTemplate2(input, database()),
@@ -16384,9 +17242,75 @@ var PROJECT_REFERENCE_COLUMNS = new Set([
16384
17242
  "external_project_id"
16385
17243
  ]);
16386
17244
  var TASK_LIST_REFERENCE_COLUMNS = new Set(["task_list_id"]);
17245
+ var SQLITE_TRANSACTION_RETRY_LIMIT = 8;
17246
+
17247
+ class SqliteRegistrationOptimisticConflict extends Error {
17248
+ constructor(message, options = {}) {
17249
+ super(message, options);
17250
+ this.name = "SqliteRegistrationOptimisticConflict";
17251
+ }
17252
+ }
17253
+ function sameSqliteValue(left, right) {
17254
+ return JSON.stringify(left) === JSON.stringify(right);
17255
+ }
17256
+ function taskListFromRow(row) {
17257
+ return {
17258
+ ...row,
17259
+ metadata: JSON.parse(row.metadata || "{}")
17260
+ };
17261
+ }
17262
+ function selectProject(db, id) {
17263
+ return db.query("SELECT * FROM projects WHERE id = ? LIMIT 1").get(id);
17264
+ }
17265
+ function selectTaskList(db, id) {
17266
+ const row = db.query("SELECT * FROM task_lists WHERE id = ? LIMIT 1").get(id);
17267
+ return row ? taskListFromRow(row) : null;
17268
+ }
17269
+ function selectProjectConflict(db, path, taskListSlug) {
17270
+ return db.query(`
17271
+ SELECT * FROM projects
17272
+ WHERE path = ? OR task_list_id = ?
17273
+ ORDER BY created_at ASC, id ASC
17274
+ LIMIT 1
17275
+ `).get(path, taskListSlug);
17276
+ }
17277
+ function selectTaskListConflict(db, projectId, slug) {
17278
+ const row = db.query(`
17279
+ SELECT * FROM task_lists
17280
+ WHERE project_id = ? AND slug = ?
17281
+ LIMIT 1
17282
+ `).get(projectId, slug);
17283
+ return row ? taskListFromRow(row) : null;
17284
+ }
16387
17285
  function quoteSqliteIdentifier(value) {
16388
17286
  return `"${value.replaceAll('"', '""')}"`;
16389
17287
  }
17288
+ function hasSqliteDependents(db, resourceKind, targetId) {
17289
+ const targetTable = resourceKind === "project" ? "projects" : "task_lists";
17290
+ const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
17291
+ const tables = db.query(`
17292
+ SELECT name FROM sqlite_schema
17293
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
17294
+ ORDER BY name
17295
+ `).all();
17296
+ for (const { name: tableName } of tables) {
17297
+ const quotedTable = quoteSqliteIdentifier(tableName);
17298
+ const columns = db.query(`PRAGMA table_info(${quotedTable})`).all();
17299
+ const foreignKeys = db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
17300
+ const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
17301
+ for (const columnName of referenceColumns) {
17302
+ const row = db.query(`
17303
+ SELECT 1 AS found
17304
+ FROM ${quotedTable}
17305
+ WHERE ${quoteSqliteIdentifier(columnName)} = ?
17306
+ LIMIT 1
17307
+ `).get(targetId);
17308
+ if (row)
17309
+ return true;
17310
+ }
17311
+ }
17312
+ return false;
17313
+ }
16390
17314
  function receiptFromRow2(row) {
16391
17315
  return {
16392
17316
  ...row,
@@ -16529,30 +17453,7 @@ class SqliteTodosProjectRegistrationTransaction {
16529
17453
  }
16530
17454
  async lockCompensationWrites() {}
16531
17455
  async hasDependents(resourceKind, targetId) {
16532
- const targetTable = resourceKind === "project" ? "projects" : "task_lists";
16533
- const semanticColumns = resourceKind === "project" ? PROJECT_REFERENCE_COLUMNS : TASK_LIST_REFERENCE_COLUMNS;
16534
- const tables = this.db.query(`
16535
- SELECT name FROM sqlite_schema
16536
- WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
16537
- ORDER BY name
16538
- `).all();
16539
- for (const { name: tableName } of tables) {
16540
- const quotedTable = quoteSqliteIdentifier(tableName);
16541
- const columns = this.db.query(`PRAGMA table_info(${quotedTable})`).all();
16542
- const foreignKeys = this.db.query(`PRAGMA foreign_key_list(${quotedTable})`).all();
16543
- const referenceColumns = columns.map((column) => column.name).filter((columnName) => semanticColumns.has(columnName) || foreignKeys.some((foreignKey) => foreignKey.from === columnName && foreignKey.table === targetTable));
16544
- for (const columnName of referenceColumns) {
16545
- const row = this.db.query(`
16546
- SELECT 1 AS found
16547
- FROM ${quotedTable}
16548
- WHERE ${quoteSqliteIdentifier(columnName)} = ?
16549
- LIMIT 1
16550
- `).get(targetId);
16551
- if (row)
16552
- return true;
16553
- }
16554
- }
16555
- return false;
17456
+ return hasSqliteDependents(this.db, resourceKind, targetId);
16556
17457
  }
16557
17458
  async deleteProject(id) {
16558
17459
  return await this.storage.projects.delete(id);
@@ -16562,6 +17463,376 @@ class SqliteTodosProjectRegistrationTransaction {
16562
17463
  }
16563
17464
  }
16564
17465
 
17466
+ class StagedSqliteTodosProjectRegistrationTransaction {
17467
+ db;
17468
+ direct;
17469
+ validators = [];
17470
+ mutations = [];
17471
+ receipts = new Map;
17472
+ bindings = new Map;
17473
+ projects = new Map;
17474
+ taskLists = new Map;
17475
+ constructor(db) {
17476
+ this.db = db;
17477
+ this.direct = new SqliteTodosProjectRegistrationTransaction(db);
17478
+ }
17479
+ commit() {
17480
+ this.db.exec("BEGIN IMMEDIATE");
17481
+ try {
17482
+ for (const validate of this.validators) {
17483
+ if (!validate()) {
17484
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration input changed before SQLite commit");
17485
+ }
17486
+ }
17487
+ for (const mutate of this.mutations)
17488
+ mutate();
17489
+ this.db.exec("COMMIT");
17490
+ } catch (error) {
17491
+ try {
17492
+ this.db.exec("ROLLBACK");
17493
+ } catch {}
17494
+ throw error;
17495
+ }
17496
+ }
17497
+ async lockStep(_identity) {}
17498
+ async getReceiptForLookup(identity) {
17499
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
17500
+ const stored = await this.direct.getReceiptForLookup(identity);
17501
+ if (stored)
17502
+ staged.push(stored);
17503
+ const outcomeRank = (receipt) => receipt.outcome === "terminal_nonacceptance" ? 0 : receipt.outcome === "duplicate_of_accepted" ? 1 : 2;
17504
+ return staged.sort((left, right) => outcomeRank(left) - outcomeRank(right) || right.created_at.localeCompare(left.created_at) || right.receipt_id.localeCompare(left.receipt_id))[0] ?? null;
17505
+ }
17506
+ async getReceiptById(receiptId) {
17507
+ return this.receipts.get(receiptId) ?? this.direct.getReceiptById(receiptId);
17508
+ }
17509
+ async getAcceptedReceiptForStep(identity) {
17510
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.outcome === "accepted");
17511
+ const stored = await this.direct.getAcceptedReceiptForStep(identity);
17512
+ if (stored)
17513
+ staged.push(stored);
17514
+ return staged.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.receipt_id.localeCompare(right.receipt_id))[0] ?? null;
17515
+ }
17516
+ async insertReceipt(receipt) {
17517
+ if (this.receipts.has(receipt.receipt_id))
17518
+ return false;
17519
+ if (await this.direct.getReceiptById(receipt.receipt_id))
17520
+ return false;
17521
+ const planned = { ...receipt };
17522
+ this.receipts.set(planned.receipt_id, planned);
17523
+ this.mutations.push(() => {
17524
+ try {
17525
+ const result = this.db.query(`
17526
+ INSERT OR IGNORE INTO todos_project_registration_receipts (
17527
+ receipt_id, authority, route, package_version, authority_id, tenant_id,
17528
+ corpus_id, operation_id, step_id, resource_kind, direction,
17529
+ target_selector, idempotency_key, request_digest, precondition_digest,
17530
+ normalized_call_digest, outcome, reason, target_id, result_revision,
17531
+ result_digest, duplicate_of_receipt_id, accepted_receipt_id,
17532
+ created_by_operation, created_at
17533
+ ) VALUES (
17534
+ ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
17535
+ )
17536
+ `).run(planned.receipt_id, planned.authority, planned.route, planned.package_version, planned.authority_id, planned.tenant_id, planned.corpus_id, planned.operation_id, planned.step_id, planned.resource_kind, planned.direction, planned.target_selector, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.outcome, planned.reason, planned.target_id, planned.result_revision, planned.result_digest, planned.duplicate_of_receipt_id, planned.accepted_receipt_id, planned.created_by_operation ? 1 : 0, planned.created_at);
17537
+ if (result.changes !== 1) {
17538
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt changed before SQLite commit");
17539
+ }
17540
+ } catch (error) {
17541
+ if (error instanceof SqliteRegistrationOptimisticConflict)
17542
+ throw error;
17543
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration receipt conflicted at SQLite commit", { cause: error });
17544
+ }
17545
+ });
17546
+ return true;
17547
+ }
17548
+ async getBinding(scope, resourceKind, targetSelector) {
17549
+ const key = this.bindingKey(scope, resourceKind, targetSelector);
17550
+ return this.bindings.get(key) ?? this.direct.getBinding(scope, resourceKind, targetSelector);
17551
+ }
17552
+ async claimBinding(binding) {
17553
+ const key = this.bindingKey(binding, binding.resource_kind, binding.target_selector);
17554
+ if (this.bindings.has(key))
17555
+ return false;
17556
+ if (await this.direct.getBinding(binding, binding.resource_kind, binding.target_selector)) {
17557
+ return false;
17558
+ }
17559
+ const planned = { ...binding };
17560
+ this.bindings.set(key, planned);
17561
+ this.mutations.push(() => {
17562
+ try {
17563
+ const result = this.db.query(`
17564
+ INSERT OR IGNORE INTO todos_project_registration_bindings (
17565
+ authority_id, tenant_id, corpus_id, resource_kind, target_selector,
17566
+ operation_id, step_id, direction, idempotency_key, request_digest,
17567
+ precondition_digest, normalized_call_digest, state, target_id,
17568
+ accepted_receipt_id, result_revision, result_digest, removed_receipt_id,
17569
+ created_at, updated_at
17570
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
17571
+ `).run(planned.authority_id, planned.tenant_id, planned.corpus_id, planned.resource_kind, planned.target_selector, planned.operation_id, planned.step_id, planned.direction, planned.idempotency_key, planned.request_digest, planned.precondition_digest, planned.normalized_call_digest, planned.state, planned.target_id, planned.accepted_receipt_id, planned.result_revision, planned.result_digest, planned.removed_receipt_id, planned.created_at, planned.updated_at);
17572
+ if (result.changes !== 1) {
17573
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding changed before SQLite commit");
17574
+ }
17575
+ } catch (error) {
17576
+ if (error instanceof SqliteRegistrationOptimisticConflict)
17577
+ throw error;
17578
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding conflicted at SQLite commit", { cause: error });
17579
+ }
17580
+ });
17581
+ return true;
17582
+ }
17583
+ async setBindingAccepted(scope, resourceKind, targetSelector, update) {
17584
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
17585
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
17586
+ ...binding,
17587
+ state: "accepted",
17588
+ target_id: update.target_id,
17589
+ accepted_receipt_id: update.accepted_receipt_id,
17590
+ result_revision: update.result_revision,
17591
+ result_digest: update.result_digest,
17592
+ updated_at: update.updated_at
17593
+ });
17594
+ this.mutations.push(() => {
17595
+ const result = this.db.query(`
17596
+ UPDATE todos_project_registration_bindings
17597
+ SET state = 'accepted', target_id = ?, accepted_receipt_id = ?,
17598
+ result_revision = ?, result_digest = ?, updated_at = ?
17599
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
17600
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
17601
+ `).run(update.target_id, update.accepted_receipt_id, update.result_revision, update.result_digest, update.updated_at, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
17602
+ if (result.changes !== 1) {
17603
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
17604
+ }
17605
+ });
17606
+ }
17607
+ async setBindingTerminal(scope, resourceKind, targetSelector, updatedAt) {
17608
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "pending");
17609
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
17610
+ ...binding,
17611
+ state: "terminal_nonacceptance",
17612
+ updated_at: updatedAt
17613
+ });
17614
+ this.mutations.push(() => {
17615
+ const result = this.db.query(`
17616
+ UPDATE todos_project_registration_bindings
17617
+ SET state = 'terminal_nonacceptance', updated_at = ?
17618
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
17619
+ AND resource_kind = ? AND target_selector = ? AND state = 'pending'
17620
+ `).run(updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
17621
+ if (result.changes !== 1) {
17622
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer pending at SQLite commit");
17623
+ }
17624
+ });
17625
+ }
17626
+ async setBindingRemoved(scope, resourceKind, targetSelector, removedReceiptId, updatedAt) {
17627
+ const binding = await this.requireBinding(scope, resourceKind, targetSelector, "accepted");
17628
+ this.bindings.set(this.bindingKey(scope, resourceKind, targetSelector), {
17629
+ ...binding,
17630
+ state: "removed",
17631
+ removed_receipt_id: removedReceiptId,
17632
+ updated_at: updatedAt
17633
+ });
17634
+ this.mutations.push(() => {
17635
+ const result = this.db.query(`
17636
+ UPDATE todos_project_registration_bindings
17637
+ SET state = 'removed', removed_receipt_id = ?, updated_at = ?
17638
+ WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
17639
+ AND resource_kind = ? AND target_selector = ? AND state = 'accepted'
17640
+ `).run(removedReceiptId, updatedAt, scope.authority_id, scope.tenant_id, scope.corpus_id, resourceKind, targetSelector);
17641
+ if (result.changes !== 1) {
17642
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration binding was no longer accepted at SQLite commit");
17643
+ }
17644
+ });
17645
+ }
17646
+ async findProjectConflict(path, taskListSlug) {
17647
+ const planned = [...this.projects.values()].find((project) => project?.path === path || project?.task_list_id === taskListSlug);
17648
+ if (planned)
17649
+ return planned;
17650
+ const observed = selectProjectConflict(this.db, path, taskListSlug);
17651
+ this.validators.push(() => sameSqliteValue(selectProjectConflict(this.db, path, taskListSlug), observed));
17652
+ return observed;
17653
+ }
17654
+ async findTaskListConflict(projectId, slug) {
17655
+ const planned = [...this.taskLists.values()].find((taskList) => taskList?.project_id === projectId && taskList.slug === slug);
17656
+ if (planned)
17657
+ return planned;
17658
+ const observed = selectTaskListConflict(this.db, projectId, slug);
17659
+ this.validators.push(() => sameSqliteValue(selectTaskListConflict(this.db, projectId, slug), observed));
17660
+ return observed;
17661
+ }
17662
+ async createProject(input) {
17663
+ const derivedSlug = normalizeSlug(input.name);
17664
+ const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
17665
+ if (!derivedSlug || !taskListId) {
17666
+ throw new Error("Project name and task-list slug must be non-empty");
17667
+ }
17668
+ const project = {
17669
+ id: uuid(),
17670
+ name: input.name,
17671
+ path: input.path,
17672
+ description: input.description || null,
17673
+ task_list_id: taskListId,
17674
+ task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
17675
+ task_counter: 0,
17676
+ created_at: now(),
17677
+ updated_at: now(),
17678
+ machine_id: currentStorageMachineId(this.db)
17679
+ };
17680
+ project.updated_at = project.created_at;
17681
+ this.projects.set(project.id, project);
17682
+ this.mutations.push(() => {
17683
+ try {
17684
+ const result = this.db.run(`INSERT INTO projects (
17685
+ id, name, path, description, task_list_id, task_prefix,
17686
+ task_counter, created_at, updated_at, machine_id
17687
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
17688
+ project.id,
17689
+ project.name,
17690
+ project.path,
17691
+ project.description,
17692
+ project.task_list_id,
17693
+ project.task_prefix,
17694
+ project.created_at,
17695
+ project.updated_at,
17696
+ project.machine_id ?? null
17697
+ ]);
17698
+ if (result.changes < 1) {
17699
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite registration commit");
17700
+ }
17701
+ } catch (error) {
17702
+ if (error instanceof SqliteRegistrationOptimisticConflict)
17703
+ throw error;
17704
+ throw new SqliteRegistrationOptimisticConflict("Todos project conflicted at SQLite registration commit", { cause: error });
17705
+ }
17706
+ });
17707
+ return project;
17708
+ }
17709
+ async createTaskList(input) {
17710
+ const slug = normalizeSlug(input.slug === undefined ? input.name : input.slug);
17711
+ if (!slug)
17712
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
17713
+ const taskList = {
17714
+ id: uuid(),
17715
+ project_id: input.project_id || null,
17716
+ slug,
17717
+ name: input.name,
17718
+ description: input.description || null,
17719
+ metadata: input.metadata ?? {},
17720
+ created_at: now(),
17721
+ updated_at: now(),
17722
+ machine_id: currentStorageMachineId(this.db)
17723
+ };
17724
+ taskList.updated_at = taskList.created_at;
17725
+ this.taskLists.set(taskList.id, taskList);
17726
+ this.mutations.push(() => {
17727
+ try {
17728
+ const result = this.db.run(`INSERT INTO task_lists (
17729
+ id, project_id, slug, name, description, metadata,
17730
+ created_at, updated_at, machine_id
17731
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
17732
+ taskList.id,
17733
+ taskList.project_id,
17734
+ taskList.slug,
17735
+ taskList.name,
17736
+ taskList.description,
17737
+ JSON.stringify(taskList.metadata),
17738
+ taskList.created_at,
17739
+ taskList.updated_at,
17740
+ taskList.machine_id ?? null
17741
+ ]);
17742
+ if (result.changes < 1) {
17743
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite registration commit");
17744
+ }
17745
+ } catch (error) {
17746
+ if (error instanceof SqliteRegistrationOptimisticConflict)
17747
+ throw error;
17748
+ throw new SqliteRegistrationOptimisticConflict("Todos task list conflicted at SQLite registration commit", { cause: error });
17749
+ }
17750
+ });
17751
+ return taskList;
17752
+ }
17753
+ async getProject(id) {
17754
+ if (this.projects.has(id))
17755
+ return this.projects.get(id) ?? null;
17756
+ const observed = selectProject(this.db, id);
17757
+ this.validators.push(() => sameSqliteValue(selectProject(this.db, id), observed));
17758
+ return observed;
17759
+ }
17760
+ async getTaskList(id) {
17761
+ if (this.taskLists.has(id))
17762
+ return this.taskLists.get(id) ?? null;
17763
+ const observed = selectTaskList(this.db, id);
17764
+ this.validators.push(() => sameSqliteValue(selectTaskList(this.db, id), observed));
17765
+ return observed;
17766
+ }
17767
+ async lockCompensationWrites() {}
17768
+ async hasDependents(resourceKind, targetId) {
17769
+ const observed = hasSqliteDependents(this.db, resourceKind, targetId);
17770
+ this.validators.push(() => hasSqliteDependents(this.db, resourceKind, targetId) === observed);
17771
+ return observed;
17772
+ }
17773
+ async deleteProject(id) {
17774
+ const project = await this.getProject(id);
17775
+ if (!project)
17776
+ return false;
17777
+ this.projects.set(id, null);
17778
+ this.mutations.push(() => {
17779
+ recordStorageTombstone({
17780
+ object_type: "projects",
17781
+ object_id: id,
17782
+ payload: project
17783
+ }, this.db);
17784
+ if (this.db.run("DELETE FROM projects WHERE id = ?", [id]).changes < 1) {
17785
+ throw new SqliteRegistrationOptimisticConflict("Todos project changed before SQLite compensation commit");
17786
+ }
17787
+ });
17788
+ return true;
17789
+ }
17790
+ async deleteTaskList(id) {
17791
+ const taskList = await this.getTaskList(id);
17792
+ if (!taskList)
17793
+ return false;
17794
+ this.taskLists.set(id, null);
17795
+ this.mutations.push(() => {
17796
+ recordStorageTombstone({
17797
+ object_type: "task_lists",
17798
+ object_id: id,
17799
+ payload: taskList
17800
+ }, this.db);
17801
+ if (this.db.run("DELETE FROM task_lists WHERE id = ?", [id]).changes < 1) {
17802
+ throw new SqliteRegistrationOptimisticConflict("Todos task list changed before SQLite compensation commit");
17803
+ }
17804
+ });
17805
+ return true;
17806
+ }
17807
+ bindingKey(scope, resourceKind, targetSelector) {
17808
+ return JSON.stringify([
17809
+ scope.authority_id,
17810
+ scope.tenant_id,
17811
+ scope.corpus_id,
17812
+ resourceKind,
17813
+ targetSelector
17814
+ ]);
17815
+ }
17816
+ async requireBinding(scope, resourceKind, targetSelector, state) {
17817
+ const binding = await this.getBinding(scope, resourceKind, targetSelector);
17818
+ if (!binding || binding.state !== state) {
17819
+ throw new Error(`Todos project registration binding was not ${state}`);
17820
+ }
17821
+ return binding;
17822
+ }
17823
+ availableProjectPrefix(name) {
17824
+ const words = name.replace(/[^a-zA-Z0-9\s]/g, "").trim().split(/\s+/);
17825
+ const prefix = words.length >= 3 ? words.slice(0, 3).map((word) => word[0].toUpperCase()).join("") : words.length === 2 ? (words[0].slice(0, 2) + words[1][0]).toUpperCase() : words[0].slice(0, 3).toUpperCase();
17826
+ let candidate = prefix;
17827
+ let suffix = 1;
17828
+ while (this.db.query("SELECT id FROM projects WHERE task_prefix = ? LIMIT 1").get(candidate) || [...this.projects.values()].some((project) => project?.task_prefix === candidate)) {
17829
+ suffix += 1;
17830
+ candidate = `${prefix}${suffix}`;
17831
+ }
17832
+ return candidate;
17833
+ }
17834
+ }
17835
+
16565
17836
  class SqliteTodosProjectRegistrationBackend {
16566
17837
  db;
16567
17838
  kind = "sqlite";
@@ -16580,15 +17851,19 @@ class SqliteTodosProjectRegistrationBackend {
16580
17851
  sqliteTransactionTails.set(this.db, current);
16581
17852
  await previous;
16582
17853
  try {
16583
- this.db.exec("BEGIN IMMEDIATE");
16584
- const result = await fn(new SqliteTodosProjectRegistrationTransaction(this.db));
16585
- this.db.exec("COMMIT");
16586
- return result;
16587
- } catch (error) {
16588
- try {
16589
- this.db.exec("ROLLBACK");
16590
- } catch {}
16591
- throw error;
17854
+ for (let attempt = 0;attempt < SQLITE_TRANSACTION_RETRY_LIMIT; attempt += 1) {
17855
+ const transaction = new StagedSqliteTodosProjectRegistrationTransaction(this.db);
17856
+ const result = await fn(transaction);
17857
+ try {
17858
+ transaction.commit();
17859
+ return result;
17860
+ } catch (error) {
17861
+ if (!(error instanceof SqliteRegistrationOptimisticConflict) || attempt === SQLITE_TRANSACTION_RETRY_LIMIT - 1) {
17862
+ throw error;
17863
+ }
17864
+ }
17865
+ }
17866
+ throw new SqliteRegistrationOptimisticConflict("Todos project registration exhausted SQLite optimistic retries");
16592
17867
  } finally {
16593
17868
  release();
16594
17869
  if (sqliteTransactionTails.get(this.db) === current) {
@@ -16647,7 +17922,7 @@ function canonicalize2(value) {
16647
17922
  return out;
16648
17923
  }
16649
17924
  function digestProjectRegistrationValue(value) {
16650
- return createHash4("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
17925
+ return createHash5("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
16651
17926
  }
16652
17927
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
16653
17928
  return `prk_${digestProjectRegistrationValue({