@hasna/todos 0.15.9 → 0.15.11

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 (67) hide show
  1. package/dist/cli/cloud-router.d.ts +14 -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/index.js +4831 -3259
  5. package/dist/contracts.js +109 -10
  6. package/dist/db/migrations.d.ts.map +1 -1
  7. package/dist/db/plan-project-links.d.ts +8 -0
  8. package/dist/db/plan-project-links.d.ts.map +1 -0
  9. package/dist/db/plan-row-serialization.d.ts +8 -0
  10. package/dist/db/plan-row-serialization.d.ts.map +1 -0
  11. package/dist/db/plans.d.ts.map +1 -1
  12. package/dist/db/task-crud.d.ts.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7747 -1275
  16. package/dist/lib/plan-project-link-contract.d.ts +32 -0
  17. package/dist/lib/plan-project-link-contract.d.ts.map +1 -0
  18. package/dist/lib/plan-project-link.d.ts +14 -0
  19. package/dist/lib/plan-project-link.d.ts.map +1 -0
  20. package/dist/mcp/index.js +1175 -50
  21. package/dist/mcp.js +7 -3
  22. package/dist/project-registration.js +846 -26
  23. package/dist/registry.d.ts +1 -1
  24. package/dist/registry.d.ts.map +1 -1
  25. package/dist/registry.js +116 -10
  26. package/dist/release-provenance.json +5 -5
  27. package/dist/sdk/index.js +21 -0
  28. package/dist/sdk/v1.generated.d.ts +53 -0
  29. package/dist/sdk/v1.generated.d.ts.map +1 -1
  30. package/dist/server/index.js +1179 -54
  31. package/dist/server/openapi.d.ts +370 -0
  32. package/dist/server/openapi.d.ts.map +1 -1
  33. package/dist/server/v1.d.ts.map +1 -1
  34. package/dist/storage/interfaces.d.ts +26 -1
  35. package/dist/storage/interfaces.d.ts.map +1 -1
  36. package/dist/storage/local-sqlite.d.ts.map +1 -1
  37. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  38. package/dist/storage/postgres-sync.d.ts.map +1 -1
  39. package/dist/storage.js +835 -19
  40. package/dist/task-manifest/authority.d.ts +19 -0
  41. package/dist/task-manifest/authority.d.ts.map +1 -0
  42. package/dist/task-manifest/backend.d.ts +27 -0
  43. package/dist/task-manifest/backend.d.ts.map +1 -0
  44. package/dist/task-manifest/canonical.d.ts +4 -0
  45. package/dist/task-manifest/canonical.d.ts.map +1 -0
  46. package/dist/task-manifest/http.d.ts +16 -0
  47. package/dist/task-manifest/http.d.ts.map +1 -0
  48. package/dist/task-manifest/index.d.ts +10 -0
  49. package/dist/task-manifest/index.d.ts.map +1 -0
  50. package/dist/task-manifest/postgres.d.ts +18 -0
  51. package/dist/task-manifest/postgres.d.ts.map +1 -0
  52. package/dist/task-manifest/reference-guard.d.ts +27 -0
  53. package/dist/task-manifest/reference-guard.d.ts.map +1 -0
  54. package/dist/task-manifest/schema-sql.d.ts +3 -0
  55. package/dist/task-manifest/schema-sql.d.ts.map +1 -0
  56. package/dist/task-manifest/schema.d.ts +15 -0
  57. package/dist/task-manifest/schema.d.ts.map +1 -0
  58. package/dist/task-manifest/sqlite.d.ts +15 -0
  59. package/dist/task-manifest/sqlite.d.ts.map +1 -0
  60. package/dist/task-manifest/types.d.ts +166 -0
  61. package/dist/task-manifest/types.d.ts.map +1 -0
  62. package/dist/task-manifest.d.ts +2 -0
  63. package/dist/task-manifest.d.ts.map +1 -0
  64. package/dist/task-manifest.js +6816 -0
  65. package/dist/types/index.d.ts +60 -2
  66. package/dist/types/index.d.ts.map +1 -1
  67. 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) {
@@ -7816,6 +7839,14 @@ var init_checklists = __esm(() => {
7816
7839
  init_database();
7817
7840
  });
7818
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
+
7819
7850
  // src/lib/recurrence.ts
7820
7851
  function parseRecurrenceRule(rule) {
7821
7852
  const normalized = rule.trim().toLowerCase();
@@ -9134,9 +9165,23 @@ function sanitizeUpdateTaskInput(input) {
9134
9165
  metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
9135
9166
  };
9136
9167
  }
9137
- function createTask2(input, db) {
9138
- input = sanitizeCreateTaskInput(input);
9139
- 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);
9140
9185
  const timestamp2 = now();
9141
9186
  const tags = input.tags || [];
9142
9187
  const machineId = currentStorageMachineId(d);
@@ -9150,7 +9195,7 @@ function createTask2(input, db) {
9150
9195
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
9151
9196
  id,
9152
9197
  null,
9153
- input.project_id || null,
9198
+ effectiveProjectId,
9154
9199
  input.parent_id || null,
9155
9200
  input.plan_id || null,
9156
9201
  input.task_list_id || null,
@@ -9200,7 +9245,15 @@ function createTask2(input, db) {
9200
9245
  if (tags.length > 0) {
9201
9246
  insertTaskTags(id, tags, d);
9202
9247
  }
9203
- 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);
9204
9257
  const payload = taskEventData(task);
9205
9258
  const databasePath = databasePathFromDatabase(d);
9206
9259
  dispatchWebhook2("task.created", payload, d).catch(() => {});
@@ -9300,6 +9353,10 @@ function listTasks2(filter = {}, db) {
9300
9353
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
9301
9354
  params.push(filter.not_created_by);
9302
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
+ }
9303
9360
  if (filter.session_id) {
9304
9361
  conditions.push("session_id = ?");
9305
9362
  params.push(filter.session_id);
@@ -9460,6 +9517,10 @@ function countTasks(filter = {}, db) {
9460
9517
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
9461
9518
  params.push(filter.not_created_by);
9462
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
+ }
9463
9524
  if (filter.session_id) {
9464
9525
  conditions.push("session_id = ?");
9465
9526
  params.push(filter.session_id);
@@ -9499,7 +9560,7 @@ function countTasks(filter = {}, db) {
9499
9560
  const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
9500
9561
  return row.count;
9501
9562
  }
9502
- function updateTask2(id, input, db) {
9563
+ function updateTaskStored(id, input, db) {
9503
9564
  const d = db || getDatabase();
9504
9565
  const task = getTask(id, d);
9505
9566
  if (!task)
@@ -9508,6 +9569,18 @@ function updateTask2(id, input, db) {
9508
9569
  throw new VersionConflictError(id, input.version, task.version);
9509
9570
  }
9510
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
+ }
9511
9584
  const timestamp2 = now();
9512
9585
  const completionTimestamp = input.completed_at ?? timestamp2;
9513
9586
  const sets = ["version = version + 1", "updated_at = ?"];
@@ -9715,6 +9788,21 @@ function updateTask2(id, input, db) {
9715
9788
  emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
9716
9789
  return updatedTask;
9717
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
+ }
9718
9806
  function deleteTask(id, db) {
9719
9807
  const d = db || getDatabase();
9720
9808
  const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
@@ -10380,7 +10468,7 @@ function listPlans(projectId, db) {
10380
10468
  }
10381
10469
  return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
10382
10470
  }
10383
- function updatePlan2(id, input, db) {
10471
+ function updatePlanStored(id, input, db) {
10384
10472
  const d = db || getDatabase();
10385
10473
  const plan = getPlan(id, d);
10386
10474
  if (!plan)
@@ -10425,6 +10513,13 @@ function updatePlan2(id, input, db) {
10425
10513
  });
10426
10514
  return updated;
10427
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
+ }
10428
10523
  function deletePlan(id, db) {
10429
10524
  const d = db || getDatabase();
10430
10525
  const plan = getPlan(id, d);
@@ -10849,7 +10944,7 @@ var init_boards = __esm(() => {
10849
10944
  });
10850
10945
 
10851
10946
  // src/lib/artifact-store.ts
10852
- import { createHash as createHash3 } from "crypto";
10947
+ import { createHash as createHash4 } from "crypto";
10853
10948
  import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync3, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
10854
10949
  import { basename, dirname as dirname4, join as join5, resolve as resolve7 } from "path";
10855
10950
  import { tmpdir as tmpdir2 } from "os";
@@ -10874,7 +10969,7 @@ function artifactStorePath(relativePath) {
10874
10969
  return join5(artifactStoreRoot(), normalized);
10875
10970
  }
10876
10971
  function sha256(buffer) {
10877
- return createHash3("sha256").update(buffer).digest("hex");
10972
+ return createHash4("sha256").update(buffer).digest("hex");
10878
10973
  }
10879
10974
  function isTextLike(buffer, path) {
10880
10975
  if (buffer.includes(0))
@@ -12283,11 +12378,11 @@ var init_tasks = __esm(() => {
12283
12378
  });
12284
12379
 
12285
12380
  // src/project-registration/authority.ts
12286
- import { createHash as createHash4 } from "crypto";
12381
+ import { createHash as createHash5 } from "crypto";
12287
12382
  // package.json
12288
12383
  var package_default = {
12289
12384
  name: "@hasna/todos",
12290
- version: "0.15.9",
12385
+ version: "0.15.11",
12291
12386
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12292
12387
  type: "module",
12293
12388
  main: "dist/index.js",
@@ -12329,6 +12424,10 @@ var package_default = {
12329
12424
  "./project-registration": {
12330
12425
  types: "./dist/project-registration.d.ts",
12331
12426
  import: "./dist/project-registration.js"
12427
+ },
12428
+ "./task-manifest": {
12429
+ types: "./dist/task-manifest.d.ts",
12430
+ import: "./dist/task-manifest.js"
12332
12431
  }
12333
12432
  },
12334
12433
  workspaces: [
@@ -12341,8 +12440,8 @@ var package_default = {
12341
12440
  "README.md"
12342
12441
  ],
12343
12442
  scripts: {
12344
- 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",
12345
- "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/*'",
12346
12445
  migrate: "bun run src/server/index.ts migrate",
12347
12446
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
12348
12447
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -12425,6 +12524,62 @@ function normalizeAgentNameInput(name) {
12425
12524
  // src/storage/postgres-adapter.ts
12426
12525
  init_creator_identity();
12427
12526
 
12527
+ // src/lib/plan-project-link-contract.ts
12528
+ init_types();
12529
+ import { createHash as createHash2 } from "crypto";
12530
+ var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1";
12531
+
12532
+ class PlanProjectLinkError extends Error {
12533
+ code;
12534
+ details;
12535
+ constructor(code, message, details = {}) {
12536
+ super(message);
12537
+ this.code = code;
12538
+ this.details = details;
12539
+ this.name = "PlanProjectLinkError";
12540
+ }
12541
+ }
12542
+ function canonicalPlanProjectLinkJson(value) {
12543
+ if (value === null || typeof value !== "object")
12544
+ return JSON.stringify(value);
12545
+ if (Array.isArray(value))
12546
+ return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
12547
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
12548
+ }
12549
+ function planProjectLinkDigest(value) {
12550
+ return createHash2("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
12551
+ }
12552
+ function normalizePlanProjectLinkIdempotencyKey(value) {
12553
+ const key = value?.trim() ?? "";
12554
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
12555
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
12556
+ }
12557
+ return key;
12558
+ }
12559
+ function planProjectLinkReceiptId(idempotencyKey) {
12560
+ return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
12561
+ }
12562
+ function planProjectLinkRollbackReceiptId(receiptId) {
12563
+ return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
12564
+ }
12565
+ function planProjectLinkRequestHash(planId, projectId) {
12566
+ return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
12567
+ }
12568
+ function planProjectLinkResultDigest(plan, tasks) {
12569
+ return planProjectLinkDigest({
12570
+ plan_id: plan.id,
12571
+ plan_project_id: plan.project_id,
12572
+ 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))
12573
+ });
12574
+ }
12575
+ function assertPlanProjectLinkReceipt(value) {
12576
+ const receipt = value;
12577
+ 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") {
12578
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
12579
+ }
12580
+ return receipt;
12581
+ }
12582
+
12428
12583
  // src/storage/postgres-sync.ts
12429
12584
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
12430
12585
  var DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
@@ -12453,6 +12608,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
12453
12608
  RETURNS text
12454
12609
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
12455
12610
  AS $$ SELECT unaccent('unaccent', $1) $$`,
12611
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
12612
+ RETURNS timestamptz
12613
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
12614
+ SET DateStyle TO 'ISO, YMD'
12615
+ AS $$
12616
+ BEGIN
12617
+ RETURN CASE
12618
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
12619
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
12620
+ END;
12621
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
12622
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
12623
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
12624
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
12625
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
12456
12626
  `ALTER TABLE ${tableName}
12457
12627
  ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
12458
12628
  GENERATED ALWAYS AS (
@@ -13119,6 +13289,12 @@ function createPostgresTodosStorageAdapter(options) {
13119
13289
  update: (id, input) => updatePlan(id, input, store),
13120
13290
  delete: (id, context) => store.deletePlan(id, context)
13121
13291
  },
13292
+ planProjectLinks: {
13293
+ apply: (input, context) => store.applyPlanProjectLink(input, context),
13294
+ rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
13295
+ getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
13296
+ getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
13297
+ },
13122
13298
  agents: {
13123
13299
  register: (input, context) => registerAgent(input, store, context),
13124
13300
  get: (id) => store.get("agents", id),
@@ -13301,6 +13477,9 @@ class PostgresJsonRecordStore {
13301
13477
  conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
13302
13478
  if (filter.not_created_by !== undefined)
13303
13479
  conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
13480
+ if (filter.updated_after !== undefined) {
13481
+ conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
13482
+ }
13304
13483
  if (filter.session_id !== undefined)
13305
13484
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
13306
13485
  if (filter.tags?.length) {
@@ -13485,6 +13664,115 @@ class PostgresJsonRecordStore {
13485
13664
  }
13486
13665
  return value;
13487
13666
  }
13667
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
13668
+ const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
13669
+ if (planIds.length === 0)
13670
+ return this.upsert("tasks", value, context);
13671
+ await this.ensureSchema();
13672
+ const updatedAt = value.updated_at;
13673
+ const targetPlanId = value.plan_id;
13674
+ const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
13675
+ locked_plans AS MATERIALIZED (
13676
+ SELECT object_id, payload FROM ${this.tableName}
13677
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
13678
+ AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
13679
+ ORDER BY object_id
13680
+ FOR UPDATE
13681
+ ), validation AS (
13682
+ SELECT
13683
+ (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
13684
+ ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
13685
+ (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
13686
+ ), guarded AS (
13687
+ SELECT
13688
+ validation.*,
13689
+ ($9::boolean AND validation.target_project_id IS NOT NULL
13690
+ AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
13691
+ CASE
13692
+ WHEN validation.target_project_id IS NULL THEN $3::jsonb
13693
+ ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
13694
+ END AS payload
13695
+ FROM validation
13696
+ ), stored AS (
13697
+ INSERT INTO ${this.tableName} (
13698
+ service, object_type, object_id, payload, updated_at,
13699
+ deleted_at, source_machine_id, version
13700
+ )
13701
+ SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
13702
+ FROM guarded
13703
+ WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
13704
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
13705
+ payload = EXCLUDED.payload,
13706
+ updated_at = EXCLUDED.updated_at,
13707
+ deleted_at = NULL,
13708
+ source_machine_id = EXCLUDED.source_machine_id,
13709
+ version = EXCLUDED.version
13710
+ WHERE ${this.tableName}.updated_at IS NULL
13711
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
13712
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
13713
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
13714
+ RETURNING payload
13715
+ )
13716
+ SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
13717
+ (SELECT payload FROM stored) AS payload
13718
+ FROM guarded`, [
13719
+ this.service,
13720
+ value.id,
13721
+ jsonbParam(value),
13722
+ updatedAt,
13723
+ context.requestId ?? this.sourceMachineId ?? null,
13724
+ numberValue2(value.version),
13725
+ jsonbParam(planIds),
13726
+ targetPlanId,
13727
+ explicitProject
13728
+ ]);
13729
+ const row = result.rows[0];
13730
+ if (!row?.all_plans_found || !row.target_plan_found) {
13731
+ 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 });
13732
+ }
13733
+ if (row.project_conflict) {
13734
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
13735
+ }
13736
+ if (!row.payload) {
13737
+ return await requireRecord("tasks", value.id, this);
13738
+ }
13739
+ return payloadRecord2(row.payload);
13740
+ }
13741
+ async updatePlanWithProjectLinkGuard(value, context = {}) {
13742
+ await this.ensureSchema();
13743
+ const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
13744
+ SELECT payload FROM ${this.tableName}
13745
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
13746
+ FOR UPDATE
13747
+ ), stored AS (
13748
+ UPDATE ${this.tableName} r SET
13749
+ payload = jsonb_set(
13750
+ $3::jsonb,
13751
+ '{project_id}',
13752
+ COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
13753
+ true
13754
+ ),
13755
+ updated_at = $4::timestamptz,
13756
+ deleted_at = NULL,
13757
+ source_machine_id = COALESCE($5, r.source_machine_id),
13758
+ version = COALESCE(r.version, 0) + 1
13759
+ FROM locked_plan
13760
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
13761
+ RETURNING r.payload
13762
+ )
13763
+ SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
13764
+ (SELECT payload FROM stored) AS payload`, [
13765
+ this.service,
13766
+ value.id,
13767
+ jsonbParam(value),
13768
+ value.updated_at,
13769
+ context.requestId ?? this.sourceMachineId ?? null
13770
+ ]);
13771
+ const row = result.rows[0];
13772
+ if (!row?.plan_found || !row.payload)
13773
+ throw new PlanNotFoundError(value.id);
13774
+ return payloadRecord2(row.payload);
13775
+ }
13488
13776
  async createTemplateWithTasks(template, tasks, context = {}) {
13489
13777
  await this.ensureSchema();
13490
13778
  const records = [
@@ -13723,6 +14011,333 @@ class PostgresJsonRecordStore {
13723
14011
  version: numberValue2(existing["version"])
13724
14012
  }, context);
13725
14013
  }
14014
+ async getPlanProjectLinkReceipt(receiptId) {
14015
+ const value = await this.get("plan_project_link_receipts", receiptId);
14016
+ return value ? assertPlanProjectLinkReceipt(value) : null;
14017
+ }
14018
+ async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
14019
+ await this.ensureSchema();
14020
+ const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
14021
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
14022
+ AND payload->>'idempotency_key' = $2
14023
+ LIMIT 2`, [this.service, idempotencyKey]);
14024
+ if (result.rows.length > 1) {
14025
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
14026
+ }
14027
+ return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
14028
+ }
14029
+ async currentPlanProjectLinkResult(receipt, action) {
14030
+ const [plan, project, tasks] = await Promise.all([
14031
+ this.get("plans", receipt.plan_id),
14032
+ this.get("projects", receipt.project_id),
14033
+ this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
14034
+ ]);
14035
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14036
+ if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
14037
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
14038
+ }
14039
+ return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
14040
+ }
14041
+ async applyPlanProjectLink(input, context = {}) {
14042
+ await this.ensureSchema();
14043
+ const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
14044
+ if (existing) {
14045
+ if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
14046
+ 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 });
14047
+ }
14048
+ const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
14049
+ if (rolledBack) {
14050
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
14051
+ }
14052
+ return this.currentPlanProjectLinkResult(existing, "already_linked");
14053
+ }
14054
+ const [plan, project, tasks, scopedPlans] = await Promise.all([
14055
+ this.get("plans", input.plan_id),
14056
+ this.get("projects", input.project_id),
14057
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
14058
+ this.list("plans")
14059
+ ]);
14060
+ if (!plan)
14061
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
14062
+ if (!project)
14063
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
14064
+ if (plan.updated_at !== input.expected_plan_revision) {
14065
+ 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 });
14066
+ }
14067
+ if (project.updated_at !== input.expected_project_revision) {
14068
+ 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 });
14069
+ }
14070
+ const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
14071
+ if (collision) {
14072
+ 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 });
14073
+ }
14074
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14075
+ const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
14076
+ const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
14077
+ 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 });
14078
+ const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
14079
+ const receipt = {
14080
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
14081
+ receipt_id: input.receipt_id,
14082
+ idempotency_key: input.idempotency_key,
14083
+ plan_id: plan.id,
14084
+ project_id: project.id,
14085
+ prior_plan_project_id: plan.project_id,
14086
+ prior_task_project_ids: priorTaskProjectIds,
14087
+ task_ids: sortedTasks.map((task) => task.id),
14088
+ task_count: sortedTasks.length,
14089
+ result_plan_revision: projectedPlan.updated_at,
14090
+ result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
14091
+ rollback_supported: true,
14092
+ created_at: input.created_at
14093
+ };
14094
+ let mutation;
14095
+ try {
14096
+ mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
14097
+ target_plan AS MATERIALIZED (
14098
+ SELECT payload FROM ${this.tableName}
14099
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
14100
+ FOR UPDATE
14101
+ ), target_project AS (
14102
+ SELECT payload FROM ${this.tableName}
14103
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
14104
+ FOR UPDATE
14105
+ ), member_tasks AS MATERIALIZED (
14106
+ SELECT object_id, payload FROM ${this.tableName}
14107
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
14108
+ AND payload->>'plan_id' = $2
14109
+ AND EXISTS (SELECT 1 FROM target_plan)
14110
+ FOR UPDATE
14111
+ ), existing AS (
14112
+ SELECT payload FROM ${this.tableName}
14113
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts'
14114
+ AND object_id = $6 AND deleted_at IS NULL
14115
+ FOR UPDATE
14116
+ ), collision AS (
14117
+ SELECT 1 FROM ${this.tableName} r, target_plan p
14118
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
14119
+ AND r.object_id <> $2 AND r.payload->>'project_id' = $3
14120
+ AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
14121
+ AND p.payload->>'slug' IS NOT NULL
14122
+ LIMIT 1
14123
+ ), checks AS (
14124
+ SELECT
14125
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
14126
+ EXISTS (SELECT 1 FROM target_project) AS project_found,
14127
+ COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
14128
+ COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
14129
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
14130
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
14131
+ EXISTS (SELECT 1 FROM collision) AS collision,
14132
+ EXISTS (SELECT 1 FROM existing) AS has_existing
14133
+ ), updated_plan AS (
14134
+ UPDATE ${this.tableName} r SET
14135
+ payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
14136
+ updated_at = $10::timestamptz,
14137
+ version = COALESCE(r.version, 0) + 1,
14138
+ source_machine_id = COALESCE($11, r.source_machine_id)
14139
+ FROM checks
14140
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
14141
+ AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
14142
+ AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
14143
+ RETURNING r.payload
14144
+ ), updated_tasks AS (
14145
+ UPDATE ${this.tableName} r SET
14146
+ payload = r.payload || jsonb_build_object(
14147
+ 'project_id', $3::text,
14148
+ 'updated_at', $10::text,
14149
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
14150
+ ),
14151
+ updated_at = $10::timestamptz,
14152
+ version = COALESCE(r.version, 0) + 1,
14153
+ source_machine_id = COALESCE($11, r.source_machine_id)
14154
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
14155
+ AND r.payload->>'plan_id' = $2
14156
+ AND r.payload->>'project_id' IS DISTINCT FROM $3
14157
+ AND EXISTS (SELECT 1 FROM updated_plan)
14158
+ RETURNING 1
14159
+ ), task_gate AS (
14160
+ SELECT count(*) AS count FROM updated_tasks
14161
+ ), inserted AS (
14162
+ INSERT INTO ${this.tableName}
14163
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
14164
+ SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
14165
+ FROM checks, task_gate
14166
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
14167
+ RETURNING payload
14168
+ ) SELECT
14169
+ checks.plan_found,
14170
+ checks.project_found,
14171
+ checks.plan_revision_ok,
14172
+ checks.project_revision_ok,
14173
+ checks.membership_ok,
14174
+ checks.collision,
14175
+ (SELECT payload FROM existing) AS existing_receipt,
14176
+ (SELECT payload FROM inserted) AS inserted_receipt
14177
+ FROM checks`, [
14178
+ this.service,
14179
+ plan.id,
14180
+ project.id,
14181
+ input.expected_plan_revision,
14182
+ input.expected_project_revision,
14183
+ receipt.receipt_id,
14184
+ jsonbParam(receipt),
14185
+ jsonbParam(priorTaskProjectIds),
14186
+ jsonbParam(receipt.task_ids),
14187
+ input.created_at,
14188
+ this.machineId(context)
14189
+ ]);
14190
+ } catch (error) {
14191
+ if (isPostgresUniqueViolation(error)) {
14192
+ const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
14193
+ if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
14194
+ return this.currentPlanProjectLinkResult(raced, "already_linked");
14195
+ }
14196
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
14197
+ }
14198
+ throw error;
14199
+ }
14200
+ const row = mutation.rows[0];
14201
+ if (!row?.plan_found)
14202
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
14203
+ if (!row.project_found)
14204
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
14205
+ if (!row.plan_revision_ok)
14206
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
14207
+ if (!row.project_revision_ok)
14208
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
14209
+ if (!row.membership_ok)
14210
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
14211
+ if (row.collision)
14212
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
14213
+ const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
14214
+ if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
14215
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
14216
+ }
14217
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
14218
+ }
14219
+ async rollbackPlanProjectLink(input, context = {}) {
14220
+ await this.ensureSchema();
14221
+ const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
14222
+ if (existingRollback)
14223
+ return existingRollback;
14224
+ const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
14225
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
14226
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
14227
+ }
14228
+ const [plan, tasks] = await Promise.all([
14229
+ this.get("plans", input.plan_id),
14230
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
14231
+ ]);
14232
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
14233
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
14234
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
14235
+ }
14236
+ if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
14237
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
14238
+ }
14239
+ const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
14240
+ const projectedTasks = sortedTasks.map((task) => ({
14241
+ ...task,
14242
+ project_id: receipt.prior_task_project_ids[task.id] ?? null,
14243
+ updated_at: input.restored_at,
14244
+ version: task.version + 1
14245
+ }));
14246
+ const rollback = {
14247
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
14248
+ action: "restored",
14249
+ plan: projectedPlan,
14250
+ tasks: projectedTasks,
14251
+ accepted_receipt_id: receipt.receipt_id,
14252
+ rollback_receipt_id: input.rollback_receipt_id,
14253
+ restored_at: input.restored_at
14254
+ };
14255
+ const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
14256
+ const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
14257
+ target_plan AS MATERIALIZED (
14258
+ SELECT payload FROM ${this.tableName}
14259
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
14260
+ FOR UPDATE
14261
+ ), member_tasks AS MATERIALIZED (
14262
+ SELECT object_id, payload FROM ${this.tableName}
14263
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
14264
+ AND payload->>'plan_id' = $2
14265
+ AND EXISTS (SELECT 1 FROM target_plan)
14266
+ FOR UPDATE
14267
+ ), existing AS (
14268
+ SELECT payload FROM ${this.tableName}
14269
+ WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
14270
+ AND object_id = $5 AND deleted_at IS NULL
14271
+ FOR UPDATE
14272
+ ), checks AS (
14273
+ SELECT
14274
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
14275
+ COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
14276
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
14277
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
14278
+ EXISTS (SELECT 1 FROM existing) AS has_existing
14279
+ ), updated_plan AS (
14280
+ UPDATE ${this.tableName} r SET
14281
+ payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
14282
+ updated_at = $10::timestamptz,
14283
+ version = COALESCE(r.version, 0) + 1,
14284
+ source_machine_id = COALESCE($11, r.source_machine_id)
14285
+ FROM checks
14286
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
14287
+ AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
14288
+ RETURNING 1
14289
+ ), updated_tasks AS (
14290
+ UPDATE ${this.tableName} r SET
14291
+ payload = r.payload || jsonb_build_object(
14292
+ 'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
14293
+ 'updated_at', $10::text,
14294
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
14295
+ ),
14296
+ updated_at = $10::timestamptz,
14297
+ version = COALESCE(r.version, 0) + 1,
14298
+ source_machine_id = COALESCE($11, r.source_machine_id)
14299
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
14300
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
14301
+ RETURNING 1
14302
+ ), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
14303
+ INSERT INTO ${this.tableName}
14304
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
14305
+ SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
14306
+ FROM checks, task_gate
14307
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
14308
+ RETURNING payload
14309
+ ) SELECT
14310
+ checks.plan_found,
14311
+ checks.plan_revision_ok,
14312
+ checks.membership_ok,
14313
+ (SELECT payload FROM existing) AS existing_rollback,
14314
+ (SELECT payload FROM inserted) AS inserted_rollback
14315
+ FROM checks`, [
14316
+ this.service,
14317
+ input.plan_id,
14318
+ input.expected_plan_revision,
14319
+ input.receipt_id,
14320
+ input.rollback_receipt_id,
14321
+ jsonbParam(rollback),
14322
+ jsonbParam(currentTaskProjects),
14323
+ jsonbParam(receipt.task_ids),
14324
+ jsonbParam(receipt.prior_plan_project_id),
14325
+ input.restored_at,
14326
+ this.machineId(context),
14327
+ jsonbParam(receipt.prior_task_project_ids)
14328
+ ]);
14329
+ const row = result.rows[0];
14330
+ if (!row?.plan_found)
14331
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
14332
+ if (!row.plan_revision_ok)
14333
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
14334
+ if (!row.membership_ok)
14335
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
14336
+ const accepted = row.existing_rollback ?? row.inserted_rollback;
14337
+ if (!accepted)
14338
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
14339
+ return accepted;
14340
+ }
13726
14341
  async deletePlan(id, context = {}) {
13727
14342
  await this.ensureSchema();
13728
14343
  const timestamp = new Date().toISOString();
@@ -13817,11 +14432,17 @@ class PostgresJsonRecordStore {
13817
14432
  }
13818
14433
  async function createTask(input, store, context) {
13819
14434
  const timestamp = new Date().toISOString();
13820
- const shortId = input.project_id ? await nextTaskShortId(input.project_id, store, context) : null;
14435
+ const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
14436
+ const requestedProjectId = input.project_id ?? context?.projectId ?? null;
14437
+ if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
14438
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
14439
+ }
14440
+ const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
14441
+ const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
13821
14442
  const task = {
13822
14443
  id: randomUUID(),
13823
14444
  short_id: shortId,
13824
- project_id: input.project_id ?? context?.projectId ?? null,
14445
+ project_id: effectiveProjectId,
13825
14446
  parent_id: input.parent_id ?? null,
13826
14447
  plan_id: input.plan_id ?? null,
13827
14448
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -13875,15 +14496,27 @@ async function createTask(input, store, context) {
13875
14496
  synced_at: null,
13876
14497
  archived_at: null
13877
14498
  };
13878
- await store.upsert("tasks", task, context);
13879
- await logTaskChange(task.id, "created", "status", null, task.status, task.assigned_by ?? task.agent_id, store, context);
13880
- return task;
14499
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
14500
+ await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
14501
+ return storedTask;
13881
14502
  }
13882
14503
  async function updateTask(id, input, store) {
13883
14504
  const existing = await requireRecord("tasks", id, store);
13884
14505
  if (existing.version !== input.version) {
13885
14506
  throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
13886
14507
  }
14508
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
14509
+ const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
14510
+ if (linkedPlan?.project_id) {
14511
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
14512
+ if (effectiveProjectId !== linkedPlan.project_id) {
14513
+ if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
14514
+ input = { ...input, project_id: linkedPlan.project_id };
14515
+ } else {
14516
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
14517
+ }
14518
+ }
14519
+ }
13887
14520
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
13888
14521
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
13889
14522
  const task = {
@@ -13899,8 +14532,7 @@ async function updateTask(id, input, store) {
13899
14532
  created_by: existing.created_by,
13900
14533
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
13901
14534
  };
13902
- await store.upsert("tasks", task);
13903
- return task;
14535
+ return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
13904
14536
  }
13905
14537
  async function startTask(id, agentId, store) {
13906
14538
  const task = await requireRecord("tasks", id, store);
@@ -13964,8 +14596,7 @@ async function patchTask(task, patch, store) {
13964
14596
  version: task.version + 1,
13965
14597
  updated_at: new Date().toISOString()
13966
14598
  };
13967
- await store.upsert("tasks", updated);
13968
- return updated;
14599
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
13969
14600
  }
13970
14601
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
13971
14602
  function sameCloudLockHolder(stored, incoming) {
@@ -14230,7 +14861,12 @@ async function updatePlan(id, input, store) {
14230
14861
  excludeId: id
14231
14862
  });
14232
14863
  }
14233
- return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
14864
+ return store.updatePlanWithProjectLinkGuard({
14865
+ ...plan,
14866
+ ...patch,
14867
+ project_id: plan.project_id,
14868
+ updated_at: new Date().toISOString()
14869
+ });
14234
14870
  }
14235
14871
  function matchAgentByName(agents, name, options) {
14236
14872
  const target = normalizeAgentNameInput(name);
@@ -15850,6 +16486,184 @@ function scanSqliteIntegrity(db = getDatabase()) {
15850
16486
  return buildIntegrityReport(conditions, now());
15851
16487
  }
15852
16488
 
16489
+ // src/db/plan-project-links.ts
16490
+ init_database();
16491
+ init_plans();
16492
+ init_projects();
16493
+ init_tasks();
16494
+ function getPlanProjectLinkReceipt(receiptId, db) {
16495
+ const d = db || getDatabase();
16496
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
16497
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
16498
+ }
16499
+ function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
16500
+ const d = db || getDatabase();
16501
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
16502
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
16503
+ }
16504
+ function exactTasks(planId, db) {
16505
+ return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
16506
+ }
16507
+ function currentResult(planId, projectId, receipt, db, action) {
16508
+ const plan = getPlan(planId, db);
16509
+ const project = getProject(projectId, db);
16510
+ if (!plan || !project) {
16511
+ 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 });
16512
+ }
16513
+ const tasks = exactTasks(planId, db);
16514
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
16515
+ 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 });
16516
+ }
16517
+ return { mode: "apply", action, plan, project, tasks, receipt };
16518
+ }
16519
+ function applyPlanProjectLinkSqlite(input, db) {
16520
+ const d = db || getDatabase();
16521
+ const mutate = d.transaction(() => {
16522
+ guardPlanRowsSqlite([input.plan_id], d);
16523
+ const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
16524
+ const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
16525
+ if (existingRow) {
16526
+ if (existingRow.payload_hash !== requestHash) {
16527
+ 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 });
16528
+ }
16529
+ const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
16530
+ if (rolledBack) {
16531
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
16532
+ }
16533
+ return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
16534
+ }
16535
+ const plan = getPlan(input.plan_id, d);
16536
+ if (!plan) {
16537
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
16538
+ }
16539
+ const project = getProject(input.project_id, d);
16540
+ if (!project) {
16541
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
16542
+ }
16543
+ if (plan.updated_at !== input.expected_plan_revision) {
16544
+ 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 });
16545
+ }
16546
+ if (project.updated_at !== input.expected_project_revision) {
16547
+ 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 });
16548
+ }
16549
+ if (plan.slug) {
16550
+ const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
16551
+ if (collision) {
16552
+ 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 });
16553
+ }
16554
+ }
16555
+ const beforeTasks = exactTasks(plan.id, d);
16556
+ const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
16557
+ const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
16558
+ const timestamp2 = input.created_at;
16559
+ if (plan.project_id !== project.id) {
16560
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
16561
+ project.id,
16562
+ timestamp2,
16563
+ plan.id,
16564
+ input.expected_plan_revision
16565
+ ]);
16566
+ }
16567
+ for (const task of beforeTasks) {
16568
+ if (task.project_id === project.id)
16569
+ continue;
16570
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
16571
+ }
16572
+ const linkedPlan = getPlan(plan.id, d);
16573
+ const linkedTasks = exactTasks(plan.id, d);
16574
+ if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
16575
+ 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 });
16576
+ }
16577
+ const receipt = {
16578
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
16579
+ receipt_id: input.receipt_id,
16580
+ idempotency_key: input.idempotency_key,
16581
+ plan_id: plan.id,
16582
+ project_id: project.id,
16583
+ prior_plan_project_id: plan.project_id,
16584
+ prior_task_project_ids: priorTaskProjectIds,
16585
+ task_ids: beforeTasks.map((task) => task.id),
16586
+ task_count: beforeTasks.length,
16587
+ result_plan_revision: linkedPlan.updated_at,
16588
+ result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
16589
+ rollback_supported: true,
16590
+ created_at: input.created_at
16591
+ };
16592
+ d.run(`INSERT INTO plan_project_link_receipts
16593
+ (receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
16594
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
16595
+ receipt.receipt_id,
16596
+ receipt.idempotency_key,
16597
+ receipt.plan_id,
16598
+ receipt.project_id,
16599
+ requestHash,
16600
+ JSON.stringify(receipt),
16601
+ receipt.created_at
16602
+ ]);
16603
+ return {
16604
+ mode: "apply",
16605
+ action: alreadyLinked ? "already_linked" : "linked",
16606
+ plan: linkedPlan,
16607
+ project,
16608
+ tasks: linkedTasks,
16609
+ receipt
16610
+ };
16611
+ });
16612
+ return mutate();
16613
+ }
16614
+ function rollbackPlanProjectLinkSqlite(input, db) {
16615
+ const d = db || getDatabase();
16616
+ return d.transaction(() => {
16617
+ guardPlanRowsSqlite([input.plan_id], d);
16618
+ const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
16619
+ if (priorRollback)
16620
+ return JSON.parse(priorRollback.payload);
16621
+ const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
16622
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
16623
+ 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 });
16624
+ }
16625
+ const plan = getPlan(input.plan_id, d);
16626
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
16627
+ 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 });
16628
+ }
16629
+ const tasks = exactTasks(plan.id, d);
16630
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
16631
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
16632
+ }
16633
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
16634
+ receipt.prior_plan_project_id,
16635
+ input.restored_at,
16636
+ plan.id,
16637
+ input.expected_plan_revision
16638
+ ]);
16639
+ for (const task of tasks) {
16640
+ const priorProjectId = receipt.prior_task_project_ids[task.id];
16641
+ if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
16642
+ 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 });
16643
+ }
16644
+ 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]);
16645
+ }
16646
+ const restoredPlan = getPlan(plan.id, d);
16647
+ const restoredTasks = exactTasks(plan.id, d);
16648
+ if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
16649
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
16650
+ }
16651
+ const result = {
16652
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
16653
+ action: "restored",
16654
+ plan: restoredPlan,
16655
+ tasks: restoredTasks,
16656
+ accepted_receipt_id: receipt.receipt_id,
16657
+ rollback_receipt_id: input.rollback_receipt_id,
16658
+ restored_at: input.restored_at
16659
+ };
16660
+ d.run(`INSERT INTO plan_project_link_rollback_receipts
16661
+ (rollback_receipt_id, accepted_receipt_id, payload, created_at)
16662
+ VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
16663
+ return result;
16664
+ })();
16665
+ }
16666
+
15853
16667
  // src/storage/sqlite-snapshot.ts
15854
16668
  init_database();
15855
16669
  init_audit();
@@ -16353,6 +17167,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
16353
17167
  update: (id, input) => updatePlan2(id, input, database()),
16354
17168
  delete: (id) => deletePlan(id, database())
16355
17169
  },
17170
+ planProjectLinks: {
17171
+ apply: (input) => applyPlanProjectLinkSqlite(input, database()),
17172
+ rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
17173
+ getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
17174
+ getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
17175
+ },
16356
17176
  agents: {
16357
17177
  register: (input) => registerAgent2(input, database()),
16358
17178
  get: (id) => getAgent(id, database()),
@@ -17103,7 +17923,7 @@ function canonicalize2(value) {
17103
17923
  return out;
17104
17924
  }
17105
17925
  function digestProjectRegistrationValue(value) {
17106
- return createHash4("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
17926
+ return createHash5("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
17107
17927
  }
17108
17928
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
17109
17929
  return `prk_${digestProjectRegistrationValue({