@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
@@ -70,7 +70,7 @@ var package_default;
70
70
  var init_package = __esm(() => {
71
71
  package_default = {
72
72
  name: "@hasna/todos",
73
- version: "0.15.9",
73
+ version: "0.15.10",
74
74
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
75
75
  type: "module",
76
76
  main: "dist/index.js",
@@ -112,6 +112,10 @@ var init_package = __esm(() => {
112
112
  "./project-registration": {
113
113
  types: "./dist/project-registration.d.ts",
114
114
  import: "./dist/project-registration.js"
115
+ },
116
+ "./task-manifest": {
117
+ types: "./dist/task-manifest.d.ts",
118
+ import: "./dist/task-manifest.js"
115
119
  }
116
120
  },
117
121
  workspaces: [
@@ -124,8 +128,8 @@ var init_package = __esm(() => {
124
128
  "README.md"
125
129
  ],
126
130
  scripts: {
127
- 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",
128
- "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/*'",
131
+ 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",
132
+ "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/*'",
129
133
  migrate: "bun run src/server/index.ts migrate",
130
134
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
131
135
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -1122,6 +1126,62 @@ var init_creator_identity = __esm(() => {
1122
1126
  init_sync_utils();
1123
1127
  });
1124
1128
 
1129
+ // src/lib/plan-project-link-contract.ts
1130
+ import { createHash as createHash2 } from "crypto";
1131
+ function canonicalPlanProjectLinkJson(value) {
1132
+ if (value === null || typeof value !== "object")
1133
+ return JSON.stringify(value);
1134
+ if (Array.isArray(value))
1135
+ return `[${value.map(canonicalPlanProjectLinkJson).join(",")}]`;
1136
+ return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalPlanProjectLinkJson(item)}`).join(",")}}`;
1137
+ }
1138
+ function planProjectLinkDigest(value) {
1139
+ return createHash2("sha256").update(canonicalPlanProjectLinkJson(value)).digest("hex");
1140
+ }
1141
+ function normalizePlanProjectLinkIdempotencyKey(value) {
1142
+ const key = value?.trim() ?? "";
1143
+ if (key.length < 8 || key.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(key)) {
1144
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_KEY_INVALID", "idempotency_key must be 8-128 ASCII letters, digits, dots, underscores, colons, or hyphens");
1145
+ }
1146
+ return key;
1147
+ }
1148
+ function planProjectLinkReceiptId(idempotencyKey) {
1149
+ return `pplr_${planProjectLinkDigest({ idempotency_key: idempotencyKey }).slice(0, 48)}`;
1150
+ }
1151
+ function planProjectLinkRollbackReceiptId(receiptId) {
1152
+ return `pplr_inverse_${planProjectLinkDigest({ accepted_receipt_id: receiptId }).slice(0, 38)}`;
1153
+ }
1154
+ function planProjectLinkRequestHash(planId, projectId) {
1155
+ return planProjectLinkDigest({ plan_id: planId, project_id: projectId });
1156
+ }
1157
+ function planProjectLinkResultDigest(plan, tasks) {
1158
+ return planProjectLinkDigest({
1159
+ plan_id: plan.id,
1160
+ plan_project_id: plan.project_id,
1161
+ 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))
1162
+ });
1163
+ }
1164
+ function assertPlanProjectLinkReceipt(value) {
1165
+ const receipt = value;
1166
+ 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") {
1167
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "Stored plan-project-link receipt is invalid");
1168
+ }
1169
+ return receipt;
1170
+ }
1171
+ var PLAN_PROJECT_LINK_SCHEMA_VERSION = "todos.plan-project-link.v1", PlanProjectLinkError;
1172
+ var init_plan_project_link_contract = __esm(() => {
1173
+ PlanProjectLinkError = class PlanProjectLinkError extends Error {
1174
+ code;
1175
+ details;
1176
+ constructor(code, message, details = {}) {
1177
+ super(message);
1178
+ this.code = code;
1179
+ this.details = details;
1180
+ this.name = "PlanProjectLinkError";
1181
+ }
1182
+ };
1183
+ });
1184
+
1125
1185
  // src/lib/slugs.ts
1126
1186
  function normalizeSlug(value) {
1127
1187
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1219,6 +1279,21 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
1219
1279
  RETURNS text
1220
1280
  LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
1221
1281
  AS $$ SELECT unaccent('unaccent', $1) $$`,
1282
+ `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
1283
+ RETURNS timestamptz
1284
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
1285
+ SET DateStyle TO 'ISO, YMD'
1286
+ AS $$
1287
+ BEGIN
1288
+ RETURN CASE
1289
+ WHEN $1 ~ '(Z|[+-][0-9]{2}:?[0-9]{2})$' THEN $1::timestamptz
1290
+ ELSE ($1::timestamp AT TIME ZONE 'UTC')
1291
+ END;
1292
+ EXCEPTION WHEN others THEN RETURN NULL; END $$`,
1293
+ `DROP INDEX IF EXISTS ${tableName}_task_updated_at_idx`,
1294
+ `CREATE INDEX IF NOT EXISTS ${tableName}_task_updated_at_utc_idx
1295
+ ON ${tableName} (todos_try_timestamptz(payload->>'updated_at'))
1296
+ WHERE object_type = 'tasks' AND deleted_at IS NULL`,
1222
1297
  `ALTER TABLE ${tableName}
1223
1298
  ADD COLUMN IF NOT EXISTS task_search_tsv tsvector
1224
1299
  GENERATED ALWAYS AS (
@@ -2018,6 +2093,12 @@ function createPostgresTodosStorageAdapter(options) {
2018
2093
  update: (id, input) => updatePlan(id, input, store),
2019
2094
  delete: (id, context) => store.deletePlan(id, context)
2020
2095
  },
2096
+ planProjectLinks: {
2097
+ apply: (input, context) => store.applyPlanProjectLink(input, context),
2098
+ rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
2099
+ getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
2100
+ getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
2101
+ },
2021
2102
  agents: {
2022
2103
  register: (input, context) => registerAgent(input, store, context),
2023
2104
  get: (id) => store.get("agents", id),
@@ -2200,6 +2281,9 @@ class PostgresJsonRecordStore {
2200
2281
  conds.push(`LOWER(payload->>'created_by') = LOWER(${p(filter.created_by)})`);
2201
2282
  if (filter.not_created_by !== undefined)
2202
2283
  conds.push(`(payload->>'created_by' IS NULL OR LOWER(payload->>'created_by') <> LOWER(${p(filter.not_created_by)}))`);
2284
+ if (filter.updated_after !== undefined) {
2285
+ conds.push(`(todos_try_timestamptz(payload->>'updated_at') IS NULL ` + `OR todos_try_timestamptz(payload->>'updated_at') > ${p(filter.updated_after)}::timestamptz)`);
2286
+ }
2203
2287
  if (filter.session_id !== undefined)
2204
2288
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
2205
2289
  if (filter.tags?.length) {
@@ -2384,6 +2468,115 @@ class PostgresJsonRecordStore {
2384
2468
  }
2385
2469
  return value;
2386
2470
  }
2471
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
2472
+ const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
2473
+ if (planIds.length === 0)
2474
+ return this.upsert("tasks", value, context);
2475
+ await this.ensureSchema();
2476
+ const updatedAt = value.updated_at;
2477
+ const targetPlanId = value.plan_id;
2478
+ const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
2479
+ locked_plans AS MATERIALIZED (
2480
+ SELECT object_id, payload FROM ${this.tableName}
2481
+ WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
2482
+ AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
2483
+ ORDER BY object_id
2484
+ FOR UPDATE
2485
+ ), validation AS (
2486
+ SELECT
2487
+ (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
2488
+ ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
2489
+ (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
2490
+ ), guarded AS (
2491
+ SELECT
2492
+ validation.*,
2493
+ ($9::boolean AND validation.target_project_id IS NOT NULL
2494
+ AND ($3::jsonb->>'project_id') IS DISTINCT FROM validation.target_project_id) AS project_conflict,
2495
+ CASE
2496
+ WHEN validation.target_project_id IS NULL THEN $3::jsonb
2497
+ ELSE jsonb_set($3::jsonb, '{project_id}', to_jsonb(validation.target_project_id), true)
2498
+ END AS payload
2499
+ FROM validation
2500
+ ), stored AS (
2501
+ INSERT INTO ${this.tableName} (
2502
+ service, object_type, object_id, payload, updated_at,
2503
+ deleted_at, source_machine_id, version
2504
+ )
2505
+ SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
2506
+ FROM guarded
2507
+ WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
2508
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
2509
+ payload = EXCLUDED.payload,
2510
+ updated_at = EXCLUDED.updated_at,
2511
+ deleted_at = NULL,
2512
+ source_machine_id = EXCLUDED.source_machine_id,
2513
+ version = EXCLUDED.version
2514
+ WHERE ${this.tableName}.updated_at IS NULL
2515
+ OR ${this.tableName}.updated_at < EXCLUDED.updated_at
2516
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
2517
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
2518
+ RETURNING payload
2519
+ )
2520
+ SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
2521
+ (SELECT payload FROM stored) AS payload
2522
+ FROM guarded`, [
2523
+ this.service,
2524
+ value.id,
2525
+ jsonbParam(value),
2526
+ updatedAt,
2527
+ context.requestId ?? this.sourceMachineId ?? null,
2528
+ numberValue2(value.version),
2529
+ jsonbParam(planIds),
2530
+ targetPlanId,
2531
+ explicitProject
2532
+ ]);
2533
+ const row = result.rows[0];
2534
+ if (!row?.all_plans_found || !row.target_plan_found) {
2535
+ 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 });
2536
+ }
2537
+ if (row.project_conflict) {
2538
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
2539
+ }
2540
+ if (!row.payload) {
2541
+ return await requireRecord("tasks", value.id, this);
2542
+ }
2543
+ return payloadRecord2(row.payload);
2544
+ }
2545
+ async updatePlanWithProjectLinkGuard(value, context = {}) {
2546
+ await this.ensureSchema();
2547
+ const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
2548
+ SELECT payload FROM ${this.tableName}
2549
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
2550
+ FOR UPDATE
2551
+ ), stored AS (
2552
+ UPDATE ${this.tableName} r SET
2553
+ payload = jsonb_set(
2554
+ $3::jsonb,
2555
+ '{project_id}',
2556
+ COALESCE((SELECT payload->'project_id' FROM locked_plan), 'null'::jsonb),
2557
+ true
2558
+ ),
2559
+ updated_at = $4::timestamptz,
2560
+ deleted_at = NULL,
2561
+ source_machine_id = COALESCE($5, r.source_machine_id),
2562
+ version = COALESCE(r.version, 0) + 1
2563
+ FROM locked_plan
2564
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
2565
+ RETURNING r.payload
2566
+ )
2567
+ SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
2568
+ (SELECT payload FROM stored) AS payload`, [
2569
+ this.service,
2570
+ value.id,
2571
+ jsonbParam(value),
2572
+ value.updated_at,
2573
+ context.requestId ?? this.sourceMachineId ?? null
2574
+ ]);
2575
+ const row = result.rows[0];
2576
+ if (!row?.plan_found || !row.payload)
2577
+ throw new PlanNotFoundError(value.id);
2578
+ return payloadRecord2(row.payload);
2579
+ }
2387
2580
  async createTemplateWithTasks(template, tasks, context = {}) {
2388
2581
  await this.ensureSchema();
2389
2582
  const records = [
@@ -2622,6 +2815,333 @@ class PostgresJsonRecordStore {
2622
2815
  version: numberValue2(existing["version"])
2623
2816
  }, context);
2624
2817
  }
2818
+ async getPlanProjectLinkReceipt(receiptId) {
2819
+ const value = await this.get("plan_project_link_receipts", receiptId);
2820
+ return value ? assertPlanProjectLinkReceipt(value) : null;
2821
+ }
2822
+ async getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey) {
2823
+ await this.ensureSchema();
2824
+ const result = await this.options.client.query(`/* todos:plan-project-link-receipt-by-key */ SELECT payload FROM ${this.tableName}
2825
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts' AND deleted_at IS NULL
2826
+ AND payload->>'idempotency_key' = $2
2827
+ LIMIT 2`, [this.service, idempotencyKey]);
2828
+ if (result.rows.length > 1) {
2829
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "More than one immutable receipt carries this idempotency key", { idempotency_key: idempotencyKey });
2830
+ }
2831
+ return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
2832
+ }
2833
+ async currentPlanProjectLinkResult(receipt, action) {
2834
+ const [plan, project, tasks] = await Promise.all([
2835
+ this.get("plans", receipt.plan_id),
2836
+ this.get("projects", receipt.project_id),
2837
+ this.listTasks({ plan_id: receipt.plan_id, include_subtasks: true })
2838
+ ]);
2839
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
2840
+ if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
2841
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
2842
+ }
2843
+ return { mode: "apply", action, plan, project, tasks: sortedTasks, receipt };
2844
+ }
2845
+ async applyPlanProjectLink(input, context = {}) {
2846
+ await this.ensureSchema();
2847
+ const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
2848
+ if (existing) {
2849
+ if (existing.plan_id !== input.plan_id || existing.project_id !== input.project_id) {
2850
+ 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 });
2851
+ }
2852
+ const rolledBack = await this.get("plan_project_link_rollback_receipts", planProjectLinkRollbackReceiptId(input.receipt_id));
2853
+ if (rolledBack) {
2854
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
2855
+ }
2856
+ return this.currentPlanProjectLinkResult(existing, "already_linked");
2857
+ }
2858
+ const [plan, project, tasks, scopedPlans] = await Promise.all([
2859
+ this.get("plans", input.plan_id),
2860
+ this.get("projects", input.project_id),
2861
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true }),
2862
+ this.list("plans")
2863
+ ]);
2864
+ if (!plan)
2865
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
2866
+ if (!project)
2867
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
2868
+ if (plan.updated_at !== input.expected_plan_revision) {
2869
+ 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 });
2870
+ }
2871
+ if (project.updated_at !== input.expected_project_revision) {
2872
+ 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 });
2873
+ }
2874
+ const collision = scopedPlans.find((candidate) => candidate.id !== plan.id && candidate.project_id === project.id && candidate.slug !== null && candidate.slug === plan.slug);
2875
+ if (collision) {
2876
+ 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 });
2877
+ }
2878
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
2879
+ const priorTaskProjectIds = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
2880
+ const projectedPlan = { ...plan, project_id: project.id, updated_at: input.created_at };
2881
+ const projectedTasks = sortedTasks.map((task) => task.project_id === project.id ? task : { ...task, project_id: project.id, updated_at: input.created_at, version: task.version + 1 });
2882
+ const alreadyLinked = plan.project_id === project.id && sortedTasks.every((task) => task.project_id === project.id);
2883
+ const receipt = {
2884
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
2885
+ receipt_id: input.receipt_id,
2886
+ idempotency_key: input.idempotency_key,
2887
+ plan_id: plan.id,
2888
+ project_id: project.id,
2889
+ prior_plan_project_id: plan.project_id,
2890
+ prior_task_project_ids: priorTaskProjectIds,
2891
+ task_ids: sortedTasks.map((task) => task.id),
2892
+ task_count: sortedTasks.length,
2893
+ result_plan_revision: projectedPlan.updated_at,
2894
+ result_digest: planProjectLinkResultDigest(projectedPlan, projectedTasks),
2895
+ rollback_supported: true,
2896
+ created_at: input.created_at
2897
+ };
2898
+ let mutation;
2899
+ try {
2900
+ mutation = await this.options.client.query(`/* todos:plan-project-link-atomic */ WITH
2901
+ target_plan AS MATERIALIZED (
2902
+ SELECT payload FROM ${this.tableName}
2903
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
2904
+ FOR UPDATE
2905
+ ), target_project AS (
2906
+ SELECT payload FROM ${this.tableName}
2907
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $3 AND deleted_at IS NULL
2908
+ FOR UPDATE
2909
+ ), member_tasks AS MATERIALIZED (
2910
+ SELECT object_id, payload FROM ${this.tableName}
2911
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
2912
+ AND payload->>'plan_id' = $2
2913
+ AND EXISTS (SELECT 1 FROM target_plan)
2914
+ FOR UPDATE
2915
+ ), existing AS (
2916
+ SELECT payload FROM ${this.tableName}
2917
+ WHERE service = $1 AND object_type = 'plan_project_link_receipts'
2918
+ AND object_id = $6 AND deleted_at IS NULL
2919
+ FOR UPDATE
2920
+ ), collision AS (
2921
+ SELECT 1 FROM ${this.tableName} r, target_plan p
2922
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.deleted_at IS NULL
2923
+ AND r.object_id <> $2 AND r.payload->>'project_id' = $3
2924
+ AND r.payload->>'slug' IS NOT DISTINCT FROM p.payload->>'slug'
2925
+ AND p.payload->>'slug' IS NOT NULL
2926
+ LIMIT 1
2927
+ ), checks AS (
2928
+ SELECT
2929
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
2930
+ EXISTS (SELECT 1 FROM target_project) AS project_found,
2931
+ COALESCE((SELECT payload->>'updated_at' = $4 FROM target_plan), false) AS plan_revision_ok,
2932
+ COALESCE((SELECT payload->>'updated_at' = $5 FROM target_project), false) AS project_revision_ok,
2933
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $8::jsonb
2934
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $9::jsonb AS membership_ok,
2935
+ EXISTS (SELECT 1 FROM collision) AS collision,
2936
+ EXISTS (SELECT 1 FROM existing) AS has_existing
2937
+ ), updated_plan AS (
2938
+ UPDATE ${this.tableName} r SET
2939
+ payload = r.payload || jsonb_build_object('project_id', $3::text, 'updated_at', $10::text),
2940
+ updated_at = $10::timestamptz,
2941
+ version = COALESCE(r.version, 0) + 1,
2942
+ source_machine_id = COALESCE($11, r.source_machine_id)
2943
+ FROM checks
2944
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
2945
+ AND checks.plan_found AND checks.project_found AND checks.plan_revision_ok
2946
+ AND checks.project_revision_ok AND checks.membership_ok AND NOT checks.collision AND NOT checks.has_existing
2947
+ RETURNING r.payload
2948
+ ), updated_tasks AS (
2949
+ UPDATE ${this.tableName} r SET
2950
+ payload = r.payload || jsonb_build_object(
2951
+ 'project_id', $3::text,
2952
+ 'updated_at', $10::text,
2953
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
2954
+ ),
2955
+ updated_at = $10::timestamptz,
2956
+ version = COALESCE(r.version, 0) + 1,
2957
+ source_machine_id = COALESCE($11, r.source_machine_id)
2958
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
2959
+ AND r.payload->>'plan_id' = $2
2960
+ AND r.payload->>'project_id' IS DISTINCT FROM $3
2961
+ AND EXISTS (SELECT 1 FROM updated_plan)
2962
+ RETURNING 1
2963
+ ), task_gate AS (
2964
+ SELECT count(*) AS count FROM updated_tasks
2965
+ ), inserted AS (
2966
+ INSERT INTO ${this.tableName}
2967
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
2968
+ SELECT $1, 'plan_project_link_receipts', $6, $7::jsonb, $10::timestamptz, NULL, $11, 1
2969
+ FROM checks, task_gate
2970
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
2971
+ RETURNING payload
2972
+ ) SELECT
2973
+ checks.plan_found,
2974
+ checks.project_found,
2975
+ checks.plan_revision_ok,
2976
+ checks.project_revision_ok,
2977
+ checks.membership_ok,
2978
+ checks.collision,
2979
+ (SELECT payload FROM existing) AS existing_receipt,
2980
+ (SELECT payload FROM inserted) AS inserted_receipt
2981
+ FROM checks`, [
2982
+ this.service,
2983
+ plan.id,
2984
+ project.id,
2985
+ input.expected_plan_revision,
2986
+ input.expected_project_revision,
2987
+ receipt.receipt_id,
2988
+ jsonbParam(receipt),
2989
+ jsonbParam(priorTaskProjectIds),
2990
+ jsonbParam(receipt.task_ids),
2991
+ input.created_at,
2992
+ this.machineId(context)
2993
+ ]);
2994
+ } catch (error) {
2995
+ if (isPostgresUniqueViolation(error)) {
2996
+ const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
2997
+ if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
2998
+ return this.currentPlanProjectLinkResult(raced, "already_linked");
2999
+ }
3000
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
3001
+ }
3002
+ throw error;
3003
+ }
3004
+ const row = mutation.rows[0];
3005
+ if (!row?.plan_found)
3006
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${plan.id}`);
3007
+ if (!row.project_found)
3008
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${project.id}`);
3009
+ if (!row.plan_revision_ok)
3010
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during the atomic link");
3011
+ if (!row.project_revision_ok)
3012
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_REVISION_CONFLICT", "Project changed during the atomic link");
3013
+ if (!row.membership_ok)
3014
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "Plan membership changed during the atomic link");
3015
+ if (row.collision)
3016
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_SCOPE_COLLISION", "Another plan owns this slug in the destination project");
3017
+ const accepted = assertPlanProjectLinkReceipt(row.existing_receipt ?? row.inserted_receipt);
3018
+ if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
3019
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
3020
+ }
3021
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
3022
+ }
3023
+ async rollbackPlanProjectLink(input, context = {}) {
3024
+ await this.ensureSchema();
3025
+ const existingRollback = await this.get("plan_project_link_rollback_receipts", input.rollback_receipt_id);
3026
+ if (existingRollback)
3027
+ return existingRollback;
3028
+ const receipt = await this.getPlanProjectLinkReceipt(input.receipt_id);
3029
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
3030
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RECEIPT_NOT_FOUND", "No exact plan-project-link receipt matches this rollback request", { receipt_id: input.receipt_id });
3031
+ }
3032
+ const [plan, tasks] = await Promise.all([
3033
+ this.get("plans", input.plan_id),
3034
+ this.listTasks({ plan_id: input.plan_id, include_subtasks: true })
3035
+ ]);
3036
+ const sortedTasks = tasks.sort((left, right) => left.id.localeCompare(right.id));
3037
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
3038
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed after the accepted link; fetch an exact readback before rollback");
3039
+ }
3040
+ if (planProjectLinkResultDigest(plan, sortedTasks) !== receipt.result_digest) {
3041
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback");
3042
+ }
3043
+ const projectedPlan = { ...plan, project_id: receipt.prior_plan_project_id, updated_at: input.restored_at };
3044
+ const projectedTasks = sortedTasks.map((task) => ({
3045
+ ...task,
3046
+ project_id: receipt.prior_task_project_ids[task.id] ?? null,
3047
+ updated_at: input.restored_at,
3048
+ version: task.version + 1
3049
+ }));
3050
+ const rollback = {
3051
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
3052
+ action: "restored",
3053
+ plan: projectedPlan,
3054
+ tasks: projectedTasks,
3055
+ accepted_receipt_id: receipt.receipt_id,
3056
+ rollback_receipt_id: input.rollback_receipt_id,
3057
+ restored_at: input.restored_at
3058
+ };
3059
+ const currentTaskProjects = Object.fromEntries(sortedTasks.map((task) => [task.id, task.project_id]));
3060
+ const result = await this.options.client.query(`/* todos:plan-project-link-rollback-atomic */ WITH
3061
+ target_plan AS MATERIALIZED (
3062
+ SELECT payload FROM ${this.tableName}
3063
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
3064
+ FOR UPDATE
3065
+ ), member_tasks AS MATERIALIZED (
3066
+ SELECT object_id, payload FROM ${this.tableName}
3067
+ WHERE service = $1 AND object_type = 'tasks' AND deleted_at IS NULL
3068
+ AND payload->>'plan_id' = $2
3069
+ AND EXISTS (SELECT 1 FROM target_plan)
3070
+ FOR UPDATE
3071
+ ), existing AS (
3072
+ SELECT payload FROM ${this.tableName}
3073
+ WHERE service = $1 AND object_type = 'plan_project_link_rollback_receipts'
3074
+ AND object_id = $5 AND deleted_at IS NULL
3075
+ FOR UPDATE
3076
+ ), checks AS (
3077
+ SELECT
3078
+ EXISTS (SELECT 1 FROM target_plan) AS plan_found,
3079
+ COALESCE((SELECT payload->>'updated_at' = $3 FROM target_plan), false) AS plan_revision_ok,
3080
+ COALESCE((SELECT jsonb_object_agg(object_id, COALESCE(payload->'project_id', 'null'::jsonb) ORDER BY object_id) FROM member_tasks), '{}'::jsonb) = $7::jsonb
3081
+ AND COALESCE((SELECT jsonb_agg(object_id ORDER BY object_id) FROM member_tasks), '[]'::jsonb) = $8::jsonb AS membership_ok,
3082
+ EXISTS (SELECT 1 FROM existing) AS has_existing
3083
+ ), updated_plan AS (
3084
+ UPDATE ${this.tableName} r SET
3085
+ payload = r.payload || jsonb_build_object('project_id', $9::jsonb, 'updated_at', $10::text),
3086
+ updated_at = $10::timestamptz,
3087
+ version = COALESCE(r.version, 0) + 1,
3088
+ source_machine_id = COALESCE($11, r.source_machine_id)
3089
+ FROM checks
3090
+ WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
3091
+ AND checks.plan_found AND checks.plan_revision_ok AND checks.membership_ok AND NOT checks.has_existing
3092
+ RETURNING 1
3093
+ ), updated_tasks AS (
3094
+ UPDATE ${this.tableName} r SET
3095
+ payload = r.payload || jsonb_build_object(
3096
+ 'project_id', COALESCE($12::jsonb -> r.object_id, 'null'::jsonb),
3097
+ 'updated_at', $10::text,
3098
+ 'version', COALESCE((r.payload->>'version')::int, 0) + 1
3099
+ ),
3100
+ updated_at = $10::timestamptz,
3101
+ version = COALESCE(r.version, 0) + 1,
3102
+ source_machine_id = COALESCE($11, r.source_machine_id)
3103
+ WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
3104
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM updated_plan)
3105
+ RETURNING 1
3106
+ ), task_gate AS (SELECT count(*) AS count FROM updated_tasks), inserted AS (
3107
+ INSERT INTO ${this.tableName}
3108
+ (service, object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version)
3109
+ SELECT $1, 'plan_project_link_rollback_receipts', $5, $6::jsonb, $10::timestamptz, NULL, $11, 1
3110
+ FROM checks, task_gate
3111
+ WHERE NOT checks.has_existing AND EXISTS (SELECT 1 FROM updated_plan)
3112
+ RETURNING payload
3113
+ ) SELECT
3114
+ checks.plan_found,
3115
+ checks.plan_revision_ok,
3116
+ checks.membership_ok,
3117
+ (SELECT payload FROM existing) AS existing_rollback,
3118
+ (SELECT payload FROM inserted) AS inserted_rollback
3119
+ FROM checks`, [
3120
+ this.service,
3121
+ input.plan_id,
3122
+ input.expected_plan_revision,
3123
+ input.receipt_id,
3124
+ input.rollback_receipt_id,
3125
+ jsonbParam(rollback),
3126
+ jsonbParam(currentTaskProjects),
3127
+ jsonbParam(receipt.task_ids),
3128
+ jsonbParam(receipt.prior_plan_project_id),
3129
+ input.restored_at,
3130
+ this.machineId(context),
3131
+ jsonbParam(receipt.prior_task_project_ids)
3132
+ ]);
3133
+ const row = result.rows[0];
3134
+ if (!row?.plan_found)
3135
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
3136
+ if (!row.plan_revision_ok)
3137
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_REVISION_CONFLICT", "Plan changed during rollback");
3138
+ if (!row.membership_ok)
3139
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership changed during rollback");
3140
+ const accepted = row.existing_rollback ?? row.inserted_rollback;
3141
+ if (!accepted)
3142
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
3143
+ return accepted;
3144
+ }
2625
3145
  async deletePlan(id, context = {}) {
2626
3146
  await this.ensureSchema();
2627
3147
  const timestamp = new Date().toISOString();
@@ -2716,11 +3236,17 @@ class PostgresJsonRecordStore {
2716
3236
  }
2717
3237
  async function createTask(input, store, context) {
2718
3238
  const timestamp = new Date().toISOString();
2719
- const shortId = input.project_id ? await nextTaskShortId(input.project_id, store, context) : null;
3239
+ const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
3240
+ const requestedProjectId = input.project_id ?? context?.projectId ?? null;
3241
+ if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
3242
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedPlan.project_id}`);
3243
+ }
3244
+ const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
3245
+ const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
2720
3246
  const task = {
2721
3247
  id: randomUUID(),
2722
3248
  short_id: shortId,
2723
- project_id: input.project_id ?? context?.projectId ?? null,
3249
+ project_id: effectiveProjectId,
2724
3250
  parent_id: input.parent_id ?? null,
2725
3251
  plan_id: input.plan_id ?? null,
2726
3252
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -2774,15 +3300,27 @@ async function createTask(input, store, context) {
2774
3300
  synced_at: null,
2775
3301
  archived_at: null
2776
3302
  };
2777
- await store.upsert("tasks", task, context);
2778
- await logTaskChange(task.id, "created", "status", null, task.status, task.assigned_by ?? task.agent_id, store, context);
2779
- return task;
3303
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
3304
+ await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
3305
+ return storedTask;
2780
3306
  }
2781
3307
  async function updateTask(id, input, store) {
2782
3308
  const existing = await requireRecord("tasks", id, store);
2783
3309
  if (existing.version !== input.version) {
2784
3310
  throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
2785
3311
  }
3312
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
3313
+ const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
3314
+ if (linkedPlan?.project_id) {
3315
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : existing.project_id;
3316
+ if (effectiveProjectId !== linkedPlan.project_id) {
3317
+ if (input.project_id === undefined && (input.plan_id !== undefined || existing.project_id === null)) {
3318
+ input = { ...input, project_id: linkedPlan.project_id };
3319
+ } else {
3320
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedPlan.project_id}`);
3321
+ }
3322
+ }
3323
+ }
2786
3324
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
2787
3325
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
2788
3326
  const task = {
@@ -2798,8 +3336,7 @@ async function updateTask(id, input, store) {
2798
3336
  created_by: existing.created_by,
2799
3337
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
2800
3338
  };
2801
- await store.upsert("tasks", task);
2802
- return task;
3339
+ return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
2803
3340
  }
2804
3341
  async function startTask(id, agentId, store) {
2805
3342
  const task = await requireRecord("tasks", id, store);
@@ -2863,8 +3400,7 @@ async function patchTask(task, patch, store) {
2863
3400
  version: task.version + 1,
2864
3401
  updated_at: new Date().toISOString()
2865
3402
  };
2866
- await store.upsert("tasks", updated);
2867
- return updated;
3403
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
2868
3404
  }
2869
3405
  function sameCloudLockHolder(stored, incoming) {
2870
3406
  if (!stored || !incoming)
@@ -3126,7 +3662,12 @@ async function updatePlan(id, input, store) {
3126
3662
  excludeId: id
3127
3663
  });
3128
3664
  }
3129
- return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
3665
+ return store.updatePlanWithProjectLinkGuard({
3666
+ ...plan,
3667
+ ...patch,
3668
+ project_id: plan.project_id,
3669
+ updated_at: new Date().toISOString()
3670
+ });
3130
3671
  }
3131
3672
  function matchAgentByName(agents, name, options) {
3132
3673
  const target = normalizeAgentNameInput(name);
@@ -3514,6 +4055,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
3514
4055
  var init_postgres_adapter = __esm(() => {
3515
4056
  init_types();
3516
4057
  init_creator_identity();
4058
+ init_plan_project_link_contract();
3517
4059
  init_postgres_sync();
3518
4060
  init_integrity();
3519
4061
  init_redaction();
@@ -3536,9 +4078,9 @@ var init_types2 = __esm(() => {
3536
4078
  });
3537
4079
 
3538
4080
  // src/pr-groups/ledger.ts
3539
- import { createHash as createHash2 } from "crypto";
4081
+ import { createHash as createHash3 } from "crypto";
3540
4082
  function sha256(value) {
3541
- return createHash2("sha256").update(value).digest("hex");
4083
+ return createHash3("sha256").update(value).digest("hex");
3542
4084
  }
3543
4085
  function stableValue(value) {
3544
4086
  if (Array.isArray(value))
@@ -7610,6 +8152,29 @@ var init_migrations = __esm(() => {
7610
8152
  ${sqliteTodosProjectRegistrationSchemaSql()}
7611
8153
  INSERT OR IGNORE INTO _migrations (id) VALUES (69);
7612
8154
  COMMIT;
8155
+ `,
8156
+ `BEGIN;
8157
+ CREATE TABLE IF NOT EXISTS plan_project_link_receipts (
8158
+ receipt_id TEXT PRIMARY KEY,
8159
+ idempotency_key TEXT NOT NULL UNIQUE,
8160
+ plan_id TEXT NOT NULL,
8161
+ project_id TEXT NOT NULL,
8162
+ payload_hash TEXT NOT NULL,
8163
+ payload TEXT NOT NULL,
8164
+ created_at TEXT NOT NULL
8165
+ );
8166
+ CREATE INDEX IF NOT EXISTS idx_plan_project_link_receipts_plan
8167
+ ON plan_project_link_receipts(plan_id, created_at);
8168
+
8169
+ CREATE TABLE IF NOT EXISTS plan_project_link_rollback_receipts (
8170
+ rollback_receipt_id TEXT PRIMARY KEY,
8171
+ accepted_receipt_id TEXT NOT NULL UNIQUE
8172
+ REFERENCES plan_project_link_receipts(receipt_id) ON DELETE RESTRICT,
8173
+ payload TEXT NOT NULL,
8174
+ created_at TEXT NOT NULL
8175
+ );
8176
+ INSERT OR IGNORE INTO _migrations (id) VALUES (70);
8177
+ COMMIT;
7613
8178
  `
7614
8179
  ];
7615
8180
  });
@@ -10952,7 +11517,7 @@ var init_runner_sandbox = __esm(() => {
10952
11517
  });
10953
11518
 
10954
11519
  // src/lib/event-hooks.ts
10955
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
11520
+ import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
10956
11521
  import { appendFileSync, mkdirSync as mkdirSync3 } from "fs";
10957
11522
  import { dirname as dirname3, resolve as resolve6 } from "path";
10958
11523
  import { createConnection } from "net";
@@ -11019,7 +11584,7 @@ function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
11019
11584
  payload: redactValue(payload ?? {}),
11020
11585
  source: { package: "@hasna/todos", local_only: true }
11021
11586
  };
11022
- const digest = createHash3("sha256").update(canonicalEvent(base)).digest("hex");
11587
+ const digest = createHash4("sha256").update(canonicalEvent(base)).digest("hex");
11023
11588
  return { ...base, integrity: { algorithm: "sha256", digest } };
11024
11589
  }
11025
11590
  function summarize(value) {
@@ -12838,6 +13403,14 @@ var init_checklists = __esm(() => {
12838
13403
  init_database();
12839
13404
  });
12840
13405
 
13406
+ // src/db/plan-row-serialization.ts
13407
+ function guardPlanRowsSqlite(planIds, db) {
13408
+ const ids = [...new Set(planIds.filter((id) => Boolean(id)))].sort();
13409
+ for (const id of ids) {
13410
+ db.run("/* todos:sqlite-plan-row-guard */ UPDATE plans SET id = id WHERE id = ?", [id]);
13411
+ }
13412
+ }
13413
+
12841
13414
  // src/lib/recurrence.ts
12842
13415
  function parseRecurrenceRule(rule) {
12843
13416
  const normalized = rule.trim().toLowerCase();
@@ -14176,9 +14749,23 @@ function sanitizeUpdateTaskInput(input) {
14176
14749
  metadata: input.metadata !== undefined ? sanitizePreWriteValue(input.metadata, "task.metadata") : undefined
14177
14750
  };
14178
14751
  }
14179
- function createTask2(input, db) {
14180
- input = sanitizeCreateTaskInput(input);
14181
- const d = db || getDatabase();
14752
+ function linkedPlanProjectId(planId, db) {
14753
+ if (!planId)
14754
+ return null;
14755
+ const row = db.query("SELECT project_id FROM plans WHERE id = ?").get(planId);
14756
+ return row?.project_id ?? null;
14757
+ }
14758
+ function resolveCreateProjectForPlan(input, db) {
14759
+ const linkedProjectId = linkedPlanProjectId(input.plan_id, db);
14760
+ if (!linkedProjectId)
14761
+ return input.project_id || null;
14762
+ if (input.project_id !== undefined && input.project_id !== linkedProjectId) {
14763
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${input.plan_id}: expected ${linkedProjectId}`);
14764
+ }
14765
+ return linkedProjectId;
14766
+ }
14767
+ function createTaskStored(input, d) {
14768
+ const effectiveProjectId = resolveCreateProjectForPlan(input, d);
14182
14769
  const timestamp2 = now();
14183
14770
  const tags = input.tags || [];
14184
14771
  const machineId = currentStorageMachineId(d);
@@ -14192,7 +14779,7 @@ function createTask2(input, db) {
14192
14779
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
14193
14780
  id,
14194
14781
  null,
14195
- input.project_id || null,
14782
+ effectiveProjectId,
14196
14783
  input.parent_id || null,
14197
14784
  input.plan_id || null,
14198
14785
  input.task_list_id || null,
@@ -14242,7 +14829,15 @@ function createTask2(input, db) {
14242
14829
  if (tags.length > 0) {
14243
14830
  insertTaskTags(id, tags, d);
14244
14831
  }
14245
- const task = getTask(id, d);
14832
+ return getTask(id, d);
14833
+ }
14834
+ function createTask2(input, db) {
14835
+ input = sanitizeCreateTaskInput(input);
14836
+ const d = db || getDatabase();
14837
+ const task = input.plan_id ? d.transaction(() => {
14838
+ guardPlanRowsSqlite([input.plan_id], d);
14839
+ return createTaskStored(input, d);
14840
+ })() : createTaskStored(input, d);
14246
14841
  const payload = taskEventData(task);
14247
14842
  const databasePath = databasePathFromDatabase(d);
14248
14843
  dispatchWebhook2("task.created", payload, d).catch(() => {});
@@ -14342,6 +14937,10 @@ function listTasks2(filter = {}, db) {
14342
14937
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
14343
14938
  params.push(filter.not_created_by);
14344
14939
  }
14940
+ if (filter.updated_after) {
14941
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
14942
+ params.push(filter.updated_after);
14943
+ }
14345
14944
  if (filter.session_id) {
14346
14945
  conditions.push("session_id = ?");
14347
14946
  params.push(filter.session_id);
@@ -14502,6 +15101,10 @@ function countTasks(filter = {}, db) {
14502
15101
  conditions.push("(created_by IS NULL OR LOWER(created_by) != LOWER(?))");
14503
15102
  params.push(filter.not_created_by);
14504
15103
  }
15104
+ if (filter.updated_after) {
15105
+ conditions.push("(julianday(updated_at) IS NULL OR julianday(updated_at) > julianday(?))");
15106
+ params.push(filter.updated_after);
15107
+ }
14505
15108
  if (filter.session_id) {
14506
15109
  conditions.push("session_id = ?");
14507
15110
  params.push(filter.session_id);
@@ -14541,7 +15144,7 @@ function countTasks(filter = {}, db) {
14541
15144
  const row = d.query(`SELECT COUNT(*) as count FROM tasks ${where}`).get(...params);
14542
15145
  return row.count;
14543
15146
  }
14544
- function updateTask2(id, input, db) {
15147
+ function updateTaskStored(id, input, db) {
14545
15148
  const d = db || getDatabase();
14546
15149
  const task = getTask(id, d);
14547
15150
  if (!task)
@@ -14550,6 +15153,18 @@ function updateTask2(id, input, db) {
14550
15153
  throw new VersionConflictError(id, input.version, task.version);
14551
15154
  }
14552
15155
  input = sanitizeUpdateTaskInput(input);
15156
+ const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
15157
+ const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
15158
+ if (linkedProjectId) {
15159
+ const effectiveProjectId = input.project_id !== undefined ? input.project_id : task.project_id;
15160
+ if (effectiveProjectId !== linkedProjectId) {
15161
+ if (input.project_id === undefined && (input.plan_id !== undefined || task.project_id === null)) {
15162
+ input = { ...input, project_id: linkedProjectId };
15163
+ } else {
15164
+ throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${effectivePlanId}: expected ${linkedProjectId}`);
15165
+ }
15166
+ }
15167
+ }
14553
15168
  const timestamp2 = now();
14554
15169
  const completionTimestamp = input.completed_at ?? timestamp2;
14555
15170
  const sets = ["version = version + 1", "updated_at = ?"];
@@ -14757,6 +15372,21 @@ function updateTask2(id, input, db) {
14757
15372
  emitSharedTaskEventQuiet({ type: "task.updated", task: updatedTask, databasePath });
14758
15373
  return updatedTask;
14759
15374
  }
15375
+ function updateTask2(id, input, db) {
15376
+ const d = db || getDatabase();
15377
+ const before = getTask(id, d);
15378
+ if (!before)
15379
+ throw new TaskNotFoundError(id);
15380
+ const guardedPlanIds = [before.plan_id, input.plan_id];
15381
+ if (!guardedPlanIds.some(Boolean))
15382
+ return updateTaskStored(id, input, d);
15383
+ return d.transaction(() => {
15384
+ guardPlanRowsSqlite(guardedPlanIds, d);
15385
+ const current = getTask(id, d);
15386
+ guardPlanRowsSqlite([current?.plan_id, input.plan_id], d);
15387
+ return updateTaskStored(id, input, d);
15388
+ })();
15389
+ }
14760
15390
  function deleteTask(id, db) {
14761
15391
  const d = db || getDatabase();
14762
15392
  const row = d.query("SELECT * FROM tasks WHERE id = ?").get(id);
@@ -15563,7 +16193,7 @@ function listPlans(projectId, db) {
15563
16193
  }
15564
16194
  return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
15565
16195
  }
15566
- function updatePlan2(id, input, db) {
16196
+ function updatePlanStored(id, input, db) {
15567
16197
  const d = db || getDatabase();
15568
16198
  const plan = getPlan(id, d);
15569
16199
  if (!plan)
@@ -15608,6 +16238,13 @@ function updatePlan2(id, input, db) {
15608
16238
  });
15609
16239
  return updated;
15610
16240
  }
16241
+ function updatePlan2(id, input, db) {
16242
+ const d = db || getDatabase();
16243
+ return d.transaction(() => {
16244
+ guardPlanRowsSqlite([id], d);
16245
+ return updatePlanStored(id, input, d);
16246
+ })();
16247
+ }
15611
16248
  function deletePlan(id, db) {
15612
16249
  const d = db || getDatabase();
15613
16250
  const plan = getPlan(id, d);
@@ -16032,7 +16669,7 @@ var init_boards = __esm(() => {
16032
16669
  });
16033
16670
 
16034
16671
  // src/lib/artifact-store.ts
16035
- import { createHash as createHash4 } from "crypto";
16672
+ import { createHash as createHash5 } from "crypto";
16036
16673
  import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
16037
16674
  import { basename, dirname as dirname4, join as join6, resolve as resolve7 } from "path";
16038
16675
  import { tmpdir as tmpdir2 } from "os";
@@ -16057,7 +16694,7 @@ function artifactStorePath(relativePath) {
16057
16694
  return join6(artifactStoreRoot(), normalized);
16058
16695
  }
16059
16696
  function sha2562(buffer) {
16060
- return createHash4("sha256").update(buffer).digest("hex");
16697
+ return createHash5("sha256").update(buffer).digest("hex");
16061
16698
  }
16062
16699
  function isTextLike(buffer, path) {
16063
16700
  if (buffer.includes(0))
@@ -18344,6 +18981,187 @@ var init_integrity2 = __esm(() => {
18344
18981
  REQUIRED_TABLES = { tasks: true, task_lists: true, projects: true };
18345
18982
  });
18346
18983
 
18984
+ // src/db/plan-project-links.ts
18985
+ function getPlanProjectLinkReceipt(receiptId, db) {
18986
+ const d = db || getDatabase();
18987
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE receipt_id = ?").get(receiptId);
18988
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
18989
+ }
18990
+ function getPlanProjectLinkReceiptByIdempotencyKey(idempotencyKey, db) {
18991
+ const d = db || getDatabase();
18992
+ const row = d.query("SELECT payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(idempotencyKey);
18993
+ return row ? assertPlanProjectLinkReceipt(JSON.parse(row.payload)) : null;
18994
+ }
18995
+ function exactTasks(planId, db) {
18996
+ return listTasks2({ plan_id: planId, include_subtasks: true, include_archived: true }, db).sort((left, right) => left.id.localeCompare(right.id));
18997
+ }
18998
+ function currentResult(planId, projectId, receipt, db, action) {
18999
+ const plan = getPlan(planId, db);
19000
+ const project = getProject(projectId, db);
19001
+ if (!plan || !project) {
19002
+ 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 });
19003
+ }
19004
+ const tasks = exactTasks(planId, db);
19005
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest) {
19006
+ 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 });
19007
+ }
19008
+ return { mode: "apply", action, plan, project, tasks, receipt };
19009
+ }
19010
+ function applyPlanProjectLinkSqlite(input, db) {
19011
+ const d = db || getDatabase();
19012
+ const mutate = d.transaction(() => {
19013
+ guardPlanRowsSqlite([input.plan_id], d);
19014
+ const requestHash = planProjectLinkRequestHash(input.plan_id, input.project_id);
19015
+ const existingRow = d.query("SELECT payload_hash, payload FROM plan_project_link_receipts WHERE idempotency_key = ?").get(input.idempotency_key);
19016
+ if (existingRow) {
19017
+ if (existingRow.payload_hash !== requestHash) {
19018
+ 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 });
19019
+ }
19020
+ const rolledBack = d.query("SELECT rollback_receipt_id FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
19021
+ if (rolledBack) {
19022
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
19023
+ }
19024
+ return currentResult(input.plan_id, input.project_id, assertPlanProjectLinkReceipt(JSON.parse(existingRow.payload)), d, "already_linked");
19025
+ }
19026
+ const plan = getPlan(input.plan_id, d);
19027
+ if (!plan) {
19028
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${input.plan_id}`);
19029
+ }
19030
+ const project = getProject(input.project_id, d);
19031
+ if (!project) {
19032
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${input.project_id}`);
19033
+ }
19034
+ if (plan.updated_at !== input.expected_plan_revision) {
19035
+ 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 });
19036
+ }
19037
+ if (project.updated_at !== input.expected_project_revision) {
19038
+ 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 });
19039
+ }
19040
+ if (plan.slug) {
19041
+ const collision = d.query("SELECT id FROM plans WHERE project_id = ? AND slug = ? AND id <> ? LIMIT 1").get(project.id, plan.slug, plan.id);
19042
+ if (collision) {
19043
+ 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 });
19044
+ }
19045
+ }
19046
+ const beforeTasks = exactTasks(plan.id, d);
19047
+ const alreadyLinked = plan.project_id === project.id && beforeTasks.every((task) => task.project_id === project.id);
19048
+ const priorTaskProjectIds = Object.fromEntries(beforeTasks.map((task) => [task.id, task.project_id]));
19049
+ const timestamp2 = input.created_at;
19050
+ if (plan.project_id !== project.id) {
19051
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
19052
+ project.id,
19053
+ timestamp2,
19054
+ plan.id,
19055
+ input.expected_plan_revision
19056
+ ]);
19057
+ }
19058
+ for (const task of beforeTasks) {
19059
+ if (task.project_id === project.id)
19060
+ continue;
19061
+ d.run("UPDATE tasks SET project_id = ?, updated_at = ?, version = version + 1 WHERE id = ? AND plan_id = ?", [project.id, timestamp2, task.id, plan.id]);
19062
+ }
19063
+ const linkedPlan = getPlan(plan.id, d);
19064
+ const linkedTasks = exactTasks(plan.id, d);
19065
+ if (linkedPlan.project_id !== project.id || linkedTasks.length !== beforeTasks.length || linkedTasks.some((task) => task.project_id !== project.id)) {
19066
+ 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 });
19067
+ }
19068
+ const receipt = {
19069
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
19070
+ receipt_id: input.receipt_id,
19071
+ idempotency_key: input.idempotency_key,
19072
+ plan_id: plan.id,
19073
+ project_id: project.id,
19074
+ prior_plan_project_id: plan.project_id,
19075
+ prior_task_project_ids: priorTaskProjectIds,
19076
+ task_ids: beforeTasks.map((task) => task.id),
19077
+ task_count: beforeTasks.length,
19078
+ result_plan_revision: linkedPlan.updated_at,
19079
+ result_digest: planProjectLinkResultDigest(linkedPlan, linkedTasks),
19080
+ rollback_supported: true,
19081
+ created_at: input.created_at
19082
+ };
19083
+ d.run(`INSERT INTO plan_project_link_receipts
19084
+ (receipt_id, idempotency_key, plan_id, project_id, payload_hash, payload, created_at)
19085
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
19086
+ receipt.receipt_id,
19087
+ receipt.idempotency_key,
19088
+ receipt.plan_id,
19089
+ receipt.project_id,
19090
+ requestHash,
19091
+ JSON.stringify(receipt),
19092
+ receipt.created_at
19093
+ ]);
19094
+ return {
19095
+ mode: "apply",
19096
+ action: alreadyLinked ? "already_linked" : "linked",
19097
+ plan: linkedPlan,
19098
+ project,
19099
+ tasks: linkedTasks,
19100
+ receipt
19101
+ };
19102
+ });
19103
+ return mutate();
19104
+ }
19105
+ function rollbackPlanProjectLinkSqlite(input, db) {
19106
+ const d = db || getDatabase();
19107
+ return d.transaction(() => {
19108
+ guardPlanRowsSqlite([input.plan_id], d);
19109
+ const priorRollback = d.query("SELECT payload FROM plan_project_link_rollback_receipts WHERE accepted_receipt_id = ?").get(input.receipt_id);
19110
+ if (priorRollback)
19111
+ return JSON.parse(priorRollback.payload);
19112
+ const receipt = getPlanProjectLinkReceipt(input.receipt_id, d);
19113
+ if (!receipt || receipt.plan_id !== input.plan_id || receipt.project_id !== input.project_id) {
19114
+ 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 });
19115
+ }
19116
+ const plan = getPlan(input.plan_id, d);
19117
+ if (!plan || plan.updated_at !== input.expected_plan_revision) {
19118
+ 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 });
19119
+ }
19120
+ const tasks = exactTasks(plan.id, d);
19121
+ if (planProjectLinkResultDigest(plan, tasks) !== receipt.result_digest || tasks.length !== receipt.task_ids.length || tasks.some((task, index) => task.id !== receipt.task_ids[index])) {
19122
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Plan membership or project linkage drifted; refusing conditional rollback", { receipt_id: receipt.receipt_id });
19123
+ }
19124
+ d.run("UPDATE plans SET project_id = ?, updated_at = ? WHERE id = ? AND updated_at = ?", [
19125
+ receipt.prior_plan_project_id,
19126
+ input.restored_at,
19127
+ plan.id,
19128
+ input.expected_plan_revision
19129
+ ]);
19130
+ for (const task of tasks) {
19131
+ const priorProjectId = receipt.prior_task_project_ids[task.id];
19132
+ if (priorProjectId === undefined && !(task.id in receipt.prior_task_project_ids)) {
19133
+ 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 });
19134
+ }
19135
+ 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]);
19136
+ }
19137
+ const restoredPlan = getPlan(plan.id, d);
19138
+ const restoredTasks = exactTasks(plan.id, d);
19139
+ if (restoredPlan.project_id !== receipt.prior_plan_project_id || restoredTasks.some((task) => task.project_id !== receipt.prior_task_project_ids[task.id])) {
19140
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback readback did not restore every exact prior project id", { receipt_id: receipt.receipt_id });
19141
+ }
19142
+ const result = {
19143
+ schema_version: PLAN_PROJECT_LINK_SCHEMA_VERSION,
19144
+ action: "restored",
19145
+ plan: restoredPlan,
19146
+ tasks: restoredTasks,
19147
+ accepted_receipt_id: receipt.receipt_id,
19148
+ rollback_receipt_id: input.rollback_receipt_id,
19149
+ restored_at: input.restored_at
19150
+ };
19151
+ d.run(`INSERT INTO plan_project_link_rollback_receipts
19152
+ (rollback_receipt_id, accepted_receipt_id, payload, created_at)
19153
+ VALUES (?, ?, ?, ?)`, [result.rollback_receipt_id, result.accepted_receipt_id, JSON.stringify(result), result.restored_at]);
19154
+ return result;
19155
+ })();
19156
+ }
19157
+ var init_plan_project_links = __esm(() => {
19158
+ init_plan_project_link_contract();
19159
+ init_database();
19160
+ init_plans();
19161
+ init_projects();
19162
+ init_tasks();
19163
+ });
19164
+
18347
19165
  // src/storage/sqlite-snapshot.ts
18348
19166
  function exportSqliteTodosStorageSnapshot(db) {
18349
19167
  const d = db ?? getDatabase();
@@ -18850,6 +19668,12 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
18850
19668
  update: (id, input) => updatePlan2(id, input, database()),
18851
19669
  delete: (id) => deletePlan(id, database())
18852
19670
  },
19671
+ planProjectLinks: {
19672
+ apply: (input) => applyPlanProjectLinkSqlite(input, database()),
19673
+ rollback: (input) => rollbackPlanProjectLinkSqlite(input, database()),
19674
+ getReceipt: (receiptId) => getPlanProjectLinkReceipt(receiptId, database()),
19675
+ getReceiptByIdempotencyKey: (key) => getPlanProjectLinkReceiptByIdempotencyKey(key, database())
19676
+ },
18853
19677
  agents: {
18854
19678
  register: (input) => registerAgent2(input, database()),
18855
19679
  get: (id) => getAgent(id, database()),
@@ -18924,6 +19748,7 @@ var init_local_sqlite = __esm(() => {
18924
19748
  init_comments();
18925
19749
  init_database();
18926
19750
  init_integrity2();
19751
+ init_plan_project_links();
18927
19752
  init_sqlite_snapshot();
18928
19753
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18929
19754
  });
@@ -19533,7 +20358,7 @@ var init_sqlite = __esm(() => {
19533
20358
  });
19534
20359
 
19535
20360
  // src/project-registration/authority.ts
19536
- import { createHash as createHash5 } from "crypto";
20361
+ import { createHash as createHash6 } from "crypto";
19537
20362
  function canonicalProjectRegistrationJson(value) {
19538
20363
  return JSON.stringify(canonicalize(value));
19539
20364
  }
@@ -19551,7 +20376,7 @@ function canonicalize(value) {
19551
20376
  return out;
19552
20377
  }
19553
20378
  function digestProjectRegistrationValue(value) {
19554
- return createHash5("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
20379
+ return createHash6("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
19555
20380
  }
19556
20381
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
19557
20382
  return `prk_${digestProjectRegistrationValue({
@@ -20829,7 +21654,7 @@ var init_cloud = __esm(() => {
20829
21654
  });
20830
21655
 
20831
21656
  // src/db/api-keys.ts
20832
- import { createHash as createHash6, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
21657
+ import { createHash as createHash7, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
20833
21658
  function rowToRecord2(row) {
20834
21659
  return {
20835
21660
  id: row.id,
@@ -20843,7 +21668,7 @@ function rowToRecord2(row) {
20843
21668
  };
20844
21669
  }
20845
21670
  function hashApiKey(key) {
20846
- return createHash6("sha256").update(key).digest("hex");
21671
+ return createHash7("sha256").update(key).digest("hex");
20847
21672
  }
20848
21673
  function safeEqualHex(a, b) {
20849
21674
  if (a.length !== b.length)
@@ -20851,8 +21676,8 @@ function safeEqualHex(a, b) {
20851
21676
  return timingSafeEqual3(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
20852
21677
  }
20853
21678
  function safeEqualStrings(a, b) {
20854
- const ah = createHash6("sha256").update(a, "utf8").digest();
20855
- const bh = createHash6("sha256").update(b, "utf8").digest();
21679
+ const ah = createHash7("sha256").update(a, "utf8").digest();
21680
+ const bh = createHash7("sha256").update(b, "utf8").digest();
20856
21681
  return timingSafeEqual3(ah, bh);
20857
21682
  }
20858
21683
  function hasActiveApiKeys(db) {
@@ -22878,6 +23703,9 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
22878
23703
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
22879
23704
  TaskComment: taskCommentSchema,
22880
23705
  Plan: planSchema,
23706
+ PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
23707
+ PlanProjectLinkResult: planProjectLinkResultSchema,
23708
+ PlanProjectLinkRollbackResult: planProjectLinkRollbackResultSchema,
22881
23709
  Template: templateSchema,
22882
23710
  TemplateTask: templateTaskSchema,
22883
23711
  TemplateVariable: templateVariableSchema,
@@ -22891,6 +23719,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
22891
23719
  status: { type: "string" },
22892
23720
  priority: { type: "string" },
22893
23721
  project_id: { type: "string" },
23722
+ plan_id: { type: "string" },
22894
23723
  assigned_to: { type: "string" },
22895
23724
  agent_id: { type: "string" },
22896
23725
  tags: { type: "array", items: { type: "string" } }
@@ -22905,6 +23734,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
22905
23734
  priority: { type: "string" },
22906
23735
  assigned_to: { type: "string" },
22907
23736
  project_id: { type: "string", nullable: true },
23737
+ plan_id: { type: "string", nullable: true },
22908
23738
  task_list_id: { type: "string", nullable: true },
22909
23739
  version: { type: "number" }
22910
23740
  }
@@ -22976,6 +23806,32 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
22976
23806
  expected_task_list_revision: { type: "string", minLength: 1 }
22977
23807
  }
22978
23808
  },
23809
+ PlanProjectLinkApplyInput: {
23810
+ type: "object",
23811
+ additionalProperties: false,
23812
+ required: ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"],
23813
+ properties: {
23814
+ project_id: { type: "string", minLength: 1 },
23815
+ expected_plan_revision: { type: "string", minLength: 1 },
23816
+ expected_project_revision: { type: "string", minLength: 1 },
23817
+ idempotency_key: {
23818
+ type: "string",
23819
+ minLength: 8,
23820
+ maxLength: 128,
23821
+ pattern: "^[A-Za-z0-9._:-]+$"
23822
+ }
23823
+ }
23824
+ },
23825
+ PlanProjectLinkRollbackInput: {
23826
+ type: "object",
23827
+ additionalProperties: false,
23828
+ required: ["project_id", "receipt_id", "expected_plan_revision"],
23829
+ properties: {
23830
+ project_id: { type: "string", minLength: 1 },
23831
+ receipt_id: { type: "string", minLength: 1 },
23832
+ expected_plan_revision: { type: "string", minLength: 1 }
23833
+ }
23834
+ },
22979
23835
  ErrorResponse: {
22980
23836
  type: "object",
22981
23837
  required: ["error"],
@@ -23859,6 +24715,12 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
23859
24715
  { name: "assigned_to", in: "query", schema: { type: "string" } },
23860
24716
  { name: "agent_id", in: "query", schema: { type: "string" } },
23861
24717
  { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
24718
+ {
24719
+ name: "updated_after",
24720
+ in: "query",
24721
+ schema: { type: "string", format: "date-time" },
24722
+ 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."
24723
+ },
23862
24724
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
23863
24725
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
23864
24726
  ],
@@ -24187,6 +25049,54 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
24187
25049
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean" }, id: { type: "string" } } } } } } }
24188
25050
  }
24189
25051
  },
25052
+ "/v1/plans/{id}/project-link": {
25053
+ get: {
25054
+ operationId: "planPlanProjectLink",
25055
+ summary: "Plan atomic linkage of an existing plan and every current member task to a project",
25056
+ parameters: [
25057
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
25058
+ { name: "project_id", in: "query", required: true, schema: { type: "string" } }
25059
+ ],
25060
+ responses: {
25061
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
25062
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
25063
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
25064
+ }
25065
+ },
25066
+ post: {
25067
+ operationId: "applyPlanProjectLink",
25068
+ summary: "Atomically and idempotently link an existing plan and every current member task to a project",
25069
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
25070
+ requestBody: {
25071
+ required: true,
25072
+ content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkApplyInput" } } }
25073
+ },
25074
+ responses: {
25075
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
25076
+ "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkResult" } } } },
25077
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
25078
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
25079
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
25080
+ }
25081
+ }
25082
+ },
25083
+ "/v1/plans/{id}/project-link/rollback": {
25084
+ post: {
25085
+ operationId: "rollbackPlanProjectLink",
25086
+ summary: "Conditionally restore every exact prior project link from an accepted receipt",
25087
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
25088
+ requestBody: {
25089
+ required: true,
25090
+ content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackInput" } } }
25091
+ },
25092
+ responses: {
25093
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/PlanProjectLinkRollbackResult" } } } },
25094
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
25095
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
25096
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
25097
+ }
25098
+ }
25099
+ },
24190
25100
  "/v1/templates": {
24191
25101
  get: {
24192
25102
  operationId: "listTemplates",
@@ -24404,7 +25314,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
24404
25314
  }
24405
25315
  };
24406
25316
  }
24407
- var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
25317
+ var taskSchema, projectSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
24408
25318
  var init_openapi = __esm(() => {
24409
25319
  init_package_version();
24410
25320
  init_types();
@@ -24556,6 +25466,75 @@ var init_openapi = __esm(() => {
24556
25466
  updated_at: { type: "string", format: "date-time" }
24557
25467
  }
24558
25468
  };
25469
+ planProjectLinkReceiptSchema = {
25470
+ type: "object",
25471
+ additionalProperties: false,
25472
+ required: [
25473
+ "schema_version",
25474
+ "receipt_id",
25475
+ "idempotency_key",
25476
+ "plan_id",
25477
+ "project_id",
25478
+ "prior_plan_project_id",
25479
+ "prior_task_project_ids",
25480
+ "task_ids",
25481
+ "task_count",
25482
+ "result_plan_revision",
25483
+ "result_digest",
25484
+ "rollback_supported",
25485
+ "created_at"
25486
+ ],
25487
+ properties: {
25488
+ schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
25489
+ receipt_id: { type: "string" },
25490
+ idempotency_key: { type: "string" },
25491
+ plan_id: { type: "string" },
25492
+ project_id: { type: "string" },
25493
+ prior_plan_project_id: { type: "string", nullable: true },
25494
+ prior_task_project_ids: {
25495
+ type: "object",
25496
+ additionalProperties: { type: "string", nullable: true }
25497
+ },
25498
+ task_ids: { type: "array", items: { type: "string" } },
25499
+ task_count: { type: "integer", minimum: 0 },
25500
+ result_plan_revision: { type: "string" },
25501
+ result_digest: { type: "string" },
25502
+ rollback_supported: { type: "boolean", enum: [true] },
25503
+ created_at: { type: "string", format: "date-time" }
25504
+ }
25505
+ };
25506
+ planProjectLinkResultSchema = {
25507
+ type: "object",
25508
+ additionalProperties: false,
25509
+ required: ["mode", "action", "plan", "project", "tasks", "receipt"],
25510
+ properties: {
25511
+ mode: { type: "string", enum: ["plan", "apply"] },
25512
+ action: { type: "string", enum: ["would_link", "linked", "already_linked"] },
25513
+ plan: { $ref: "#/components/schemas/Plan" },
25514
+ project: { $ref: "#/components/schemas/Project" },
25515
+ tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
25516
+ receipt: {
25517
+ oneOf: [
25518
+ { $ref: "#/components/schemas/PlanProjectLinkReceipt" },
25519
+ { type: "null" }
25520
+ ]
25521
+ }
25522
+ }
25523
+ };
25524
+ planProjectLinkRollbackResultSchema = {
25525
+ type: "object",
25526
+ additionalProperties: false,
25527
+ required: ["schema_version", "action", "plan", "tasks", "accepted_receipt_id", "rollback_receipt_id", "restored_at"],
25528
+ properties: {
25529
+ schema_version: { type: "string", enum: ["todos.plan-project-link.v1"] },
25530
+ action: { type: "string", enum: ["restored"] },
25531
+ plan: { $ref: "#/components/schemas/Plan" },
25532
+ tasks: { type: "array", items: { $ref: "#/components/schemas/Task" } },
25533
+ accepted_receipt_id: { type: "string" },
25534
+ rollback_receipt_id: { type: "string" },
25535
+ restored_at: { type: "string", format: "date-time" }
25536
+ }
25537
+ };
24559
25538
  templateTaskSchema = {
24560
25539
  type: "object",
24561
25540
  required: ["id", "template_id", "position", "title_pattern", "priority", "tags", "depends_on_positions", "metadata", "created_at"],
@@ -24760,7 +25739,7 @@ function decodeCommentCursor(value) {
24760
25739
  var MAX_COMMENT_CURSOR_LENGTH = 1024;
24761
25740
 
24762
25741
  // src/lib/project-task-list-ensure.ts
24763
- import { createHash as createHash7 } from "crypto";
25742
+ import { createHash as createHash8 } from "crypto";
24764
25743
  function canonicalJson(value) {
24765
25744
  if (value === null || typeof value !== "object")
24766
25745
  return JSON.stringify(value);
@@ -24769,7 +25748,7 @@ function canonicalJson(value) {
24769
25748
  return `{${Object.entries(value).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
24770
25749
  }
24771
25750
  function digest(value) {
24772
- return createHash7("sha256").update(canonicalJson(value)).digest("hex");
25751
+ return createHash8("sha256").update(canonicalJson(value)).digest("hex");
24773
25752
  }
24774
25753
  function deriveIdempotencyKey(projectId, slug) {
24775
25754
  return `ptlk_${digest({ project_id: projectId, slug }).slice(0, 48)}`;
@@ -25014,6 +25993,75 @@ var init_project_task_list_ensure = __esm(() => {
25014
25993
  };
25015
25994
  });
25016
25995
 
25996
+ // src/lib/plan-project-link.ts
25997
+ async function exactPlanProjectLinkState(store, planId, projectId) {
25998
+ const [plan, project] = await Promise.all([
25999
+ store.plans.get(planId),
26000
+ store.projects.get(projectId)
26001
+ ]);
26002
+ if (!plan) {
26003
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan not found: ${planId}`, { plan_id: planId });
26004
+ }
26005
+ if (!project) {
26006
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PROJECT_NOT_FOUND", `Project not found: ${projectId}`, { project_id: projectId });
26007
+ }
26008
+ const [tasks, projectPlans] = await Promise.all([
26009
+ store.tasks.list({ plan_id: plan.id, include_subtasks: true, include_archived: true }),
26010
+ store.plans.list(project.id)
26011
+ ]);
26012
+ const collision = projectPlans.find((candidate) => candidate.id !== plan.id && candidate.slug !== null && candidate.slug === plan.slug);
26013
+ if (collision) {
26014
+ 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 });
26015
+ }
26016
+ return {
26017
+ plan,
26018
+ project,
26019
+ tasks: tasks.sort((left, right) => left.id.localeCompare(right.id))
26020
+ };
26021
+ }
26022
+ async function planPlanProjectLink(store, planId, projectId) {
26023
+ const state = await exactPlanProjectLinkState(store, planId, projectId);
26024
+ const alreadyLinked = state.plan.project_id === state.project.id && state.tasks.every((task) => task.project_id === state.project.id);
26025
+ return {
26026
+ mode: "plan",
26027
+ action: alreadyLinked ? "already_linked" : "would_link",
26028
+ ...state,
26029
+ receipt: null
26030
+ };
26031
+ }
26032
+ async function applyPlanProjectLink(store, planId, projectId, options) {
26033
+ if (!store.planProjectLinks) {
26034
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically link an existing plan and its tasks", { storage_kind: store.kind });
26035
+ }
26036
+ const key = normalizePlanProjectLinkIdempotencyKey(options.idempotency_key);
26037
+ return store.planProjectLinks.apply({
26038
+ plan_id: planId,
26039
+ project_id: projectId,
26040
+ expected_plan_revision: options.expected_plan_revision,
26041
+ expected_project_revision: options.expected_project_revision,
26042
+ idempotency_key: key,
26043
+ receipt_id: planProjectLinkReceiptId(key),
26044
+ created_at: new Date().toISOString()
26045
+ });
26046
+ }
26047
+ async function rollbackPlanProjectLink(store, planId, projectId, options) {
26048
+ if (!store.planProjectLinks) {
26049
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_UNSUPPORTED", "This storage backend cannot atomically roll back an existing plan project link", { storage_kind: store.kind });
26050
+ }
26051
+ return store.planProjectLinks.rollback({
26052
+ plan_id: planId,
26053
+ project_id: projectId,
26054
+ receipt_id: options.receipt_id,
26055
+ expected_plan_revision: options.expected_plan_revision,
26056
+ rollback_receipt_id: planProjectLinkRollbackReceiptId(options.receipt_id),
26057
+ restored_at: new Date().toISOString()
26058
+ });
26059
+ }
26060
+ var init_plan_project_link = __esm(() => {
26061
+ init_plan_project_link_contract();
26062
+ init_plan_project_link_contract();
26063
+ });
26064
+
25017
26065
  // src/server/v1.ts
25018
26066
  var exports_v1 = {};
25019
26067
  __export(exports_v1, {
@@ -25036,6 +26084,21 @@ function enumQueryParam(url, name, vocabulary) {
25036
26084
  return { ok: false, response: error(400, result.message) };
25037
26085
  return { ok: true, value: collapseEnumValues(result.values) };
25038
26086
  }
26087
+ function parseSinceCursor(raw) {
26088
+ 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)}`;
26089
+ const match = RFC3339_DATE_TIME.exec(raw);
26090
+ if (!match)
26091
+ return { ok: false, message: shape };
26092
+ const parsed = Date.parse(raw);
26093
+ if (Number.isNaN(parsed))
26094
+ return { ok: false, message: shape };
26095
+ const [, year, month, day] = match;
26096
+ const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
26097
+ if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() !== Number(month) - 1 || probe.getUTCDate() !== Number(day)) {
26098
+ return { ok: false, message: `updated_after names a date that does not exist: ${JSON.stringify(raw)}` };
26099
+ }
26100
+ return { ok: true, value: new Date(parsed).toISOString() };
26101
+ }
25039
26102
  function validateTaskCompletion(value) {
25040
26103
  if (!value || typeof value !== "object" || Array.isArray(value))
25041
26104
  return { ok: false, message: "completion body must be an object" };
@@ -25445,7 +26508,13 @@ async function handleV1Request(req, url, dependencies = {}) {
25445
26508
  const priorityParam = enumQueryParam(url, "priority", TASK_PRIORITIES);
25446
26509
  if (!priorityParam.ok)
25447
26510
  return priorityParam.response;
26511
+ const updatedAfterRaw = url.searchParams.get("updated_after");
26512
+ const updatedAfter = updatedAfterRaw === null ? null : parseSinceCursor(updatedAfterRaw);
26513
+ if (updatedAfter !== null && !updatedAfter.ok) {
26514
+ return error(400, updatedAfter.message);
26515
+ }
25448
26516
  const filter = {
26517
+ ...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
25449
26518
  ...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
25450
26519
  ...statusParam.value !== undefined ? { status: statusParam.value } : {},
25451
26520
  ...priorityParam.value !== undefined ? { priority: priorityParam.value } : {},
@@ -25907,6 +26976,54 @@ async function handleV1Request(req, url, dependencies = {}) {
25907
26976
  const plan = await store.plans.create(validated.input, contextFromPrincipal(principal, validated.input));
25908
26977
  return json4({ plan }, 201);
25909
26978
  }
26979
+ if (id && action === "project-link" && !subId) {
26980
+ if (method === "GET") {
26981
+ const projectId = url.searchParams.get("project_id");
26982
+ if (!projectId?.trim())
26983
+ return error(400, "project_id query parameter is required");
26984
+ return json4(await planPlanProjectLink(store, id, projectId));
26985
+ }
26986
+ if (method !== "POST")
26987
+ return error(405, `method ${method} not allowed on /v1/plans/:id/project-link`);
26988
+ const body = await readJson3(req);
26989
+ if (!body)
26990
+ return error(400, "invalid JSON body");
26991
+ const allowed = new Set(["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]);
26992
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
26993
+ if (unknown)
26994
+ return error(400, `unknown plan-project-link field: ${unknown}`);
26995
+ for (const field of ["project_id", "expected_plan_revision", "expected_project_revision", "idempotency_key"]) {
26996
+ if (typeof body[field] !== "string" || !body[field].trim()) {
26997
+ return error(400, `${field} must be a non-empty string`);
26998
+ }
26999
+ }
27000
+ const result = await applyPlanProjectLink(store, id, body.project_id, {
27001
+ expected_plan_revision: body.expected_plan_revision,
27002
+ expected_project_revision: body.expected_project_revision,
27003
+ idempotency_key: body.idempotency_key
27004
+ });
27005
+ return json4(result, result.action === "linked" ? 201 : 200);
27006
+ }
27007
+ if (id && action === "project-link" && subId === "rollback") {
27008
+ if (method !== "POST")
27009
+ return error(405, `method ${method} not allowed on /v1/plans/:id/project-link/rollback`);
27010
+ const body = await readJson3(req);
27011
+ if (!body)
27012
+ return error(400, "invalid JSON body");
27013
+ const allowed = new Set(["project_id", "receipt_id", "expected_plan_revision"]);
27014
+ const unknown = Object.keys(body).find((key) => !allowed.has(key));
27015
+ if (unknown)
27016
+ return error(400, `unknown plan-project-link rollback field: ${unknown}`);
27017
+ for (const field of ["project_id", "receipt_id", "expected_plan_revision"]) {
27018
+ if (typeof body[field] !== "string" || !body[field].trim()) {
27019
+ return error(400, `${field} must be a non-empty string`);
27020
+ }
27021
+ }
27022
+ return json4(await rollbackPlanProjectLink(store, id, body.project_id, {
27023
+ receipt_id: body.receipt_id,
27024
+ expected_plan_revision: body.expected_plan_revision
27025
+ }));
27026
+ }
25910
27027
  if (id && method === "GET") {
25911
27028
  const plan = await store.plans.get(id);
25912
27029
  return plan ? json4({ plan }) : error(404, "plan not found");
@@ -26185,6 +27302,10 @@ async function handleV1Request(req, url, dependencies = {}) {
26185
27302
  }
26186
27303
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
26187
27304
  } catch (e) {
27305
+ if (e instanceof PlanProjectLinkError) {
27306
+ 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;
27307
+ return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
27308
+ }
26188
27309
  if (e instanceof ProjectTaskListEnsureError) {
26189
27310
  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;
26190
27311
  return error(status, e.message, { code: e.code, conflict: status === 409, ...e.details });
@@ -26208,7 +27329,7 @@ async function handleV1Request(req, url, dependencies = {}) {
26208
27329
  return error(500, e.message || "internal error");
26209
27330
  }
26210
27331
  }
26211
- var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
27332
+ var JSON_HEADERS3, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500, RFC3339_DATE_TIME;
26212
27333
  var init_v1 = __esm(() => {
26213
27334
  init_types();
26214
27335
  init_cloud();
@@ -26216,7 +27337,9 @@ var init_v1 = __esm(() => {
26216
27337
  init_project_registration();
26217
27338
  init_redaction();
26218
27339
  init_project_task_list_ensure();
27340
+ init_plan_project_link();
26219
27341
  JSON_HEADERS3 = { "Content-Type": "application/json" };
27342
+ RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
26220
27343
  });
26221
27344
 
26222
27345
  // node_modules/.bun/zod@3.25.76/node_modules/zod/v4/core/core.js
@@ -63870,12 +64993,12 @@ var init_local_notifications = __esm(() => {
63870
64993
  });
63871
64994
 
63872
64995
  // src/lib/local-encryption.ts
63873
- import { createCipheriv, createDecipheriv, createHash as createHash8, randomBytes as randomBytes3, scryptSync, timingSafeEqual as timingSafeEqual4 } from "crypto";
64996
+ import { createCipheriv, createDecipheriv, createHash as createHash9, randomBytes as randomBytes3, scryptSync, timingSafeEqual as timingSafeEqual4 } from "crypto";
63874
64997
  function now3() {
63875
64998
  return new Date().toISOString();
63876
64999
  }
63877
65000
  function sha2563(value) {
63878
- return createHash8("sha256").update(value).digest("hex");
65001
+ return createHash9("sha256").update(value).digest("hex");
63879
65002
  }
63880
65003
  function normalizeProfileName(value) {
63881
65004
  const name = (value || DEFAULT_ENCRYPTION_PROFILE).trim();
@@ -66013,7 +67136,7 @@ var init_capacity_forecasts = __esm(() => {
66013
67136
  });
66014
67137
 
66015
67138
  // src/lib/audit-ledger.ts
66016
- import { createHash as createHash9 } from "crypto";
67139
+ import { createHash as createHash10 } from "crypto";
66017
67140
  function canonicalize2(value) {
66018
67141
  if (value === null || typeof value !== "object")
66019
67142
  return JSON.stringify(value);
@@ -66023,7 +67146,7 @@ function canonicalize2(value) {
66023
67146
  return `{${Object.keys(object4).sort().map((key) => `${JSON.stringify(key)}:${canonicalize2(object4[key])}`).join(",")}}`;
66024
67147
  }
66025
67148
  function hash(value) {
66026
- return createHash9("sha256").update(value).digest("hex");
67149
+ return createHash10("sha256").update(value).digest("hex");
66027
67150
  }
66028
67151
  function parsePayload3(value) {
66029
67152
  if (!value)
@@ -99124,7 +100247,7 @@ var init_local_bridge = __esm(() => {
99124
100247
  });
99125
100248
 
99126
100249
  // src/lib/local-backups.ts
99127
- import { createHash as createHash10 } from "crypto";
100250
+ import { createHash as createHash11 } from "crypto";
99128
100251
  import { readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
99129
100252
  import { dirname as dirname7, resolve as resolve13 } from "path";
99130
100253
  import { mkdirSync as mkdirSync6 } from "fs";
@@ -99137,7 +100260,7 @@ function stableJson2(value) {
99137
100260
  return `{${Object.keys(record3).sort().map((key) => `${JSON.stringify(key)}:${stableJson2(record3[key])}`).join(",")}}`;
99138
100261
  }
99139
100262
  function sha2564(value) {
99140
- return createHash10("sha256").update(stableJson2(value)).digest("hex");
100263
+ return createHash11("sha256").update(stableJson2(value)).digest("hex");
99141
100264
  }
99142
100265
  function sqliteIntegrity(db) {
99143
100266
  let quick = "unknown";
@@ -99787,7 +100910,7 @@ var init_onboarding_fixtures = __esm(() => {
99787
100910
  });
99788
100911
 
99789
100912
  // src/lib/local-snapshots.ts
99790
- import { createHash as createHash11 } from "crypto";
100913
+ import { createHash as createHash12 } from "crypto";
99791
100914
  function source(version2) {
99792
100915
  return {
99793
100916
  packageName: "@hasna/todos",
@@ -99811,7 +100934,7 @@ function stable(value) {
99811
100934
  return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stable(item)]));
99812
100935
  }
99813
100936
  function sha2565(value) {
99814
- return createHash11("sha256").update(JSON.stringify(stable(value))).digest("hex");
100937
+ return createHash12("sha256").update(JSON.stringify(stable(value))).digest("hex");
99815
100938
  }
99816
100939
  function latestTimestamp2(items, fallback) {
99817
100940
  const timestamps = [];
@@ -100377,7 +101500,7 @@ var init_retrospectives = __esm(() => {
100377
101500
  });
100378
101501
 
100379
101502
  // src/lib/agent-replay-simulator.ts
100380
- import { createHash as createHash12 } from "crypto";
101503
+ import { createHash as createHash13 } from "crypto";
100381
101504
  function isObject2(value) {
100382
101505
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
100383
101506
  }
@@ -100398,7 +101521,7 @@ function stable2(value) {
100398
101521
  return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable2(value[key])]));
100399
101522
  }
100400
101523
  function fingerprint2(value) {
100401
- return createHash12("sha256").update(JSON.stringify(stable2(value))).digest("hex");
101524
+ return createHash13("sha256").update(JSON.stringify(stable2(value))).digest("hex");
100402
101525
  }
100403
101526
  function unpackFixture(input) {
100404
101527
  if (!isObject2(input))
@@ -100685,7 +101808,7 @@ __export(exports_local_extensions, {
100685
101808
  getLocalExtension: () => getLocalExtension,
100686
101809
  discoverLocalExtensions: () => discoverLocalExtensions
100687
101810
  });
100688
- import { createHash as createHash13, createVerify } from "crypto";
101811
+ import { createHash as createHash14, createVerify } from "crypto";
100689
101812
  import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
100690
101813
  import { basename as basename5, join as join12, resolve as resolve14 } from "path";
100691
101814
  function isObject3(value) {
@@ -100771,7 +101894,7 @@ function parseJson(path) {
100771
101894
  return JSON.parse(readFileSync9(path, "utf8"));
100772
101895
  }
100773
101896
  function sha2566(bytes) {
100774
- return `sha256:${createHash13("sha256").update(bytes).digest("hex")}`;
101897
+ return `sha256:${createHash14("sha256").update(bytes).digest("hex")}`;
100775
101898
  }
100776
101899
  function compareVersions(a, b) {
100777
101900
  const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -101246,7 +102369,7 @@ function issueToTask(issue2, opts) {
101246
102369
  var init_github = () => {};
101247
102370
 
101248
102371
  // src/db/inbox.ts
101249
- import { createHash as createHash14 } from "crypto";
102372
+ import { createHash as createHash15 } from "crypto";
101250
102373
  function parseMetadata3(value) {
101251
102374
  if (!value)
101252
102375
  return {};
@@ -101266,7 +102389,7 @@ function compactWhitespace(value) {
101266
102389
  function fingerprintInboxInput(input) {
101267
102390
  const sourceType = input.source_type || detectInboxSourceType(input.body, input.source_url);
101268
102391
  const normalized = compactWhitespace(sanitizePreWriteText(input.body, "inbox.fingerprint")).slice(0, 8000);
101269
- return createHash14("sha256").update(`${sourceType}
102392
+ return createHash15("sha256").update(`${sourceType}
101270
102393
  ${input.source_url || ""}
101271
102394
  ${normalized}`).digest("hex");
101272
102395
  }
@@ -105466,10 +106589,10 @@ __export(exports_extract, {
105466
106589
  EXTRACT_TAGS: () => EXTRACT_TAGS
105467
106590
  });
105468
106591
  import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync7 } from "fs";
105469
- import { createHash as createHash15 } from "crypto";
106592
+ import { createHash as createHash16 } from "crypto";
105470
106593
  import { relative as relative5, resolve as resolve15, join as join13 } from "path";
105471
106594
  function stableHash(value) {
105472
- return createHash15("sha256").update(value).digest("hex");
106595
+ return createHash16("sha256").update(value).digest("hex");
105473
106596
  }
105474
106597
  function normalizePathForMatch(value) {
105475
106598
  return value.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -107193,13 +108316,13 @@ __export(exports_environment_snapshots, {
107193
108316
  compareEnvironmentSnapshotFiles: () => compareEnvironmentSnapshotFiles,
107194
108317
  captureEnvironmentSnapshot: () => captureEnvironmentSnapshot
107195
108318
  });
107196
- import { createHash as createHash16 } from "crypto";
108319
+ import { createHash as createHash17 } from "crypto";
107197
108320
  import { existsSync as existsSync15, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
107198
108321
  import { hostname as hostname3, platform, arch } from "os";
107199
108322
  import { dirname as dirname8, join as join15, resolve as resolve16 } from "path";
107200
108323
  import { tmpdir as tmpdir3 } from "os";
107201
108324
  function sha2567(value) {
107202
- return createHash16("sha256").update(value).digest("hex");
108325
+ return createHash17("sha256").update(value).digest("hex");
107203
108326
  }
107204
108327
  function fileRecord(root, relativePath) {
107205
108328
  const path = join15(root, relativePath);