@hasna/todos 0.15.9 → 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 (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 +4282 -3088
  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 -1276
  16. package/dist/lib/plan-project-link-contract.d.ts +17 -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 +1153 -30
  21. package/dist/mcp.js +7 -3
  22. package/dist/project-registration.js +845 -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 +1177 -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 +834 -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
package/dist/mcp/index.js CHANGED
@@ -2038,6 +2038,29 @@ var init_migrations = __esm(() => {
2038
2038
  ${sqliteTodosProjectRegistrationSchemaSql()}
2039
2039
  INSERT OR IGNORE INTO _migrations (id) VALUES (69);
2040
2040
  COMMIT;
2041
+ `,
2042
+ `BEGIN;
2043
+ CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
2044
+ receipt_id TEXT PRIMARY KEY,
2045
+ idempotency_key TEXT NOT NULL UNIQUE,
2046
+ plan_id TEXT NOT NULL,
2047
+ project_id TEXT NOT NULL,
2048
+ payload_hash TEXT NOT NULL,
2049
+ payload TEXT NOT NULL,
2050
+ created_at TEXT NOT NULL
2051
+ );
2052
+ CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
2053
+ ON plan_project_link_receipts(plan_id, created_at);
2054
+
2055
+ CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
2056
+ rollback_receipt_id TEXT PRIMARY KEY,
2057
+ accepted_receipt_id TEXT NOT NULL UNIQUE
2058
+ REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
2059
+ payload TEXT NOT NULL,
2060
+ created_at TEXT NOT NULL
2061
+ );
2062
+ INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2063
+ COMMIT;
2041
2064
  `
2042
2065
  ];
2043
2066
  });
@@ -12399,6 +12422,14 @@ var init_checklists = __esm(() => {
12399
12422
  init_database();
12400
12423
  });
12401
12424
 
12425
+ // src/db/plan-row-serialization.ts
12426
+ function guardPlanRowsSqlite(planIds, db) {
12427
+ const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
12428
+ for (const id of ids) {
12429
+ db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
12430
+ }
12431
+ }
12432
+
12402
12433
  // src/lib/creator-identity.ts
12403
12434
  import { existsSync as existsSync6, rmSync } from "fs";
12404
12435
  import { join as join5 } from "path";
@@ -13781,9 +13812,23 @@ function sanitizeUpdateTaskInput(input) {
13781
13812
  metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
13782
13813
  };
13783
13814
  }
13784
- function createTask(input, db) {
13785
- input = sanitizeCreateTaskInput(input);
13786
- const d = db || getDatabase();
13815
+ function linkedPlanProjectId(planId, db) {
13816
+ if (!planId)
13817
+ return null;
13818
+ const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
13819
+ return row?.project_id ?? null;
13820
+ }
13821
+ function resolveCreateProjectForPlan(input, db) {
13822
+ const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
13823
+ if (!linkedProjectId)
13824
+ return input.project_id || null;
13825
+ if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
13826
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
13827
+ }
13828
+ return linkedProjectId;
13829
+ }
13830
+ function createTaskStored(input, d) {
13831
+ const effectiveProjectId = resolveCreateProjectForPlan(input, d);
13787
13832
  const timestamp2 = now();
13788
13833
  const tags = input.tags || [];
13789
13834
  const machineId = currentStorageMachineId(d);
@@ -13797,7 +13842,7 @@ function createTask(input, db) {
13797
13842
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
13798
13843
  id,
13799
13844
  null,
13800
- input.project_id || null,
13845
+ effectiveProjectId,
13801
13846
  input.parent_id || null,
13802
13847
  input.plan_id || null,
13803
13848
  input.task_list_id || null,
@@ -13847,7 +13892,15 @@ function createTask(input, db) {
13847
13892
  if (tags.length > 0) {
13848
13893
  insertTaskTags(id, tags, d);
13849
13894
  }
13850
- const task = getTask(id, d);
13895
+ return getTask(id, d);
13896
+ }
13897
+ function createTask(input, db) {
13898
+ input = sanitizeCreateTaskInput(input);
13899
+ const d = db || getDatabase();
13900
+ const task = input.plan_id ? d.transaction(() => {
13901
+ guardPlanRowsSqlite([input.plan_id], d);
13902
+ return createTaskStored(input, d);
13903
+ })() : createTaskStored(input, d);
13851
13904
  const payload = taskEventData(task);
13852
13905
  const databasePath = databasePathFromDatabase(d);
13853
13906
  dispatchWebhook2("task.created", payload, d).catch(() => {});
@@ -13947,6 +14000,10 @@ function listTasks(filter = {}, db) {
13947
14000
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
13948
14001
  params.push(filter.not_created_by);
13949
14002
  }
14003
+ if (filter.updated_after) {
14004
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
14005
+ params.push(filter.updated_after);
14006
+ }
13950
14007
  if (filter.session_id) {
13951
14008
  conditions.push("session_id = ?");
13952
14009
  params.push(filter.session_id);
@@ -14107,6 +14164,10 @@ function countTasks(filter = {}, db) {
14107
14164
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
14108
14165
  params.push(filter.not_created_by);
14109
14166
  }
14167
+ if (filter.updated_after) {
14168
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
14169
+ params.push(filter.updated_after);
14170
+ }
14110
14171
  if (filter.session_id) {
14111
14172
  conditions.push("session_id = ?");
14112
14173
  params.push(filter.session_id);
@@ -14146,7 +14207,7 @@ function countTasks(filter = {}, db) {
14146
14207
  const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
14147
14208
  return row.count;
14148
14209
  }
14149
- function updateTask(id, input, db) {
14210
+ function updateTaskStored(id, input, db) {
14150
14211
  const d = db || getDatabase();
14151
14212
  const task = getTask(id, d);
14152
14213
  if (!task)
@@ -14155,6 +14216,18 @@ function updateTask(id, input, db) {
14155
14216
  throw new VersionConflictError(id, input.version, task.version);
14156
14217
  }
14157
14218
  input = sanitizeUpdateTaskInput(input);
14219
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
14220
+ const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
14221
+ if (linkedProjectId) {
14222
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
14223
+ if (effectiveProjectId !== linkedProjectId) {
14224
+ if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
14225
+ input = { ...input, project_id: linkedProjectId };
14226
+ } else {
14227
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
14228
+ }
14229
+ }
14230
+ }
14158
14231
  const timestamp2 = now();
14159
14232
  const completionTimestamp = input.completed_at ?? timestamp2;
14160
14233
  const sets = ["version = version + 1", "updated_at = ?"];
@@ -14362,6 +14435,21 @@ function updateTask(id, input, db) {
14362
14435
  emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
14363
14436
  return updatedTask;
14364
14437
  }
14438
+ function updateTask(id, input, db) {
14439
+ const d = db || getDatabase();
14440
+ const before = getTask(id, d);
14441
+ if (!before)
14442
+ throw new TaskNotFoundError(id);
14443
+ const guardedPlanIds = [before.plan_id, input.plan_id];
14444
+ if (!guardedPlanIds.some(Boolean))
14445
+ return updateTaskStored(id, input, d);
14446
+ return d.transaction(() => {
14447
+ guardPlanRowsSqlite(guardedPlanIds, d);
14448
+ const current = getTask(id, d);
14449
+ guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
14450
+ return updateTaskStored(id, input, d);
14451
+ })();
14452
+ }
14365
14453
  function deleteTask(id, db) {
14366
14454
  const d = db || getDatabase();
14367
14455
  const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
@@ -15168,7 +15256,7 @@ function listPlans(projectId, db) {
15168
15256
  }
15169
15257
  return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
15170
15258
  }
15171
- function updatePlan(id, input, db) {
15259
+ function updatePlanStored(id, input, db) {
15172
15260
  const d = db || getDatabase();
15173
15261
  const plan = getPlan(id, d);
15174
15262
  if (!plan)
@@ -15213,6 +15301,13 @@ function updatePlan(id, input, db) {
15213
15301
  });
15214
15302
  return updated;
15215
15303
  }
15304
+ function updatePlan(id, input, db) {
15305
+ const d = db || getDatabase();
15306
+ return d.transaction(() => {
15307
+ guardPlanRowsSqlite([id], d);
15308
+ return updatePlanStored(id, input, d);
15309
+ })();
15310
+ }
15216
15311
  function deletePlan(id, db) {
15217
15312
  const d = db || getDatabase();
15218
15313
  const plan = getPlan(id, d);
@@ -35214,7 +35309,7 @@ var package_default;
35214
35309
  var init_package = __esm(() => {
35215
35310
  package_default = {
35216
35311
  name: "@hasna/todos",
35217
- version: "0.15.9",
35312
+ version: "0.15.10",
35218
35313
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35219
35314
  type: "module",
35220
35315
  main: "dist/index.js",
@@ -35256,6 +35351,10 @@ var init_package = __esm(() => {
35256
35351
  "./project-registration": {
35257
35352
  types: "./dist/project-registration.d.ts",
35258
35353
  import: "./dist/project-registration.js"
35354
+ },
35355
+ "./task-manifest": {
35356
+ types: "./dist/task-manifest.d.ts",
35357
+ import: "./dist/task-manifest.js"
35259
35358
  }
35260
35359
  },
35261
35360
  workspaces: [
@@ -35268,8 +35367,8 @@ var init_package = __esm(() => {
35268
35367
  "README.md"
35269
35368
  ],
35270
35369
  scripts: {
35271
- 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",
35272
- "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/*'",
35370
+ 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",
35371
+ "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/*'",
35273
35372
  migrate: "bun run src/server/index.ts migrate",
35274
35373
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
35275
35374
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -44590,6 +44689,243 @@ var init_cloud_client = __esm(() => {
44590
44689
  init_config();
44591
44690
  });
44592
44691
 
44692
+ // src/lib/plan-project-link-contract.ts
44693
+ import { createHash as createHash13 } from "crypto";
44694
+ function canonicalPlanProjectLinkJson(value) {
44695
+ if (value === null || typeof value !== "object")
44696
+ return JSON.stringify(value);
44697
+ if (Array.isArray(value))
44698
+ return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
44699
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
44700
+ }
44701
+ function planProjectLinkDigest(value) {
44702
+ return createHash13("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
44703
+ }
44704
+ function normalizePlanProjectLinkIdempotencyKey(value) {
44705
+ const key = value?.trim() ?? "";
44706
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
44707
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
44708
+ }
44709
+ return key;
44710
+ }
44711
+ function planProjectLinkReceiptId(idempotencyKey) {
44712
+ return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
44713
+ }
44714
+ function planProjectLinkRollbackReceiptId(receiptId) {
44715
+ return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
44716
+ }
44717
+ function planProjectLinkRequestHash(planId, projectId) {
44718
+ return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
44719
+ }
44720
+ function planProjectLinkResultDigest(plan, tasks) {
44721
+ return planProjectLinkDigest({
44722
+ plan_id: plan.id,
44723
+ plan_project_id: plan.project_id,
44724
+ tasks: tasks.map((task2) => ({ id: task2.id, plan_id: task2.plan_id, project_id: task2.project_id })).sort((left, right) => left.id.localeCompare(right.id))
44725
+ });
44726
+ }
44727
+ function assertPlanProjectLinkReceipt(value) {
44728
+ const receipt = value;
44729
+ 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") {
44730
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
44731
+ }
44732
+ return receipt;
44733
+ }
44734
+ var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1", PlanProjectLinkError;
44735
+ var init_plan_project_link_contract = __esm(() => {
44736
+ PlanProjectLinkError = class PlanProjectLinkError extends Error {
44737
+ code;
44738
+ details;
44739
+ constructor(code, message, details = {}) {
44740
+ super(message);
44741
+ this.code = code;
44742
+ this.details = details;
44743
+ this.name = "PlanProjectLinkError";
44744
+ }
44745
+ };
44746
+ });
44747
+
44748
+ // src/db/plan-project-links.ts
44749
+ function getPlanProjectLinkReceipt(receiptId, db) {
44750
+ const d = db || getDatabase();
44751
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
44752
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
44753
+ }
44754
+ function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
44755
+ const d = db || getDatabase();
44756
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
44757
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
44758
+ }
44759
+ function exactTasks(planId, db) {
44760
+ return listTasks({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
44761
+ }
44762
+ function currentResult(planId, projectId, receipt, db, action) {
44763
+ const plan = getPlan(planId, db);
44764
+ const project = getProject(projectId, db);
44765
+ if (!plan || !project) {
44766
+ 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 });
44767
+ }
44768
+ const tasks = exactTasks(planId, db);
44769
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
44770
+ 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 });
44771
+ }
44772
+ return { mode: "apply", action, plan, project, tasks, receipt };
44773
+ }
44774
+ function applyPlanProjectLinkSqlite(input, db) {
44775
+ const d = db || getDatabase();
44776
+ const mutate = d.transaction(() => {
44777
+ guardPlanRowsSqlite([input.plan_id], d);
44778
+ const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
44779
+ const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
44780
+ if (existingRow) {
44781
+ if (existingRow.payload_hash !== requestHash) {
44782
+ 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 });
44783
+ }
44784
+ const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
44785
+ if (rolledBack) {
44786
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
44787
+ }
44788
+ return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
44789
+ }
44790
+ const plan = getPlan(input.plan_id, d);
44791
+ if (!plan) {
44792
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
44793
+ }
44794
+ const project = getProject(input.project_id, d);
44795
+ if (!project) {
44796
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
44797
+ }
44798
+ if (plan.updated_at !== input.expected_plan_revision) {
44799
+ 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 });
44800
+ }
44801
+ if (project.updated_at !== input.expected_project_revision) {
44802
+ 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 });
44803
+ }
44804
+ if (plan.slug) {
44805
+ const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
44806
+ if (collision) {
44807
+ 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 });
44808
+ }
44809
+ }
44810
+ const beforeTasks = exactTasks(plan.id, d);
44811
+ const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task2) => task2.project_id === project.id);
44812
+ const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task2) => [task2.id, task2.project_id]));
44813
+ const timestamp4 = input.created_at;
44814
+ if (plan.project_id !== project.id) {
44815
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
44816
+ project.id,
44817
+ timestamp4,
44818
+ plan.id,
44819
+ input.expected_plan_revision
44820
+ ]);
44821
+ }
44822
+ for (const task2 of beforeTasks) {
44823
+ if (task2.project_id === project.id)
44824
+ continue;
44825
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp4, task2.id, plan.id]);
44826
+ }
44827
+ const linkedPlan = getPlan(plan.id, d);
44828
+ const linkedTasks = exactTasks(plan.id, d);
44829
+ if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task2) => task2.project_id !== project.id)) {
44830
+ 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 });
44831
+ }
44832
+ const receipt = {
44833
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
44834
+ receipt_id: input.receipt_id,
44835
+ idempotency_key: input.idempotency_key,
44836
+ plan_id: plan.id,
44837
+ project_id: project.id,
44838
+ prior_plan_project_id: plan.project_id,
44839
+ prior_task_project_ids: priorTaskProjectIds,
44840
+ task_ids: beforeTasks.map((task2) => task2.id),
44841
+ task_count: beforeTasks.length,
44842
+ result_plan_revision: linkedPlan.updated_at,
44843
+ result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
44844
+ rollback_supported: true,
44845
+ created_at: input.created_at
44846
+ };
44847
+ d.run(`INSERT INTO plan_project_link_receipts
44848
+ (receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
44849
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
44850
+ receipt.receipt_id,
44851
+ receipt.idempotency_key,
44852
+ receipt.plan_id,
44853
+ receipt.project_id,
44854
+ requestHash,
44855
+ JSON.stringify(receipt),
44856
+ receipt.created_at
44857
+ ]);
44858
+ return {
44859
+ mode: "apply",
44860
+ action: alreadyLinked ? "already_linked" : "linked",
44861
+ plan: linkedPlan,
44862
+ project,
44863
+ tasks: linkedTasks,
44864
+ receipt
44865
+ };
44866
+ });
44867
+ return mutate();
44868
+ }
44869
+ function rollbackPlanProjectLinkSqlite(input, db) {
44870
+ const d = db || getDatabase();
44871
+ return d.transaction(() => {
44872
+ guardPlanRowsSqlite([input.plan_id], d);
44873
+ const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
44874
+ if (priorRollback)
44875
+ return JSON.parse(priorRollback.payload);
44876
+ const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
44877
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
44878
+ 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 });
44879
+ }
44880
+ const plan = getPlan(input.plan_id, d);
44881
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
44882
+ 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 });
44883
+ }
44884
+ const tasks = exactTasks(plan.id, d);
44885
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task2, index) => task2.id !== receipt.task_ids[index])) {
44886
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
44887
+ }
44888
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
44889
+ receipt.prior_plan_project_id,
44890
+ input.restored_at,
44891
+ plan.id,
44892
+ input.expected_plan_revision
44893
+ ]);
44894
+ for (const task2 of tasks) {
44895
+ const priorProjectId = receipt.prior_task_project_ids[task2.id];
44896
+ if (priorProjectId === undefined && !(task2.id in receipt.prior_task_project_ids)) {
44897
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Receipt does not contain the exact prior project for every member task", { task_id: task2.id, receipt_id: receipt.receipt_id });
44898
+ }
44899
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [priorProjectId, input.restored_at, task2.id, plan.id]);
44900
+ }
44901
+ const restoredPlan = getPlan(plan.id, d);
44902
+ const restoredTasks = exactTasks(plan.id, d);
44903
+ if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task2) => task2.project_id !== receipt.prior_task_project_ids[task2.id])) {
44904
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
44905
+ }
44906
+ const result = {
44907
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
44908
+ action: "restored",
44909
+ plan: restoredPlan,
44910
+ tasks: restoredTasks,
44911
+ accepted_receipt_id: receipt.receipt_id,
44912
+ rollback_receipt_id: input.rollback_receipt_id,
44913
+ restored_at: input.restored_at
44914
+ };
44915
+ d.run(`INSERT INTO plan_project_link_rollback_receipts
44916
+ (rollback_receipt_id, accepted_receipt_id, payload, created_at)
44917
+ VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
44918
+ return result;
44919
+ })();
44920
+ }
44921
+ var init_plan_project_links = __esm(() => {
44922
+ init_plan_project_link_contract();
44923
+ init_database();
44924
+ init_plans();
44925
+ init_projects();
44926
+ init_tasks();
44927
+ });
44928
+
44593
44929
  // src/storage/sqlite-snapshot.ts
44594
44930
  function exportSqliteTodosStorageSnapshot(db) {
44595
44931
  const d = db ?? getDatabase();
@@ -45096,6 +45432,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45096
45432
  update: (id, input) => updatePlan(id, input, database()),
45097
45433
  delete: (id) => deletePlan(id, database())
45098
45434
  },
45435
+ planProjectLinks: {
45436
+ apply: (input) => applyPlanProjectLinkSqlite(input, database()),
45437
+ rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
45438
+ getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
45439
+ getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
45440
+ },
45099
45441
  agents: {
45100
45442
  register: (input) => registerAgent(input, database()),
45101
45443
  get: (id) => getAgent(id, database()),
@@ -45170,6 +45512,7 @@ var init_local_sqlite = __esm(() => {
45170
45512
  init_comments();
45171
45513
  init_database();
45172
45514
  init_integrity2();
45515
+ init_plan_project_links();
45173
45516
  init_sqlite_snapshot();
45174
45517
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45175
45518
  });
@@ -45200,6 +45543,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
45200
45543
  RETURNS text
45201
45544
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
45202
45545
  AS $$ SELECT unaccent('unaccent', $1) $$`,
45546
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
45547
+ RETURNS timestamptz
45548
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
45549
+ SET DateStyle TO 'ISO, YMD'
45550
+ AS $$
45551
+ BEGIN
45552
+ RETURN CASE
45553
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
45554
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
45555
+ END;
45556
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
45557
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
45558
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
45559
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
45560
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
45203
45561
  `ALTER TABLE ${tableName}
45204
45562
  ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
45205
45563
  GENERATED ALWAYS AS (
@@ -45919,7 +46277,7 @@ var init_shadow_runtime = __esm(() => {
45919
46277
  });
45920
46278
 
45921
46279
  // src/db/api-keys.ts
45922
- import { createHash as createHash13, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
46280
+ import { createHash as createHash14, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
45923
46281
  function rowToRecord(row) {
45924
46282
  return {
45925
46283
  id: row.id,
@@ -45933,7 +46291,7 @@ function rowToRecord(row) {
45933
46291
  };
45934
46292
  }
45935
46293
  function hashApiKey(key) {
45936
- return createHash13("sha256").update(key).digest("hex");
46294
+ return createHash14("sha256").update(key).digest("hex");
45937
46295
  }
45938
46296
  function safeEqualHex(a, b) {
45939
46297
  if (a.length !== b.length)
@@ -45941,8 +46299,8 @@ function safeEqualHex(a, b) {
45941
46299
  return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
45942
46300
  }
45943
46301
  function safeEqualStrings(a, b) {
45944
- const ah = createHash13("sha256").update(a, "utf8").digest();
45945
- const bh = createHash13("sha256").update(b, "utf8").digest();
46302
+ const ah = createHash14("sha256").update(a, "utf8").digest();
46303
+ const bh = createHash14("sha256").update(b, "utf8").digest();
45946
46304
  return timingSafeEqual3(ah, bh);
45947
46305
  }
45948
46306
  function hasActiveApiKeys(db) {
@@ -46034,6 +46392,12 @@ function createPostgresTodosStorageAdapter(options) {
46034
46392
  update: (id, input) => updatePlan2(id, input, store),
46035
46393
  delete: (id, context) => store.deletePlan(id, context)
46036
46394
  },
46395
+ planProjectLinks: {
46396
+ apply: (input, context) => store.applyPlanProjectLink(input, context),
46397
+ rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
46398
+ getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
46399
+ getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
46400
+ },
46037
46401
  agents: {
46038
46402
  register: (input, context) => registerAgent2(input, store, context),
46039
46403
  get: (id) => store.get("agents", id),
@@ -46216,6 +46580,9 @@ class PostgresJsonRecordStore {
46216
46580
  conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
46217
46581
  if (filter.not_created_by !== undefined)
46218
46582
  conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
46583
+ if (filter.updated_after !== undefined) {
46584
+ conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
46585
+ }
46219
46586
  if (filter.session_id !== undefined)
46220
46587
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
46221
46588
  if (filter.tags?.length) {
@@ -46400,6 +46767,115 @@ class PostgresJsonRecordStore {
46400
46767
  }
46401
46768
  return value;
46402
46769
  }
46770
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
46771
+ const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
46772
+ if (planIds.length === 0)
46773
+ return this.upsert("tasks", value, context);
46774
+ await this.ensureSchema();
46775
+ const updatedAt = value.updated_at;
46776
+ const targetPlanId = value.plan_id;
46777
+ const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
46778
+ locked_plans AS MATERIALIZED (
46779
+ SELECT object_id, payload FROM ${this.tableName}
46780
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
46781
+ AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
46782
+ ORDER BY object_id
46783
+ FOR UPDATE
46784
+ ), validation AS (
46785
+ SELECT
46786
+ (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
46787
+ ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
46788
+ (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
46789
+ ), guarded AS (
46790
+ SELECT
46791
+ validation.*,
46792
+ ($9::boolean AND validation.target_project_id IS NOT NULL
46793
+ AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
46794
+ CASE
46795
+ WHEN validation.target_project_id IS NULL THEN $3::jsonb
46796
+ ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
46797
+ END AS payload
46798
+ FROM validation
46799
+ ), stored AS (
46800
+ INSERT INTO ${this.tableName} (
46801
+ service, object_type, object_id, payload, updated_at,
46802
+ deleted_at, source_machine_id, version
46803
+ )
46804
+ SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
46805
+ FROM guarded
46806
+ WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
46807
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
46808
+ payload = EXCLUDED.payload,
46809
+ updated_at = EXCLUDED.updated_at,
46810
+ deleted_at = NULL,
46811
+ source_machine_id = EXCLUDED.source_machine_id,
46812
+ version = EXCLUDED.version
46813
+ WHERE ${this.tableName}.updated_at IS NULL
46814
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
46815
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
46816
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
46817
+ RETURNING payload
46818
+ )
46819
+ SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
46820
+ (SELECT payload FROM stored) AS payload
46821
+ FROM guarded`, [
46822
+ this.service,
46823
+ value.id,
46824
+ jsonbParam(value),
46825
+ updatedAt,
46826
+ context.requestId ?? this.sourceMachineId ?? null,
46827
+ numberValue3(value.version),
46828
+ jsonbParam(planIds),
46829
+ targetPlanId,
46830
+ explicitProject
46831
+ ]);
46832
+ const row = result.rows[0];
46833
+ if (!row?.all_plans_found || !row.target_plan_found) {
46834
+ 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 });
46835
+ }
46836
+ if (row.project_conflict) {
46837
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
46838
+ }
46839
+ if (!row.payload) {
46840
+ return await requireRecord("tasks", value.id, this);
46841
+ }
46842
+ return payloadRecord2(row.payload);
46843
+ }
46844
+ async updatePlanWithProjectLinkGuard(value, context = {}) {
46845
+ await this.ensureSchema();
46846
+ const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
46847
+ SELECT payload FROM ${this.tableName}
46848
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
46849
+ FOR UPDATE
46850
+ ), stored AS (
46851
+ UPDATE ${this.tableName} r SET
46852
+ payload = jsonb_set(
46853
+ $3::jsonb,
46854
+ '{project_id}',
46855
+ COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
46856
+ true
46857
+ ),
46858
+ updated_at = $4::timestamptz,
46859
+ deleted_at = NULL,
46860
+ source_machine_id = COALESCE($5, r.source_machine_id),
46861
+ version = COALESCE(r.version, 0) + 1
46862
+ FROM locked_plan
46863
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
46864
+ RETURNING r.payload
46865
+ )
46866
+ SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
46867
+ (SELECT payload FROM stored) AS payload`, [
46868
+ this.service,
46869
+ value.id,
46870
+ jsonbParam(value),
46871
+ value.updated_at,
46872
+ context.requestId ?? this.sourceMachineId ?? null
46873
+ ]);
46874
+ const row = result.rows[0];
46875
+ if (!row?.plan_found || !row.payload)
46876
+ throw new PlanNotFoundError(value.id);
46877
+ return payloadRecord2(row.payload);
46878
+ }
46403
46879
  async createTemplateWithTasks(template, tasks, context = {}) {
46404
46880
  await this.ensureSchema();
46405
46881
  const records = [
@@ -46638,6 +47114,333 @@ class PostgresJsonRecordStore {
46638
47114
  version: numberValue3(existing["version"])
46639
47115
  }, context);
46640
47116
  }
47117
+ async getPlanProjectLinkReceipt(receiptId) {
47118
+ const value = await this.get("plan_project_link_receipts", receiptId);
47119
+ return value ? assertPlanProjectLinkReceipt(value) : null;
47120
+ }
47121
+ async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
47122
+ await this.ensureSchema();
47123
+ const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
47124
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
47125
+ AND payload->>'idempotency_key' = $2
47126
+ LIMIT 2`, [this.service, idempotencyKey]);
47127
+ if (result.rows.length > 1) {
47128
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
47129
+ }
47130
+ return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
47131
+ }
47132
+ async currentPlanProjectLinkResult(receipt, action) {
47133
+ const [plan, project, tasks] = await Promise.all([
47134
+ this.get("plans", receipt.plan_id),
47135
+ this.get("projects", receipt.project_id),
47136
+ this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
47137
+ ]);
47138
+ const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
47139
+ if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
47140
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
47141
+ }
47142
+ return { mode: "apply", action, plan, project, tasks: sortedTasks3, receipt };
47143
+ }
47144
+ async applyPlanProjectLink(input, context = {}) {
47145
+ await this.ensureSchema();
47146
+ const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
47147
+ if (existing) {
47148
+ if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
47149
+ 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 });
47150
+ }
47151
+ const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
47152
+ if (rolledBack) {
47153
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
47154
+ }
47155
+ return this.currentPlanProjectLinkResult(existing, "already_linked");
47156
+ }
47157
+ const [plan, project, tasks, scopedPlans] = await Promise.all([
47158
+ this.get("plans", input.plan_id),
47159
+ this.get("projects", input.project_id),
47160
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
47161
+ this.list("plans")
47162
+ ]);
47163
+ if (!plan)
47164
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
47165
+ if (!project)
47166
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
47167
+ if (plan.updated_at !== input.expected_plan_revision) {
47168
+ 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 });
47169
+ }
47170
+ if (project.updated_at !== input.expected_project_revision) {
47171
+ 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 });
47172
+ }
47173
+ const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
47174
+ if (collision) {
47175
+ 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 });
47176
+ }
47177
+ const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
47178
+ const priorTaskProjectIds = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
47179
+ const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
47180
+ const projectedTasks = sortedTasks3.map((task2) => task2.project_id === project.id ? task2 : { ...task2, project_id: project.id, updated_at: input.created_at, version: task2.version + 1 });
47181
+ const alreadyLinked = plan.project_id === project.id && sortedTasks3.every((task2) => task2.project_id === project.id);
47182
+ const receipt = {
47183
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
47184
+ receipt_id: input.receipt_id,
47185
+ idempotency_key: input.idempotency_key,
47186
+ plan_id: plan.id,
47187
+ project_id: project.id,
47188
+ prior_plan_project_id: plan.project_id,
47189
+ prior_task_project_ids: priorTaskProjectIds,
47190
+ task_ids: sortedTasks3.map((task2) => task2.id),
47191
+ task_count: sortedTasks3.length,
47192
+ result_plan_revision: projectedPlan.updated_at,
47193
+ result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
47194
+ rollback_supported: true,
47195
+ created_at: input.created_at
47196
+ };
47197
+ let mutation;
47198
+ try {
47199
+ mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
47200
+ target_plan AS MATERIALIZED (
47201
+ SELECT payload FROM ${this.tableName}
47202
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
47203
+ FOR UPDATE
47204
+ ), target_project AS (
47205
+ SELECT payload FROM ${this.tableName}
47206
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
47207
+ FOR UPDATE
47208
+ ), member_tasks AS MATERIALIZED (
47209
+ SELECT object_id, payload FROM ${this.tableName}
47210
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
47211
+ AND payload->>'plan_id' = $2
47212
+ AND EXISTS (SELECT 1 FROM target_plan)
47213
+ FOR UPDATE
47214
+ ), existing AS (
47215
+ SELECT payload FROM ${this.tableName}
47216
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts'
47217
+ AND object_id = $6 AND deleted_at IS NULL
47218
+ FOR UPDATE
47219
+ ), collision AS (
47220
+ SELECT 1 FROM ${this.tableName} r, target_plan p
47221
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
47222
+ AND r.object_id <> $2 AND r.payload->>'project_id' = $3
47223
+ AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
47224
+ AND p.payload->>'slug' IS NOT NULL
47225
+ LIMIT 1
47226
+ ), checks AS (
47227
+ SELECT
47228
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
47229
+ EXISTS (SELECT 1 FROM target_project) AS project_found,
47230
+ COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
47231
+ COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
47232
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
47233
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
47234
+ EXISTS (SELECT 1 FROM collision) AS collision,
47235
+ EXISTS (SELECT 1 FROM existing) AS has_existing
47236
+ ), updated_plan AS (
47237
+ UPDATE ${this.tableName} r SET
47238
+ payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
47239
+ updated_at = $10::timestamptz,
47240
+ version = COALESCE(r.version, 0) + 1,
47241
+ source_machine_id = COALESCE($11, r.source_machine_id)
47242
+ FROM checks
47243
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
47244
+ AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
47245
+ AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
47246
+ RETURNING r.payload
47247
+ ), updated_tasks AS (
47248
+ UPDATE ${this.tableName} r SET
47249
+ payload = r.payload || jsonb_build_object(
47250
+ 'project_id', $3::text,
47251
+ 'updated_at', $10::text,
47252
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
47253
+ ),
47254
+ updated_at = $10::timestamptz,
47255
+ version = COALESCE(r.version, 0) + 1,
47256
+ source_machine_id = COALESCE($11, r.source_machine_id)
47257
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
47258
+ AND r.payload->>'plan_id' = $2
47259
+ AND r.payload->>'project_id' IS DISTINCT FROM $3
47260
+ AND EXISTS (SELECT 1 FROM updated_plan)
47261
+ RETURNING 1
47262
+ ), task_gate AS (
47263
+ SELECT count(*) AS count FROM updated_tasks
47264
+ ), inserted AS (
47265
+ INSERT INTO ${this.tableName}
47266
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
47267
+ SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
47268
+ FROM checks, task_gate
47269
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
47270
+ RETURNING payload
47271
+ ) SELECT
47272
+ checks.plan_found,
47273
+ checks.project_found,
47274
+ checks.plan_revision_ok,
47275
+ checks.project_revision_ok,
47276
+ checks.membership_ok,
47277
+ checks.collision,
47278
+ (SELECT payload FROM existing) AS existing_receipt,
47279
+ (SELECT payload FROM inserted) AS inserted_receipt
47280
+ FROM checks`, [
47281
+ this.service,
47282
+ plan.id,
47283
+ project.id,
47284
+ input.expected_plan_revision,
47285
+ input.expected_project_revision,
47286
+ receipt.receipt_id,
47287
+ jsonbParam(receipt),
47288
+ jsonbParam(priorTaskProjectIds),
47289
+ jsonbParam(receipt.task_ids),
47290
+ input.created_at,
47291
+ this.machineId(context)
47292
+ ]);
47293
+ } catch (error) {
47294
+ if (isPostgresUniqueViolation(error)) {
47295
+ const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
47296
+ if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
47297
+ return this.currentPlanProjectLinkResult(raced, "already_linked");
47298
+ }
47299
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
47300
+ }
47301
+ throw error;
47302
+ }
47303
+ const row = mutation.rows[0];
47304
+ if (!row?.plan_found)
47305
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
47306
+ if (!row.project_found)
47307
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
47308
+ if (!row.plan_revision_ok)
47309
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
47310
+ if (!row.project_revision_ok)
47311
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
47312
+ if (!row.membership_ok)
47313
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
47314
+ if (row.collision)
47315
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
47316
+ const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
47317
+ if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
47318
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
47319
+ }
47320
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
47321
+ }
47322
+ async rollbackPlanProjectLink(input, context = {}) {
47323
+ await this.ensureSchema();
47324
+ const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
47325
+ if (existingRollback)
47326
+ return existingRollback;
47327
+ const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
47328
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
47329
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
47330
+ }
47331
+ const [plan, tasks] = await Promise.all([
47332
+ this.get("plans", input.plan_id),
47333
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
47334
+ ]);
47335
+ const sortedTasks3 = tasks.sort((left, right) => left.id.localeCompare(right.id));
47336
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
47337
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
47338
+ }
47339
+ if (planProjectLinkResultDigest(plan, sortedTasks3) !== receipt.result_digest) {
47340
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
47341
+ }
47342
+ const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
47343
+ const projectedTasks = sortedTasks3.map((task2) => ({
47344
+ ...task2,
47345
+ project_id: receipt.prior_task_project_ids[task2.id] ?? null,
47346
+ updated_at: input.restored_at,
47347
+ version: task2.version + 1
47348
+ }));
47349
+ const rollback = {
47350
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
47351
+ action: "restored",
47352
+ plan: projectedPlan,
47353
+ tasks: projectedTasks,
47354
+ accepted_receipt_id: receipt.receipt_id,
47355
+ rollback_receipt_id: input.rollback_receipt_id,
47356
+ restored_at: input.restored_at
47357
+ };
47358
+ const currentTaskProjects = Object.fromEntries(sortedTasks3.map((task2) => [task2.id, task2.project_id]));
47359
+ const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
47360
+ target_plan AS MATERIALIZED (
47361
+ SELECT payload FROM ${this.tableName}
47362
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
47363
+ FOR UPDATE
47364
+ ), member_tasks AS MATERIALIZED (
47365
+ SELECT object_id, payload FROM ${this.tableName}
47366
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
47367
+ AND payload->>'plan_id' = $2
47368
+ AND EXISTS (SELECT 1 FROM target_plan)
47369
+ FOR UPDATE
47370
+ ), existing AS (
47371
+ SELECT payload FROM ${this.tableName}
47372
+ WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
47373
+ AND object_id = $5 AND deleted_at IS NULL
47374
+ FOR UPDATE
47375
+ ), checks AS (
47376
+ SELECT
47377
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
47378
+ COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
47379
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
47380
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
47381
+ EXISTS (SELECT 1 FROM existing) AS has_existing
47382
+ ), updated_plan AS (
47383
+ UPDATE ${this.tableName} r SET
47384
+ payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
47385
+ updated_at = $10::timestamptz,
47386
+ version = COALESCE(r.version, 0) + 1,
47387
+ source_machine_id = COALESCE($11, r.source_machine_id)
47388
+ FROM checks
47389
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
47390
+ AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
47391
+ RETURNING 1
47392
+ ), updated_tasks AS (
47393
+ UPDATE ${this.tableName} r SET
47394
+ payload = r.payload || jsonb_build_object(
47395
+ 'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
47396
+ 'updated_at', $10::text,
47397
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
47398
+ ),
47399
+ updated_at = $10::timestamptz,
47400
+ version = COALESCE(r.version, 0) + 1,
47401
+ source_machine_id = COALESCE($11, r.source_machine_id)
47402
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
47403
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
47404
+ RETURNING 1
47405
+ ), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
47406
+ INSERT INTO ${this.tableName}
47407
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
47408
+ SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
47409
+ FROM checks, task_gate
47410
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
47411
+ RETURNING payload
47412
+ ) SELECT
47413
+ checks.plan_found,
47414
+ checks.plan_revision_ok,
47415
+ checks.membership_ok,
47416
+ (SELECT payload FROM existing) AS existing_rollback,
47417
+ (SELECT payload FROM inserted) AS inserted_rollback
47418
+ FROM checks`, [
47419
+ this.service,
47420
+ input.plan_id,
47421
+ input.expected_plan_revision,
47422
+ input.receipt_id,
47423
+ input.rollback_receipt_id,
47424
+ jsonbParam(rollback),
47425
+ jsonbParam(currentTaskProjects),
47426
+ jsonbParam(receipt.task_ids),
47427
+ jsonbParam(receipt.prior_plan_project_id),
47428
+ input.restored_at,
47429
+ this.machineId(context),
47430
+ jsonbParam(receipt.prior_task_project_ids)
47431
+ ]);
47432
+ const row = result.rows[0];
47433
+ if (!row?.plan_found)
47434
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
47435
+ if (!row.plan_revision_ok)
47436
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
47437
+ if (!row.membership_ok)
47438
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
47439
+ const accepted = row.existing_rollback ?? row.inserted_rollback;
47440
+ if (!accepted)
47441
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
47442
+ return accepted;
47443
+ }
46641
47444
  async deletePlan(id, context = {}) {
46642
47445
  await this.ensureSchema();
46643
47446
  const timestamp4 = new Date().toISOString();
@@ -46732,11 +47535,17 @@ class PostgresJsonRecordStore {
46732
47535
  }
46733
47536
  async function createTask3(input, store, context) {
46734
47537
  const timestamp4 = new Date().toISOString();
46735
- const shortId = input.project_id ? await nextTaskShortId2(input.project_id, store, context) : null;
47538
+ const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
47539
+ const requestedProjectId = input.project_id ?? context?.projectId ?? null;
47540
+ if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
47541
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
47542
+ }
47543
+ const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
47544
+ const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
46736
47545
  const task2 = {
46737
47546
  id: randomUUID3(),
46738
47547
  short_id: shortId,
46739
- project_id: input.project_id ?? context?.projectId ?? null,
47548
+ project_id: effectiveProjectId,
46740
47549
  parent_id: input.parent_id ?? null,
46741
47550
  plan_id: input.plan_id ?? null,
46742
47551
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -46790,15 +47599,27 @@ async function createTask3(input, store, context) {
46790
47599
  synced_at: null,
46791
47600
  archived_at: null
46792
47601
  };
46793
- await store.upsert("tasks", task2, context);
46794
- await logTaskChange2(task2.id, "created", "status", null, task2.status, task2.assigned_by ?? task2.agent_id, store, context);
46795
- return task2;
47602
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, task2.plan_id ? [task2.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
47603
+ await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
47604
+ return storedTask;
46796
47605
  }
46797
47606
  async function updateTask2(id, input, store) {
46798
47607
  const existing = await requireRecord("tasks", id, store);
46799
47608
  if (existing.version !== input.version) {
46800
47609
  throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
46801
47610
  }
47611
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
47612
+ const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
47613
+ if (linkedPlan?.project_id) {
47614
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
47615
+ if (effectiveProjectId !== linkedPlan.project_id) {
47616
+ if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
47617
+ input = { ...input, project_id: linkedPlan.project_id };
47618
+ } else {
47619
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
47620
+ }
47621
+ }
47622
+ }
46802
47623
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
46803
47624
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
46804
47625
  const task2 = {
@@ -46814,8 +47635,7 @@ async function updateTask2(id, input, store) {
46814
47635
  created_by: existing.created_by,
46815
47636
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
46816
47637
  };
46817
- await store.upsert("tasks", task2);
46818
- return task2;
47638
+ return store.upsertTaskWithPlanMembershipGuard(task2, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
46819
47639
  }
46820
47640
  async function startTask2(id, agentId, store) {
46821
47641
  const task2 = await requireRecord("tasks", id, store);
@@ -46879,8 +47699,7 @@ async function patchTask(task2, patch, store) {
46879
47699
  version: task2.version + 1,
46880
47700
  updated_at: new Date().toISOString()
46881
47701
  };
46882
- await store.upsert("tasks", updated);
46883
- return updated;
47702
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task2.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
46884
47703
  }
46885
47704
  function sameCloudLockHolder(stored, incoming) {
46886
47705
  if (!stored || !incoming)
@@ -47142,7 +47961,12 @@ async function updatePlan2(id, input, store) {
47142
47961
  excludeId: id
47143
47962
  });
47144
47963
  }
47145
- return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
47964
+ return store.updatePlanWithProjectLinkGuard({
47965
+ ...plan,
47966
+ ...patch,
47967
+ project_id: plan.project_id,
47968
+ updated_at: new Date().toISOString()
47969
+ });
47146
47970
  }
47147
47971
  function matchAgentByName(agents, name, options) {
47148
47972
  const target = normalizeAgentNameInput(name);
@@ -47530,6 +48354,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
47530
48354
  var init_postgres_adapter = __esm(() => {
47531
48355
  init_types();
47532
48356
  init_creator_identity();
48357
+ init_plan_project_link_contract();
47533
48358
  init_postgres_sync();
47534
48359
  init_integrity();
47535
48360
  init_redaction();
@@ -49020,7 +49845,7 @@ var init_sqlite = __esm(() => {
49020
49845
  });
49021
49846
 
49022
49847
  // src/project-registration/authority.ts
49023
- import { createHash as createHash14 } from "crypto";
49848
+ import { createHash as createHash15 } from "crypto";
49024
49849
  function canonicalProjectRegistrationJson(value) {
49025
49850
  return JSON.stringify(canonicalize2(value));
49026
49851
  }
@@ -49038,7 +49863,7 @@ function canonicalize2(value) {
49038
49863
  return out;
49039
49864
  }
49040
49865
  function digestProjectRegistrationValue(value) {
49041
- return createHash14("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
49866
+ return createHash15("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
49042
49867
  }
49043
49868
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
49044
49869
  return `prk_${digestProjectRegistrationValue({
@@ -51526,6 +52351,9 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51526
52351
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
51527
52352
  TaskComment: taskCommentSchema,
51528
52353
  Plan: planSchema,
52354
+ PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
52355
+ PlanProjectLinkResult: planProjectLinkResultSchema,
52356
+ PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
51529
52357
  Template: templateSchema,
51530
52358
  TemplateTask: templateTaskSchema,
51531
52359
  TemplateVariable: templateVariableSchema,
@@ -51539,6 +52367,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51539
52367
  status: { type: "string" },
51540
52368
  priority: { type: "string" },
51541
52369
  project_id: { type: "string" },
52370
+ plan_id: { type: "string" },
51542
52371
  assigned_to: { type: "string" },
51543
52372
  agent_id: { type: "string" },
51544
52373
  tags: { type: "array", items: { type: "string" } }
@@ -51553,6 +52382,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51553
52382
  priority: { type: "string" },
51554
52383
  assigned_to: { type: "string" },
51555
52384
  project_id: { type: "string", nullable: true },
52385
+ plan_id: { type: "string", nullable: true },
51556
52386
  task_list_id: { type: "string", nullable: true },
51557
52387
  version: { type: "number" }
51558
52388
  }
@@ -51624,6 +52454,32 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51624
52454
  expected_task_list_revision: { type: "string", minLength: 1 }
51625
52455
  }
51626
52456
  },
52457
+ PlanProjectLinkApplyInput: {
52458
+ type: "object",
52459
+ additionalProperties: false,
52460
+ required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
52461
+ properties: {
52462
+ project_id: { type: "string", minLength: 1 },
52463
+ expected_plan_revision: { type: "string", minLength: 1 },
52464
+ expected_project_revision: { type: "string", minLength: 1 },
52465
+ idempotency_key: {
52466
+ type: "string",
52467
+ minLength: 8,
52468
+ maxLength: 128,
52469
+ pattern: "^[A-Za-z0-9._:-]+$"
52470
+ }
52471
+ }
52472
+ },
52473
+ PlanProjectLinkRollbackInput: {
52474
+ type: "object",
52475
+ additionalProperties: false,
52476
+ required: ["project_id", "receipt_id", "expected_plan_revision"],
52477
+ properties: {
52478
+ project_id: { type: "string", minLength: 1 },
52479
+ receipt_id: { type: "string", minLength: 1 },
52480
+ expected_plan_revision: { type: "string", minLength: 1 }
52481
+ }
52482
+ },
51627
52483
  ErrorResponse: {
51628
52484
  type: "object",
51629
52485
  required: ["error"],
@@ -52507,6 +53363,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
52507
53363
  { name: "assigned_to", in: "query", schema: { type: "string" } },
52508
53364
  { name: "agent_id", in: "query", schema: { type: "string" } },
52509
53365
  { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
53366
+ {
53367
+ name: "updated_after",
53368
+ in: "query",
53369
+ schema: { type: "string", format: "date-time" },
53370
+ description: "Since-cursor. Returns only tasks whose updated_at is strictly after this instant, and `total` respects it too. " + "Intended for pollers: re-read what changed instead of the whole table. Must be a full RFC 3339 date-time with " + "an explicit offset (e.g. 2026-08-07T12:00:00Z or 2026-08-07T12:00:00+03:00); a reduced-precision value such as " + "`2026` or `2026-08`, or any other malformed value, is rejected with 400 rather than ignored. Timestamps stored " + "without an offset are read as UTC on every backend."
53371
+ },
52510
53372
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
52511
53373
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
52512
53374
  ],
@@ -52835,6 +53697,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
52835
53697
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
52836
53698
  }
52837
53699
  },
53700
+ "/v1/plans/{id}/project-link": {
53701
+ get: {
53702
+ operationId: "planPlanProjectLink",
53703
+ summary: "Plan atomic linkage of an existing plan and every current member task to a project",
53704
+ parameters: [
53705
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
53706
+ { name: "project_id", in: "query", required: true, schema: { type: "string" } }
53707
+ ],
53708
+ responses: {
53709
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
53710
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
53711
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
53712
+ }
53713
+ },
53714
+ post: {
53715
+ operationId: "applyPlanProjectLink",
53716
+ summary: "Atomically and idempotently link an existing plan and every current member task to a project",
53717
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
53718
+ requestBody: {
53719
+ required: true,
53720
+ content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
53721
+ },
53722
+ responses: {
53723
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
53724
+ "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
53725
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
53726
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
53727
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
53728
+ }
53729
+ }
53730
+ },
53731
+ "/v1/plans/{id}/project-link/rollback": {
53732
+ post: {
53733
+ operationId: "rollbackPlanProjectLink",
53734
+ summary: "Conditionally restore every exact prior project link from an accepted receipt",
53735
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
53736
+ requestBody: {
53737
+ required: true,
53738
+ content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
53739
+ },
53740
+ responses: {
53741
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
53742
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
53743
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
53744
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
53745
+ }
53746
+ }
53747
+ },
52838
53748
  "/v1/templates": {
52839
53749
  get: {
52840
53750
  operationId: "listTemplates",
@@ -53052,7 +53962,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
53052
53962
  }
53053
53963
  };
53054
53964
  }
53055
- var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
53965
+ var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
53056
53966
  var init_openapi = __esm(() => {
53057
53967
  init_package_version();
53058
53968
  init_types();
@@ -53204,6 +54114,75 @@ var init_openapi = __esm(() => {
53204
54114
  updated_at: { type: "string", format: "date-time" }
53205
54115
  }
53206
54116
  };
54117
+ planProjectLinkReceiptSchema = {
54118
+ type: "object",
54119
+ additionalProperties: false,
54120
+ required: [
54121
+ "schema_version",
54122
+ "receipt_id",
54123
+ "idempotency_key",
54124
+ "plan_id",
54125
+ "project_id",
54126
+ "prior_plan_project_id",
54127
+ "prior_task_project_ids",
54128
+ "task_ids",
54129
+ "task_count",
54130
+ "result_plan_revision",
54131
+ "result_digest",
54132
+ "rollback_supported",
54133
+ "created_at"
54134
+ ],
54135
+ properties: {
54136
+ schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
54137
+ receipt_id: { type: "string" },
54138
+ idempotency_key: { type: "string" },
54139
+ plan_id: { type: "string" },
54140
+ project_id: { type: "string" },
54141
+ prior_plan_project_id: { type: "string", nullable: true },
54142
+ prior_task_project_ids: {
54143
+ type: "object",
54144
+ additionalProperties: { type: "string", nullable: true }
54145
+ },
54146
+ task_ids: { type: "array", items: { type: "string" } },
54147
+ task_count: { type: "integer", minimum: 0 },
54148
+ result_plan_revision: { type: "string" },
54149
+ result_digest: { type: "string" },
54150
+ rollback_supported: { type: "boolean", enum: [true] },
54151
+ created_at: { type: "string", format: "date-time" }
54152
+ }
54153
+ };
54154
+ planProjectLinkResultSchema = {
54155
+ type: "object",
54156
+ additionalProperties: false,
54157
+ required: ["mode", "action", "plan", "project", "tasks", "receipt"],
54158
+ properties: {
54159
+ mode: { type: "string", enum: ["plan", "apply"] },
54160
+ action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
54161
+ plan: { $ref: "#/components/schemas/Plan" },
54162
+ project: { $ref: "#/components/schemas/Project" },
54163
+ tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
54164
+ receipt: {
54165
+ oneOf: [
54166
+ { $ref: "#/components/schemas/PlanProjectLinkReceipt" },
54167
+ { type: "null" }
54168
+ ]
54169
+ }
54170
+ }
54171
+ };
54172
+ planProjectLinkRollbackResultSchema = {
54173
+ type: "object",
54174
+ additionalProperties: false,
54175
+ required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
54176
+ properties: {
54177
+ schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
54178
+ action: { type: "string", enum: ["restored"] },
54179
+ plan: { $ref: "#/components/schemas/Plan" },
54180
+ tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
54181
+ accepted_receipt_id: { type: "string" },
54182
+ rollback_receipt_id: { type: "string" },
54183
+ restored_at: { type: "string", format: "date-time" }
54184
+ }
54185
+ };
53207
54186
  templateTaskSchema = {
53208
54187
  type: "object",
53209
54188
  required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
@@ -53408,7 +54387,7 @@ function decodeCommentCursor(value) {
53408
54387
  var MAX_COMMENT_CURSOR_LENGTH = 1024;
53409
54388
 
53410
54389
  // src/lib/project-task-list-ensure.ts
53411
- import { createHash as createHash15 } from "crypto";
54390
+ import { createHash as createHash16 } from "crypto";
53412
54391
  function canonicalJson(value) {
53413
54392
  if (value === null || typeof value !== "object")
53414
54393
  return JSON.stringify(value);
@@ -53417,7 +54396,7 @@ function canonicalJson(value) {
53417
54396
  return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
53418
54397
  }
53419
54398
  function digest(value) {
53420
- return createHash15("sha256").update(canonicalJson(value)).digest("hex");
54399
+ return createHash16("sha256").update(canonicalJson(value)).digest("hex");
53421
54400
  }
53422
54401
  function deriveIdempotencyKey(projectId, slug) {
53423
54402
  return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
@@ -53662,6 +54641,75 @@ var init_project_task_list_ensure = __esm(() => {
53662
54641
  };
53663
54642
  });
53664
54643
 
54644
+ // src/lib/plan-project-link.ts
54645
+ async function exactPlanProjectLinkState(store, planId, projectId) {
54646
+ const [plan, project] = await Promise.all([
54647
+ store.plans.get(planId),
54648
+ store.projects.get(projectId)
54649
+ ]);
54650
+ if (!plan) {
54651
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
54652
+ }
54653
+ if (!project) {
54654
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
54655
+ }
54656
+ const [tasks, projectPlans] = await Promise.all([
54657
+ store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
54658
+ store.plans.list(project.id)
54659
+ ]);
54660
+ const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
54661
+ if (collision) {
54662
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan already owns this slug in the destination project", { plan_id: plan.id, project_id: project.id, conflicting_plan_id: collision.id, slug: plan.slug });
54663
+ }
54664
+ return {
54665
+ plan,
54666
+ project,
54667
+ tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
54668
+ };
54669
+ }
54670
+ async function planPlanProjectLink(store, planId, projectId) {
54671
+ const state = await exactPlanProjectLinkState(store, planId, projectId);
54672
+ const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task2) => task2.project_id === state.project.id);
54673
+ return {
54674
+ mode: "plan",
54675
+ action: alreadyLinked ? "already_linked" : "would_link",
54676
+ ...state,
54677
+ receipt: null
54678
+ };
54679
+ }
54680
+ async function applyPlanProjectLink(store, planId, projectId, options) {
54681
+ if (!store.planProjectLinks) {
54682
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
54683
+ }
54684
+ const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
54685
+ return store.planProjectLinks.apply({
54686
+ plan_id: planId,
54687
+ project_id: projectId,
54688
+ expected_plan_revision: options.expected_plan_revision,
54689
+ expected_project_revision: options.expected_project_revision,
54690
+ idempotency_key: key,
54691
+ receipt_id: planProjectLinkReceiptId(key),
54692
+ created_at: new Date().toISOString()
54693
+ });
54694
+ }
54695
+ async function rollbackPlanProjectLink(store, planId, projectId, options) {
54696
+ if (!store.planProjectLinks) {
54697
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
54698
+ }
54699
+ return store.planProjectLinks.rollback({
54700
+ plan_id: planId,
54701
+ project_id: projectId,
54702
+ receipt_id: options.receipt_id,
54703
+ expected_plan_revision: options.expected_plan_revision,
54704
+ rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
54705
+ restored_at: new Date().toISOString()
54706
+ });
54707
+ }
54708
+ var init_plan_project_link = __esm(() => {
54709
+ init_plan_project_link_contract();
54710
+ init_plan_project_link_contract();
54711
+ });
54712
+
53665
54713
  // src/server/v1.ts
53666
54714
  var exports_v1 = {};
53667
54715
  __export(exports_v1, {
@@ -53684,6 +54732,21 @@ function enumQueryParam(url, name, vocabulary) {
53684
54732
  return { ok: false, response: error(400, result.message) };
53685
54733
  return { ok: true, value: collapseEnumValues(result.values) };
53686
54734
  }
54735
+ function parseSinceCursor(raw) {
54736
+ const shape = `updated_after must be an RFC 3339 date-time with an explicit offset, ` + `e.g. 2026-08-07T12:00:00Z or 2026-08-07T12:00:00+03:00; got ${JSON.stringify(raw)}`;
54737
+ const match = RFC3339_DATE_TIME.exec(raw);
54738
+ if (!match)
54739
+ return { ok: false, message: shape };
54740
+ const parsed = Date.parse(raw);
54741
+ if (Number.isNaN(parsed))
54742
+ return { ok: false, message: shape };
54743
+ const [, year, month, day] = match;
54744
+ const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
54745
+ if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
54746
+ return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
54747
+ }
54748
+ return { ok: true, value: new Date(parsed).toISOString() };
54749
+ }
53687
54750
  function validateTaskCompletion(value) {
53688
54751
  if (!value || typeof value !== "object" || Array.isArray(value))
53689
54752
  return { ok: false, message: "completion body must be an object" };
@@ -54093,7 +55156,13 @@ async function handleV1Request(req, url, dependencies = {}) {
54093
55156
  const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
54094
55157
  if (!priorityParam.ok)
54095
55158
  return priorityParam.response;
55159
+ const updatedAfterRaw = url.searchParams.get("updated_after");
55160
+ const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
55161
+ if (updatedAfter !== null && !updatedAfter.ok) {
55162
+ return error(400, updatedAfter.message);
55163
+ }
54096
55164
  const filter = {
55165
+ ...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
54097
55166
  ...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
54098
55167
  ...statusParam.value !== undefined ? { status: statusParam.value } : {},
54099
55168
  ...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
@@ -54555,6 +55624,54 @@ async function handleV1Request(req, url, dependencies = {}) {
54555
55624
  const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
54556
55625
  return json4({ plan }, 201);
54557
55626
  }
55627
+ if (id && action === "project-link" && !subId) {
55628
+ if (method === "GET") {
55629
+ const projectId = url.searchParams.get("project_id");
55630
+ if (!projectId?.trim())
55631
+ return error(400, "project_id query parameter is required");
55632
+ return json4(await planPlanProjectLink(store, id, projectId));
55633
+ }
55634
+ if (method !== "POST")
55635
+ return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
55636
+ const body = await readJson3(req);
55637
+ if (!body)
55638
+ return error(400, "invalid JSON body");
55639
+ const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
55640
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
55641
+ if (unknown)
55642
+ return error(400, `unknown plan-project-link field: ${unknown}`);
55643
+ for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
55644
+ if (typeof body[field] !== "string" || !body[field].trim()) {
55645
+ return error(400, `${field} must be a non-empty string`);
55646
+ }
55647
+ }
55648
+ const result = await applyPlanProjectLink(store, id, body.project_id, {
55649
+ expected_plan_revision: body.expected_plan_revision,
55650
+ expected_project_revision: body.expected_project_revision,
55651
+ idempotency_key: body.idempotency_key
55652
+ });
55653
+ return json4(result, result.action === "linked" ? 201 : 200);
55654
+ }
55655
+ if (id && action === "project-link" && subId === "rollback") {
55656
+ if (method !== "POST")
55657
+ return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
55658
+ const body = await readJson3(req);
55659
+ if (!body)
55660
+ return error(400, "invalid JSON body");
55661
+ const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
55662
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
55663
+ if (unknown)
55664
+ return error(400, `unknown plan-project-link rollback field: ${unknown}`);
55665
+ for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
55666
+ if (typeof body[field] !== "string" || !body[field].trim()) {
55667
+ return error(400, `${field} must be a non-empty string`);
55668
+ }
55669
+ }
55670
+ return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
55671
+ receipt_id: body.receipt_id,
55672
+ expected_plan_revision: body.expected_plan_revision
55673
+ }));
55674
+ }
54558
55675
  if (id && method === "GET") {
54559
55676
  const plan = await store.plans.get(id);
54560
55677
  return plan ? json4({ plan }) : error(404, "plan not found");
@@ -54833,6 +55950,10 @@ async function handleV1Request(req, url, dependencies = {}) {
54833
55950
  }
54834
55951
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
54835
55952
  } catch (e) {
55953
+ if (e instanceof PlanProjectLinkError) {
55954
+ const status = e.code === "PLAN_PROJECT_LINK_PLAN_NOT_FOUND" || e.code === "PLAN_PROJECT_LINK_PROJECT_NOT_FOUND" || e.code === "PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND" ? 404 : e.code === "PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID" ? 400 : e.code === "PLAN_PROJECT_LINK_UNSUPPORTED" ? 501 : 409;
55955
+ return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
55956
+ }
54836
55957
  if (e instanceof ProjectTaskListEnsureError) {
54837
55958
  const status = e.code === "PROJECT_NOT_FOUND" || e.code === "PROJECT_TASK_LIST_RECEIPT_NOT_FOUND" ? 404 : e.code === "PROJECT_TASK_LIST_IDEMPOTENCY_KEY_INVALID" ? 400 : 409;
54838
55959
  return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
@@ -54856,7 +55977,7 @@ async function handleV1Request(req, url, dependencies = {}) {
54856
55977
  return error(500, e.message || "internal error");
54857
55978
  }
54858
55979
  }
54859
- var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
55980
+ var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
54860
55981
  var init_v1 = __esm(() => {
54861
55982
  init_types();
54862
55983
  init_cloud();
@@ -54864,7 +55985,9 @@ var init_v1 = __esm(() => {
54864
55985
  init_project_registration();
54865
55986
  init_redaction();
54866
55987
  init_project_task_list_ensure();
55988
+ init_plan_project_link();
54867
55989
  JSON_HEADERS3 = { "Content-Type": "application/json" };
55990
+ RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
54868
55991
  });
54869
55992
 
54870
55993
  // src/pr-groups/sqlite.ts