@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
package/dist/storage.js CHANGED
@@ -2269,6 +2269,29 @@ var init_migrations = __esm(() => {
2269
2269
  ${sqliteTodosProjectRegistrationSchemaSql()}
2270
2270
  INSERT OR IGNORE INTO _migrations (id) VALUES (69);
2271
2271
  COMMIT;
2272
+ `,
2273
+ `BEGIN;
2274
+ CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
2275
+ receipt_id TEXT PRIMARY KEY,
2276
+ idempotency_key TEXT NOT NULL UNIQUE,
2277
+ plan_id TEXT NOT NULL,
2278
+ project_id TEXT NOT NULL,
2279
+ payload_hash TEXT NOT NULL,
2280
+ payload TEXT NOT NULL,
2281
+ created_at TEXT NOT NULL
2282
+ );
2283
+ CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
2284
+ ON plan_project_link_receipts(plan_id, created_at);
2285
+
2286
+ CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
2287
+ rollback_receipt_id TEXT PRIMARY KEY,
2288
+ accepted_receipt_id TEXT NOT NULL UNIQUE
2289
+ REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
2290
+ payload TEXT NOT NULL,
2291
+ created_at TEXT NOT NULL
2292
+ );
2293
+ INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2294
+ COMMIT;
2272
2295
  `
2273
2296
  ];
2274
2297
  });
@@ -7808,6 +7831,14 @@ var init_checklists = __esm(() => {
7808
7831
  init_database();
7809
7832
  });
7810
7833
 
7834
+ // src/db/plan-row-serialization.ts
7835
+ function guardPlanRowsSqlite(planIds, db) {
7836
+ const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
7837
+ for (const id of ids) {
7838
+ db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
7839
+ }
7840
+ }
7841
+
7811
7842
  // src/lib/creator-identity.ts
7812
7843
  function canonicalAgentRef(value) {
7813
7844
  return value.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 createTask(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 createTask(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 createTask(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 createTask(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 listTasks(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 updateTask(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 updateTask(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 updateTask(id, input, db) {
9715
9788
  emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
9716
9789
  return updatedTask;
9717
9790
  }
9791
+ function updateTask(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 updatePlan(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 updatePlan(id, input, db) {
10425
10513
  });
10426
10514
  return updated;
10427
10515
  }
10516
+ function updatePlan(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);
@@ -13366,6 +13461,240 @@ function scanSqliteIntegrity(db = getDatabase()) {
13366
13461
  return buildIntegrityReport(conditions, now());
13367
13462
  }
13368
13463
 
13464
+ // src/lib/plan-project-link-contract.ts
13465
+ init_types();
13466
+ import { createHash as createHash4 } from "crypto";
13467
+ var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1";
13468
+
13469
+ class PlanProjectLinkError extends Error {
13470
+ code;
13471
+ details;
13472
+ constructor(code, message, details = {}) {
13473
+ super(message);
13474
+ this.code = code;
13475
+ this.details = details;
13476
+ this.name = "PlanProjectLinkError";
13477
+ }
13478
+ }
13479
+ function canonicalPlanProjectLinkJson(value) {
13480
+ if (value === null || typeof value !== "object")
13481
+ return JSON.stringify(value);
13482
+ if (Array.isArray(value))
13483
+ return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
13484
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
13485
+ }
13486
+ function planProjectLinkDigest(value) {
13487
+ return createHash4("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
13488
+ }
13489
+ function normalizePlanProjectLinkIdempotencyKey(value) {
13490
+ const key = value?.trim() ?? "";
13491
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
13492
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
13493
+ }
13494
+ return key;
13495
+ }
13496
+ function planProjectLinkReceiptId(idempotencyKey) {
13497
+ return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
13498
+ }
13499
+ function planProjectLinkRollbackReceiptId(receiptId) {
13500
+ return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
13501
+ }
13502
+ function planProjectLinkRequestHash(planId, projectId) {
13503
+ return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
13504
+ }
13505
+ function planProjectLinkResultDigest(plan, tasks) {
13506
+ return planProjectLinkDigest({
13507
+ plan_id: plan.id,
13508
+ plan_project_id: plan.project_id,
13509
+ 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))
13510
+ });
13511
+ }
13512
+ function assertPlanProjectLinkReceipt(value) {
13513
+ const receipt = value;
13514
+ 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") {
13515
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
13516
+ }
13517
+ return receipt;
13518
+ }
13519
+
13520
+ // src/db/plan-project-links.ts
13521
+ init_database();
13522
+ init_plans();
13523
+ init_projects();
13524
+ init_tasks();
13525
+ function getPlanProjectLinkReceipt(receiptId, db) {
13526
+ const d = db || getDatabase();
13527
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
13528
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
13529
+ }
13530
+ function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
13531
+ const d = db || getDatabase();
13532
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
13533
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
13534
+ }
13535
+ function exactTasks(planId, db) {
13536
+ return listTasks({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
13537
+ }
13538
+ function currentResult(planId, projectId, receipt, db, action) {
13539
+ const plan = getPlan(planId, db);
13540
+ const project = getProject(projectId, db);
13541
+ if (!plan || !project) {
13542
+ 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 });
13543
+ }
13544
+ const tasks = exactTasks(planId, db);
13545
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
13546
+ 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 });
13547
+ }
13548
+ return { mode: "apply", action, plan, project, tasks, receipt };
13549
+ }
13550
+ function applyPlanProjectLinkSqlite(input, db) {
13551
+ const d = db || getDatabase();
13552
+ const mutate = d.transaction(() => {
13553
+ guardPlanRowsSqlite([input.plan_id], d);
13554
+ const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
13555
+ const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
13556
+ if (existingRow) {
13557
+ if (existingRow.payload_hash !== requestHash) {
13558
+ 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 });
13559
+ }
13560
+ const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
13561
+ if (rolledBack) {
13562
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
13563
+ }
13564
+ return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
13565
+ }
13566
+ const plan = getPlan(input.plan_id, d);
13567
+ if (!plan) {
13568
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
13569
+ }
13570
+ const project = getProject(input.project_id, d);
13571
+ if (!project) {
13572
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
13573
+ }
13574
+ if (plan.updated_at !== input.expected_plan_revision) {
13575
+ 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 });
13576
+ }
13577
+ if (project.updated_at !== input.expected_project_revision) {
13578
+ 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 });
13579
+ }
13580
+ if (plan.slug) {
13581
+ const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
13582
+ if (collision) {
13583
+ 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 });
13584
+ }
13585
+ }
13586
+ const beforeTasks = exactTasks(plan.id, d);
13587
+ const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
13588
+ const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
13589
+ const timestamp2 = input.created_at;
13590
+ if (plan.project_id !== project.id) {
13591
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
13592
+ project.id,
13593
+ timestamp2,
13594
+ plan.id,
13595
+ input.expected_plan_revision
13596
+ ]);
13597
+ }
13598
+ for (const task of beforeTasks) {
13599
+ if (task.project_id === project.id)
13600
+ continue;
13601
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
13602
+ }
13603
+ const linkedPlan = getPlan(plan.id, d);
13604
+ const linkedTasks = exactTasks(plan.id, d);
13605
+ if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
13606
+ 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 });
13607
+ }
13608
+ const receipt = {
13609
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
13610
+ receipt_id: input.receipt_id,
13611
+ idempotency_key: input.idempotency_key,
13612
+ plan_id: plan.id,
13613
+ project_id: project.id,
13614
+ prior_plan_project_id: plan.project_id,
13615
+ prior_task_project_ids: priorTaskProjectIds,
13616
+ task_ids: beforeTasks.map((task) => task.id),
13617
+ task_count: beforeTasks.length,
13618
+ result_plan_revision: linkedPlan.updated_at,
13619
+ result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
13620
+ rollback_supported: true,
13621
+ created_at: input.created_at
13622
+ };
13623
+ d.run(`INSERT INTO plan_project_link_receipts
13624
+ (receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
13625
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
13626
+ receipt.receipt_id,
13627
+ receipt.idempotency_key,
13628
+ receipt.plan_id,
13629
+ receipt.project_id,
13630
+ requestHash,
13631
+ JSON.stringify(receipt),
13632
+ receipt.created_at
13633
+ ]);
13634
+ return {
13635
+ mode: "apply",
13636
+ action: alreadyLinked ? "already_linked" : "linked",
13637
+ plan: linkedPlan,
13638
+ project,
13639
+ tasks: linkedTasks,
13640
+ receipt
13641
+ };
13642
+ });
13643
+ return mutate();
13644
+ }
13645
+ function rollbackPlanProjectLinkSqlite(input, db) {
13646
+ const d = db || getDatabase();
13647
+ return d.transaction(() => {
13648
+ guardPlanRowsSqlite([input.plan_id], d);
13649
+ const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
13650
+ if (priorRollback)
13651
+ return JSON.parse(priorRollback.payload);
13652
+ const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
13653
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
13654
+ 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 });
13655
+ }
13656
+ const plan = getPlan(input.plan_id, d);
13657
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
13658
+ 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 });
13659
+ }
13660
+ const tasks = exactTasks(plan.id, d);
13661
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
13662
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
13663
+ }
13664
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
13665
+ receipt.prior_plan_project_id,
13666
+ input.restored_at,
13667
+ plan.id,
13668
+ input.expected_plan_revision
13669
+ ]);
13670
+ for (const task of tasks) {
13671
+ const priorProjectId = receipt.prior_task_project_ids[task.id];
13672
+ if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
13673
+ 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 });
13674
+ }
13675
+ 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]);
13676
+ }
13677
+ const restoredPlan = getPlan(plan.id, d);
13678
+ const restoredTasks = exactTasks(plan.id, d);
13679
+ if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
13680
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
13681
+ }
13682
+ const result = {
13683
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
13684
+ action: "restored",
13685
+ plan: restoredPlan,
13686
+ tasks: restoredTasks,
13687
+ accepted_receipt_id: receipt.receipt_id,
13688
+ rollback_receipt_id: input.rollback_receipt_id,
13689
+ restored_at: input.restored_at
13690
+ };
13691
+ d.run(`INSERT INTO plan_project_link_rollback_receipts
13692
+ (rollback_receipt_id, accepted_receipt_id, payload, created_at)
13693
+ VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
13694
+ return result;
13695
+ })();
13696
+ }
13697
+
13369
13698
  // src/storage/sqlite-snapshot.ts
13370
13699
  init_database();
13371
13700
  init_audit();
@@ -13869,6 +14198,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
13869
14198
  update: (id, input) => updatePlan(id, input, database()),
13870
14199
  delete: (id) => deletePlan(id, database())
13871
14200
  },
14201
+ planProjectLinks: {
14202
+ apply: (input) => applyPlanProjectLinkSqlite(input, database()),
14203
+ rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
14204
+ getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
14205
+ getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
14206
+ },
13872
14207
  agents: {
13873
14208
  register: (input) => registerAgent(input, database()),
13874
14209
  get: (id) => getAgent(id, database()),
@@ -13963,6 +14298,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
13963
14298
  RETURNS text
13964
14299
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
13965
14300
  AS $$ SELECT unaccent('unaccent', $1) $$`,
14301
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
14302
+ RETURNS timestamptz
14303
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
14304
+ SET DateStyle TO 'ISO, YMD'
14305
+ AS $$
14306
+ BEGIN
14307
+ RETURN CASE
14308
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
14309
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
14310
+ END;
14311
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
14312
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
14313
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
14314
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
14315
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
13966
14316
  `ALTER TABLE ${tableName}
13967
14317
  ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
13968
14318
  GENERATED ALWAYS AS (
@@ -14392,6 +14742,12 @@ function createPostgresTodosStorageAdapter(options) {
14392
14742
  update: (id, input) => updatePlan2(id, input, store),
14393
14743
  delete: (id, context) => store.deletePlan(id, context)
14394
14744
  },
14745
+ planProjectLinks: {
14746
+ apply: (input, context) => store.applyPlanProjectLink(input, context),
14747
+ rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
14748
+ getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
14749
+ getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
14750
+ },
14395
14751
  agents: {
14396
14752
  register: (input, context) => registerAgent2(input, store, context),
14397
14753
  get: (id) => store.get("agents", id),
@@ -14574,6 +14930,9 @@ class PostgresJsonRecordStore {
14574
14930
  conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
14575
14931
  if (filter.not_created_by !== undefined)
14576
14932
  conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
14933
+ if (filter.updated_after !== undefined) {
14934
+ conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
14935
+ }
14577
14936
  if (filter.session_id !== undefined)
14578
14937
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
14579
14938
  if (filter.tags?.length) {
@@ -14758,6 +15117,115 @@ class PostgresJsonRecordStore {
14758
15117
  }
14759
15118
  return value;
14760
15119
  }
15120
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
15121
+ const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
15122
+ if (planIds.length === 0)
15123
+ return this.upsert("tasks", value, context);
15124
+ await this.ensureSchema();
15125
+ const updatedAt = value.updated_at;
15126
+ const targetPlanId = value.plan_id;
15127
+ const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
15128
+ locked_plans AS MATERIALIZED (
15129
+ SELECT object_id, payload FROM ${this.tableName}
15130
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
15131
+ AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
15132
+ ORDER BY object_id
15133
+ FOR UPDATE
15134
+ ), validation AS (
15135
+ SELECT
15136
+ (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
15137
+ ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
15138
+ (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
15139
+ ), guarded AS (
15140
+ SELECT
15141
+ validation.*,
15142
+ ($9::boolean AND validation.target_project_id IS NOT NULL
15143
+ AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
15144
+ CASE
15145
+ WHEN validation.target_project_id IS NULL THEN $3::jsonb
15146
+ ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
15147
+ END AS payload
15148
+ FROM validation
15149
+ ), stored AS (
15150
+ INSERT INTO ${this.tableName} (
15151
+ service, object_type, object_id, payload, updated_at,
15152
+ deleted_at, source_machine_id, version
15153
+ )
15154
+ SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
15155
+ FROM guarded
15156
+ WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
15157
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
15158
+ payload = EXCLUDED.payload,
15159
+ updated_at = EXCLUDED.updated_at,
15160
+ deleted_at = NULL,
15161
+ source_machine_id = EXCLUDED.source_machine_id,
15162
+ version = EXCLUDED.version
15163
+ WHERE ${this.tableName}.updated_at IS NULL
15164
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
15165
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
15166
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
15167
+ RETURNING payload
15168
+ )
15169
+ SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
15170
+ (SELECT payload FROM stored) AS payload
15171
+ FROM guarded`, [
15172
+ this.service,
15173
+ value.id,
15174
+ jsonbParam(value),
15175
+ updatedAt,
15176
+ context.requestId ?? this.sourceMachineId ?? null,
15177
+ numberValue2(value.version),
15178
+ jsonbParam(planIds),
15179
+ targetPlanId,
15180
+ explicitProject
15181
+ ]);
15182
+ const row = result.rows[0];
15183
+ if (!row?.all_plans_found || !row.target_plan_found) {
15184
+ 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 });
15185
+ }
15186
+ if (row.project_conflict) {
15187
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
15188
+ }
15189
+ if (!row.payload) {
15190
+ return await requireRecord("tasks", value.id, this);
15191
+ }
15192
+ return payloadRecord2(row.payload);
15193
+ }
15194
+ async updatePlanWithProjectLinkGuard(value, context = {}) {
15195
+ await this.ensureSchema();
15196
+ const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
15197
+ SELECT payload FROM ${this.tableName}
15198
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
15199
+ FOR UPDATE
15200
+ ), stored AS (
15201
+ UPDATE ${this.tableName} r SET
15202
+ payload = jsonb_set(
15203
+ $3::jsonb,
15204
+ '{project_id}',
15205
+ COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
15206
+ true
15207
+ ),
15208
+ updated_at = $4::timestamptz,
15209
+ deleted_at = NULL,
15210
+ source_machine_id = COALESCE($5, r.source_machine_id),
15211
+ version = COALESCE(r.version, 0) + 1
15212
+ FROM locked_plan
15213
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
15214
+ RETURNING r.payload
15215
+ )
15216
+ SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
15217
+ (SELECT payload FROM stored) AS payload`, [
15218
+ this.service,
15219
+ value.id,
15220
+ jsonbParam(value),
15221
+ value.updated_at,
15222
+ context.requestId ?? this.sourceMachineId ?? null
15223
+ ]);
15224
+ const row = result.rows[0];
15225
+ if (!row?.plan_found || !row.payload)
15226
+ throw new PlanNotFoundError(value.id);
15227
+ return payloadRecord2(row.payload);
15228
+ }
14761
15229
  async createTemplateWithTasks(template, tasks, context = {}) {
14762
15230
  await this.ensureSchema();
14763
15231
  const records = [
@@ -14996,6 +15464,333 @@ class PostgresJsonRecordStore {
14996
15464
  version: numberValue2(existing["version"])
14997
15465
  }, context);
14998
15466
  }
15467
+ async getPlanProjectLinkReceipt(receiptId) {
15468
+ const value = await this.get("plan_project_link_receipts", receiptId);
15469
+ return value ? assertPlanProjectLinkReceipt(value) : null;
15470
+ }
15471
+ async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
15472
+ await this.ensureSchema();
15473
+ const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
15474
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
15475
+ AND payload->>'idempotency_key' = $2
15476
+ LIMIT 2`, [this.service, idempotencyKey]);
15477
+ if (result.rows.length > 1) {
15478
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
15479
+ }
15480
+ return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
15481
+ }
15482
+ async currentPlanProjectLinkResult(receipt, action) {
15483
+ const [plan, project, tasks] = await Promise.all([
15484
+ this.get("plans", receipt.plan_id),
15485
+ this.get("projects", receipt.project_id),
15486
+ this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
15487
+ ]);
15488
+ const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
15489
+ if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
15490
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
15491
+ }
15492
+ return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
15493
+ }
15494
+ async applyPlanProjectLink(input, context = {}) {
15495
+ await this.ensureSchema();
15496
+ const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
15497
+ if (existing) {
15498
+ if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
15499
+ 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 });
15500
+ }
15501
+ const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
15502
+ if (rolledBack) {
15503
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
15504
+ }
15505
+ return this.currentPlanProjectLinkResult(existing, "already_linked");
15506
+ }
15507
+ const [plan, project, tasks, scopedPlans] = await Promise.all([
15508
+ this.get("plans", input.plan_id),
15509
+ this.get("projects", input.project_id),
15510
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
15511
+ this.list("plans")
15512
+ ]);
15513
+ if (!plan)
15514
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
15515
+ if (!project)
15516
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
15517
+ if (plan.updated_at !== input.expected_plan_revision) {
15518
+ 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 });
15519
+ }
15520
+ if (project.updated_at !== input.expected_project_revision) {
15521
+ 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 });
15522
+ }
15523
+ const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
15524
+ if (collision) {
15525
+ 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 });
15526
+ }
15527
+ const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
15528
+ const priorTaskProjectIds = Object.fromEntries(sortedTasks2.map((task) => [task.id, task.project_id]));
15529
+ const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
15530
+ const projectedTasks = sortedTasks2.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
15531
+ const alreadyLinked = plan.project_id === project.id && sortedTasks2.every((task) => task.project_id === project.id);
15532
+ const receipt = {
15533
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
15534
+ receipt_id: input.receipt_id,
15535
+ idempotency_key: input.idempotency_key,
15536
+ plan_id: plan.id,
15537
+ project_id: project.id,
15538
+ prior_plan_project_id: plan.project_id,
15539
+ prior_task_project_ids: priorTaskProjectIds,
15540
+ task_ids: sortedTasks2.map((task) => task.id),
15541
+ task_count: sortedTasks2.length,
15542
+ result_plan_revision: projectedPlan.updated_at,
15543
+ result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
15544
+ rollback_supported: true,
15545
+ created_at: input.created_at
15546
+ };
15547
+ let mutation;
15548
+ try {
15549
+ mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
15550
+ target_plan AS MATERIALIZED (
15551
+ SELECT payload FROM ${this.tableName}
15552
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
15553
+ FOR UPDATE
15554
+ ), target_project AS (
15555
+ SELECT payload FROM ${this.tableName}
15556
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
15557
+ FOR UPDATE
15558
+ ), member_tasks AS MATERIALIZED (
15559
+ SELECT object_id, payload FROM ${this.tableName}
15560
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
15561
+ AND payload->>'plan_id' = $2
15562
+ AND EXISTS (SELECT 1 FROM target_plan)
15563
+ FOR UPDATE
15564
+ ), existing AS (
15565
+ SELECT payload FROM ${this.tableName}
15566
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts'
15567
+ AND object_id = $6 AND deleted_at IS NULL
15568
+ FOR UPDATE
15569
+ ), collision AS (
15570
+ SELECT 1 FROM ${this.tableName} r, target_plan p
15571
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
15572
+ AND r.object_id <> $2 AND r.payload->>'project_id' = $3
15573
+ AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
15574
+ AND p.payload->>'slug' IS NOT NULL
15575
+ LIMIT 1
15576
+ ), checks AS (
15577
+ SELECT
15578
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
15579
+ EXISTS (SELECT 1 FROM target_project) AS project_found,
15580
+ COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
15581
+ COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
15582
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
15583
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
15584
+ EXISTS (SELECT 1 FROM collision) AS collision,
15585
+ EXISTS (SELECT 1 FROM existing) AS has_existing
15586
+ ), updated_plan AS (
15587
+ UPDATE ${this.tableName} r SET
15588
+ payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
15589
+ updated_at = $10::timestamptz,
15590
+ version = COALESCE(r.version, 0) + 1,
15591
+ source_machine_id = COALESCE($11, r.source_machine_id)
15592
+ FROM checks
15593
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
15594
+ AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
15595
+ AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
15596
+ RETURNING r.payload
15597
+ ), updated_tasks AS (
15598
+ UPDATE ${this.tableName} r SET
15599
+ payload = r.payload || jsonb_build_object(
15600
+ 'project_id', $3::text,
15601
+ 'updated_at', $10::text,
15602
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
15603
+ ),
15604
+ updated_at = $10::timestamptz,
15605
+ version = COALESCE(r.version, 0) + 1,
15606
+ source_machine_id = COALESCE($11, r.source_machine_id)
15607
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
15608
+ AND r.payload->>'plan_id' = $2
15609
+ AND r.payload->>'project_id' IS DISTINCT FROM $3
15610
+ AND EXISTS (SELECT 1 FROM updated_plan)
15611
+ RETURNING 1
15612
+ ), task_gate AS (
15613
+ SELECT count(*) AS count FROM updated_tasks
15614
+ ), inserted AS (
15615
+ INSERT INTO ${this.tableName}
15616
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
15617
+ SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
15618
+ FROM checks, task_gate
15619
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
15620
+ RETURNING payload
15621
+ ) SELECT
15622
+ checks.plan_found,
15623
+ checks.project_found,
15624
+ checks.plan_revision_ok,
15625
+ checks.project_revision_ok,
15626
+ checks.membership_ok,
15627
+ checks.collision,
15628
+ (SELECT payload FROM existing) AS existing_receipt,
15629
+ (SELECT payload FROM inserted) AS inserted_receipt
15630
+ FROM checks`, [
15631
+ this.service,
15632
+ plan.id,
15633
+ project.id,
15634
+ input.expected_plan_revision,
15635
+ input.expected_project_revision,
15636
+ receipt.receipt_id,
15637
+ jsonbParam(receipt),
15638
+ jsonbParam(priorTaskProjectIds),
15639
+ jsonbParam(receipt.task_ids),
15640
+ input.created_at,
15641
+ this.machineId(context)
15642
+ ]);
15643
+ } catch (error) {
15644
+ if (isPostgresUniqueViolation(error)) {
15645
+ const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
15646
+ if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
15647
+ return this.currentPlanProjectLinkResult(raced, "already_linked");
15648
+ }
15649
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
15650
+ }
15651
+ throw error;
15652
+ }
15653
+ const row = mutation.rows[0];
15654
+ if (!row?.plan_found)
15655
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
15656
+ if (!row.project_found)
15657
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
15658
+ if (!row.plan_revision_ok)
15659
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
15660
+ if (!row.project_revision_ok)
15661
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
15662
+ if (!row.membership_ok)
15663
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
15664
+ if (row.collision)
15665
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
15666
+ const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
15667
+ if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
15668
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
15669
+ }
15670
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
15671
+ }
15672
+ async rollbackPlanProjectLink(input, context = {}) {
15673
+ await this.ensureSchema();
15674
+ const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
15675
+ if (existingRollback)
15676
+ return existingRollback;
15677
+ const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
15678
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
15679
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
15680
+ }
15681
+ const [plan, tasks] = await Promise.all([
15682
+ this.get("plans", input.plan_id),
15683
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
15684
+ ]);
15685
+ const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
15686
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
15687
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
15688
+ }
15689
+ if (planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
15690
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
15691
+ }
15692
+ const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
15693
+ const projectedTasks = sortedTasks2.map((task) => ({
15694
+ ...task,
15695
+ project_id: receipt.prior_task_project_ids[task.id] ?? null,
15696
+ updated_at: input.restored_at,
15697
+ version: task.version + 1
15698
+ }));
15699
+ const rollback = {
15700
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
15701
+ action: "restored",
15702
+ plan: projectedPlan,
15703
+ tasks: projectedTasks,
15704
+ accepted_receipt_id: receipt.receipt_id,
15705
+ rollback_receipt_id: input.rollback_receipt_id,
15706
+ restored_at: input.restored_at
15707
+ };
15708
+ const currentTaskProjects = Object.fromEntries(sortedTasks2.map((task) => [task.id, task.project_id]));
15709
+ const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
15710
+ target_plan AS MATERIALIZED (
15711
+ SELECT payload FROM ${this.tableName}
15712
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
15713
+ FOR UPDATE
15714
+ ), member_tasks AS MATERIALIZED (
15715
+ SELECT object_id, payload FROM ${this.tableName}
15716
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
15717
+ AND payload->>'plan_id' = $2
15718
+ AND EXISTS (SELECT 1 FROM target_plan)
15719
+ FOR UPDATE
15720
+ ), existing AS (
15721
+ SELECT payload FROM ${this.tableName}
15722
+ WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
15723
+ AND object_id = $5 AND deleted_at IS NULL
15724
+ FOR UPDATE
15725
+ ), checks AS (
15726
+ SELECT
15727
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
15728
+ COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
15729
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
15730
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
15731
+ EXISTS (SELECT 1 FROM existing) AS has_existing
15732
+ ), updated_plan AS (
15733
+ UPDATE ${this.tableName} r SET
15734
+ payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
15735
+ updated_at = $10::timestamptz,
15736
+ version = COALESCE(r.version, 0) + 1,
15737
+ source_machine_id = COALESCE($11, r.source_machine_id)
15738
+ FROM checks
15739
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
15740
+ AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
15741
+ RETURNING 1
15742
+ ), updated_tasks AS (
15743
+ UPDATE ${this.tableName} r SET
15744
+ payload = r.payload || jsonb_build_object(
15745
+ 'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
15746
+ 'updated_at', $10::text,
15747
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
15748
+ ),
15749
+ updated_at = $10::timestamptz,
15750
+ version = COALESCE(r.version, 0) + 1,
15751
+ source_machine_id = COALESCE($11, r.source_machine_id)
15752
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
15753
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
15754
+ RETURNING 1
15755
+ ), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
15756
+ INSERT INTO ${this.tableName}
15757
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
15758
+ SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
15759
+ FROM checks, task_gate
15760
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
15761
+ RETURNING payload
15762
+ ) SELECT
15763
+ checks.plan_found,
15764
+ checks.plan_revision_ok,
15765
+ checks.membership_ok,
15766
+ (SELECT payload FROM existing) AS existing_rollback,
15767
+ (SELECT payload FROM inserted) AS inserted_rollback
15768
+ FROM checks`, [
15769
+ this.service,
15770
+ input.plan_id,
15771
+ input.expected_plan_revision,
15772
+ input.receipt_id,
15773
+ input.rollback_receipt_id,
15774
+ jsonbParam(rollback),
15775
+ jsonbParam(currentTaskProjects),
15776
+ jsonbParam(receipt.task_ids),
15777
+ jsonbParam(receipt.prior_plan_project_id),
15778
+ input.restored_at,
15779
+ this.machineId(context),
15780
+ jsonbParam(receipt.prior_task_project_ids)
15781
+ ]);
15782
+ const row = result.rows[0];
15783
+ if (!row?.plan_found)
15784
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
15785
+ if (!row.plan_revision_ok)
15786
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
15787
+ if (!row.membership_ok)
15788
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
15789
+ const accepted = row.existing_rollback ?? row.inserted_rollback;
15790
+ if (!accepted)
15791
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
15792
+ return accepted;
15793
+ }
14999
15794
  async deletePlan(id, context = {}) {
15000
15795
  await this.ensureSchema();
15001
15796
  const timestamp2 = new Date().toISOString();
@@ -15090,11 +15885,17 @@ class PostgresJsonRecordStore {
15090
15885
  }
15091
15886
  async function createTask2(input, store, context) {
15092
15887
  const timestamp2 = new Date().toISOString();
15093
- const shortId = input.project_id ? await nextTaskShortId2(input.project_id, store, context) : null;
15888
+ const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
15889
+ const requestedProjectId = input.project_id ?? context?.projectId ?? null;
15890
+ if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
15891
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
15892
+ }
15893
+ const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
15894
+ const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
15094
15895
  const task = {
15095
15896
  id: randomUUID3(),
15096
15897
  short_id: shortId,
15097
- project_id: input.project_id ?? context?.projectId ?? null,
15898
+ project_id: effectiveProjectId,
15098
15899
  parent_id: input.parent_id ?? null,
15099
15900
  plan_id: input.plan_id ?? null,
15100
15901
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -15148,15 +15949,27 @@ async function createTask2(input, store, context) {
15148
15949
  synced_at: null,
15149
15950
  archived_at: null
15150
15951
  };
15151
- await store.upsert("tasks", task, context);
15152
- await logTaskChange2(task.id, "created", "status", null, task.status, task.assigned_by ?? task.agent_id, store, context);
15153
- return task;
15952
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
15953
+ await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
15954
+ return storedTask;
15154
15955
  }
15155
15956
  async function updateTask2(id, input, store) {
15156
15957
  const existing = await requireRecord("tasks", id, store);
15157
15958
  if (existing.version !== input.version) {
15158
15959
  throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
15159
15960
  }
15961
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
15962
+ const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
15963
+ if (linkedPlan?.project_id) {
15964
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
15965
+ if (effectiveProjectId !== linkedPlan.project_id) {
15966
+ if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
15967
+ input = { ...input, project_id: linkedPlan.project_id };
15968
+ } else {
15969
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
15970
+ }
15971
+ }
15972
+ }
15160
15973
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
15161
15974
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
15162
15975
  const task = {
@@ -15172,8 +15985,7 @@ async function updateTask2(id, input, store) {
15172
15985
  created_by: existing.created_by,
15173
15986
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
15174
15987
  };
15175
- await store.upsert("tasks", task);
15176
- return task;
15988
+ return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
15177
15989
  }
15178
15990
  async function startTask2(id, agentId, store) {
15179
15991
  const task = await requireRecord("tasks", id, store);
@@ -15237,8 +16049,7 @@ async function patchTask(task, patch, store) {
15237
16049
  version: task.version + 1,
15238
16050
  updated_at: new Date().toISOString()
15239
16051
  };
15240
- await store.upsert("tasks", updated);
15241
- return updated;
16052
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
15242
16053
  }
15243
16054
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
15244
16055
  function sameCloudLockHolder(stored, incoming) {
@@ -15503,7 +16314,12 @@ async function updatePlan2(id, input, store) {
15503
16314
  excludeId: id
15504
16315
  });
15505
16316
  }
15506
- return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
16317
+ return store.updatePlanWithProjectLinkGuard({
16318
+ ...plan,
16319
+ ...patch,
16320
+ project_id: plan.project_id,
16321
+ updated_at: new Date().toISOString()
16322
+ });
15507
16323
  }
15508
16324
  function matchAgentByName(agents, name, options) {
15509
16325
  const target = normalizeAgentNameInput(name);
@@ -16835,7 +17651,7 @@ function isCommentRedactionBackfillComplete(result) {
16835
17651
  return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
16836
17652
  }
16837
17653
  // src/storage/s3-artifacts.ts
16838
- import { createHash as createHash4, createHmac as createHmac2 } from "crypto";
17654
+ import { createHash as createHash5, createHmac as createHmac2 } from "crypto";
16839
17655
  function createTodosS3ArtifactStore(options) {
16840
17656
  const requestFetch = options.fetch ?? fetch;
16841
17657
  const now3 = options.now ?? (() => new Date);
@@ -17007,7 +17823,7 @@ function toAmzDate(date) {
17007
17823
  return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
17008
17824
  }
17009
17825
  function sha256Hex(value) {
17010
- return createHash4("sha256").update(value).digest("hex");
17826
+ return createHash5("sha256").update(value).digest("hex");
17011
17827
  }
17012
17828
  function hmac(key, value) {
17013
17829
  return createHmac2("sha256", key).update(value).digest();