@hasna/todos 0.15.18 → 0.15.20

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 (47) hide show
  1. package/dist/cli/cloud-router.d.ts +20 -1
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +1306 -122
  7. package/dist/cli/stage-a.d.ts +21 -10
  8. package/dist/cli/stage-a.d.ts.map +1 -1
  9. package/dist/contracts.js +283 -16
  10. package/dist/db/audit.d.ts +8 -0
  11. package/dist/db/audit.d.ts.map +1 -1
  12. package/dist/db/plans.d.ts +4 -0
  13. package/dist/db/plans.d.ts.map +1 -1
  14. package/dist/db/task-lifecycle.d.ts +7 -1
  15. package/dist/db/task-lifecycle.d.ts.map +1 -1
  16. package/dist/db/tasks.d.ts +1 -1
  17. package/dist/db/tasks.d.ts.map +1 -1
  18. package/dist/index.js +599 -26
  19. package/dist/lib/cli-help.d.ts +3 -2
  20. package/dist/lib/cli-help.d.ts.map +1 -1
  21. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  22. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  23. package/dist/mcp/index.js +954 -46
  24. package/dist/mcp.js +3 -1
  25. package/dist/project-registration.js +4250 -3684
  26. package/dist/registry.js +283 -16
  27. package/dist/release-provenance.json +5 -5
  28. package/dist/sdk/index.js +7 -0
  29. package/dist/sdk/v1.generated.d.ts +40 -1
  30. package/dist/sdk/v1.generated.d.ts.map +1 -1
  31. package/dist/server/index.js +1287 -379
  32. package/dist/server/openapi.d.ts +232 -0
  33. package/dist/server/openapi.d.ts.map +1 -1
  34. package/dist/server/v1.d.ts.map +1 -1
  35. package/dist/storage/audit-history-import.d.ts +14 -0
  36. package/dist/storage/audit-history-import.d.ts.map +1 -0
  37. package/dist/storage/interfaces.d.ts +22 -1
  38. package/dist/storage/interfaces.d.ts.map +1 -1
  39. package/dist/storage/local-sqlite.d.ts.map +1 -1
  40. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  41. package/dist/storage/shadow.d.ts.map +1 -1
  42. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  43. package/dist/storage.js +596 -25
  44. package/dist/task-manifest.js +24 -1
  45. package/dist/types/index.d.ts +50 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/package.json +3 -1
package/dist/mcp/index.js CHANGED
@@ -47,7 +47,7 @@ function isBlockingDependencyStatus(status) {
47
47
  function isTerminalStatus(status) {
48
48
  return status === "completed" || status === "failed" || status === "cancelled";
49
49
  }
50
- var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
50
+ var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
51
51
  var init_types = __esm(() => {
52
52
  TASK_STATUSES = [
53
53
  "pending",
@@ -134,6 +134,19 @@ var init_types = __esm(() => {
134
134
  this.name = "ResourceConflictError";
135
135
  }
136
136
  };
137
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
138
+ planId;
139
+ expectedUpdatedAt;
140
+ currentUpdatedAt;
141
+ static code = "PLAN_REVISION_CONFLICT";
142
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
143
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
144
+ this.planId = planId;
145
+ this.expectedUpdatedAt = expectedUpdatedAt;
146
+ this.currentUpdatedAt = currentUpdatedAt;
147
+ this.name = "PlanRevisionConflictError";
148
+ }
149
+ };
137
150
  PlanNotFoundError = class PlanNotFoundError extends Error {
138
151
  planId;
139
152
  static code = "PLAN_NOT_FOUND";
@@ -156,6 +169,16 @@ var init_types = __esm(() => {
156
169
  this.name = "LockError";
157
170
  }
158
171
  };
172
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
173
+ code;
174
+ details;
175
+ constructor(code, message, details = {}) {
176
+ super(message);
177
+ this.code = code;
178
+ this.details = details;
179
+ this.name = "StaleLockHandoffError";
180
+ }
181
+ };
159
182
  AgentNotFoundError = class AgentNotFoundError extends Error {
160
183
  agentId;
161
184
  static code = "AGENT_NOT_FOUND";
@@ -12157,28 +12180,55 @@ var init_activity_audit = __esm(() => {
12157
12180
  function sanitizeHistoryValue(value, context) {
12158
12181
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
12159
12182
  }
12160
- function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
12183
+ function insertTaskHistory(entry, db) {
12161
12184
  const d = db || getDatabase();
12162
- const id = uuid();
12163
- const timestamp2 = now();
12164
- const machineId = currentStorageMachineId(d);
12165
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
12166
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
12185
+ const safeEntry = {
12186
+ ...entry,
12187
+ field: entry.field || null,
12188
+ old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
12189
+ new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
12190
+ agent_id: entry.agent_id || null,
12191
+ machine_id: entry.machine_id ?? currentStorageMachineId(d)
12192
+ };
12167
12193
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
12168
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
12194
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
12195
+ safeEntry.id,
12196
+ safeEntry.task_id,
12197
+ safeEntry.action,
12198
+ safeEntry.field,
12199
+ safeEntry.old_value,
12200
+ safeEntry.new_value,
12201
+ safeEntry.agent_id,
12202
+ safeEntry.created_at,
12203
+ safeEntry.machine_id ?? null
12204
+ ]);
12169
12205
  try {
12170
12206
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
12171
12207
  logActivity2({
12172
12208
  entity_type: "task",
12173
- entity_id: taskId,
12174
- action,
12175
- field,
12176
- old_value: safeOldValue,
12177
- new_value: safeNewValue,
12178
- actor_id: agentId ?? undefined
12209
+ entity_id: safeEntry.task_id,
12210
+ action: safeEntry.action,
12211
+ field: safeEntry.field ?? undefined,
12212
+ old_value: safeEntry.old_value,
12213
+ new_value: safeEntry.new_value,
12214
+ actor_id: safeEntry.agent_id ?? undefined
12179
12215
  }, d);
12180
12216
  } catch {}
12181
- return { id, task_id: taskId, action, field: field || null, old_value: safeOldValue, new_value: safeNewValue, agent_id: agentId || null, created_at: timestamp2, machine_id: machineId };
12217
+ return safeEntry;
12218
+ }
12219
+ function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
12220
+ const d = db || getDatabase();
12221
+ return insertTaskHistory({
12222
+ id: uuid(),
12223
+ task_id: taskId,
12224
+ action,
12225
+ field: field || null,
12226
+ old_value: oldValue ?? null,
12227
+ new_value: newValue ?? null,
12228
+ agent_id: agentId || null,
12229
+ created_at: now(),
12230
+ machine_id: currentStorageMachineId(d)
12231
+ }, d);
12182
12232
  }
12183
12233
  function getTaskHistory(taskId, db) {
12184
12234
  const d = db || getDatabase();
@@ -13181,6 +13231,145 @@ var init_task_graph = __esm(() => {
13181
13231
  init_task_crud();
13182
13232
  });
13183
13233
 
13234
+ // src/lib/stale-lock-handoff.ts
13235
+ function normalizeExactTaskId(value) {
13236
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
13237
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_TASK_ID", "stale-lock handoff requires one exact full task UUID", { task_id: typeof value === "string" ? value : null });
13238
+ }
13239
+ return value.trim().toLowerCase();
13240
+ }
13241
+ function requireNonEmptyString(value, field) {
13242
+ if (typeof value !== "string" || !value.trim()) {
13243
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
13244
+ }
13245
+ const trimmed = value.trim();
13246
+ if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
13247
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
13248
+ }
13249
+ return trimmed;
13250
+ }
13251
+ function requireCanonicalLockVersion(value) {
13252
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
13253
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must be the exact canonical locked_at timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)", { field: "expected_lock_version" });
13254
+ }
13255
+ const parsed = Date.parse(value);
13256
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
13257
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
13258
+ }
13259
+ return value;
13260
+ }
13261
+ function requireStaleThreshold(value) {
13262
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
13263
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
13264
+ }
13265
+ return Number(value);
13266
+ }
13267
+ function prepareStaleLockHandoff(input, options = {}) {
13268
+ const taskId = normalizeExactTaskId(input.task_id);
13269
+ const actor = requireNonEmptyString(input.actor, "actor");
13270
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
13271
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
13272
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
13273
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
13274
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
13275
+ if (!reason) {
13276
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
13277
+ }
13278
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
13279
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
13280
+ }
13281
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
13282
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
13283
+ }
13284
+ const operationTimestamp = options.now ?? new Date().toISOString();
13285
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
13286
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
13287
+ }
13288
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
13289
+ return {
13290
+ task_id: taskId,
13291
+ actor,
13292
+ expected_holder: expectedHolder,
13293
+ expected_lock_version: expectedLockVersion,
13294
+ stale_after_seconds: staleAfterSeconds,
13295
+ new_holder: newHolder,
13296
+ reason,
13297
+ operation_timestamp: operationTimestamp,
13298
+ stale_cutoff: staleCutoff,
13299
+ receipt_id: options.receiptId ?? crypto.randomUUID()
13300
+ };
13301
+ }
13302
+ function buildStaleLockHandoffReceipt(input) {
13303
+ return {
13304
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
13305
+ receipt_id: input.receipt_id,
13306
+ task_id: input.task_id,
13307
+ actor: input.actor,
13308
+ previous_holder: input.expected_holder,
13309
+ previous_lock_version: input.expected_lock_version,
13310
+ new_holder: input.new_holder,
13311
+ new_lock_version: input.operation_timestamp,
13312
+ stale_after_seconds: input.stale_after_seconds,
13313
+ stale_cutoff: input.stale_cutoff,
13314
+ reason: input.reason,
13315
+ created_at: input.operation_timestamp
13316
+ };
13317
+ }
13318
+ function staleLockHandoffHistory(receipt, machineId) {
13319
+ return {
13320
+ id: receipt.receipt_id,
13321
+ task_id: receipt.task_id,
13322
+ action: STALE_LOCK_HANDOFF_ACTION,
13323
+ field: STALE_LOCK_HANDOFF_FIELD,
13324
+ old_value: JSON.stringify({
13325
+ holder: receipt.previous_holder,
13326
+ lock_version: receipt.previous_lock_version
13327
+ }),
13328
+ new_value: JSON.stringify(receipt),
13329
+ agent_id: receipt.actor,
13330
+ created_at: receipt.created_at,
13331
+ machine_id: machineId
13332
+ };
13333
+ }
13334
+ function throwStaleLockHandoffConflict(task, input) {
13335
+ if (!task.locked_by || !task.locked_at) {
13336
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_LOCKED", `Task ${input.task_id} does not have a complete lock to hand off`, { task_id: input.task_id });
13337
+ }
13338
+ if (task.locked_at !== input.expected_lock_version) {
13339
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
13340
+ task_id: input.task_id,
13341
+ expected_lock_version: input.expected_lock_version,
13342
+ current_lock_version: task.locked_at
13343
+ });
13344
+ }
13345
+ if (task.locked_by !== input.expected_holder) {
13346
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
13347
+ task_id: input.task_id,
13348
+ expected_holder: input.expected_holder,
13349
+ current_holder: task.locked_by
13350
+ });
13351
+ }
13352
+ if (isTerminalStatus(task.status)) {
13353
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_TERMINAL", `Task ${input.task_id} is ${task.status} and cannot transfer a lock`, { task_id: input.task_id, status: task.status });
13354
+ }
13355
+ if (task.locked_at >= input.stale_cutoff) {
13356
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
13357
+ task_id: input.task_id,
13358
+ current_lock_version: task.locked_at,
13359
+ stale_cutoff: input.stale_cutoff
13360
+ });
13361
+ }
13362
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
13363
+ }
13364
+ var STALE_LOCK_HANDOFF_SCHEMA_VERSION = "todos.stale-lock-handoff.v1", STALE_LOCK_HANDOFF_ACTION = "stale_lock_handoff", STALE_LOCK_HANDOFF_FIELD = "lock", EXACT_TASK_UUID_RE, CANONICAL_LOCK_VERSION_RE, MAX_REASON_LENGTH = 4096;
13365
+ var init_stale_lock_handoff = __esm(() => {
13366
+ init_types();
13367
+ init_creator_identity();
13368
+ init_prewrite_secrets();
13369
+ EXACT_TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13370
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13371
+ });
13372
+
13184
13373
  // src/db/task-lifecycle.ts
13185
13374
  var exports_task_lifecycle = {};
13186
13375
  __export(exports_task_lifecycle, {
@@ -13189,6 +13378,7 @@ __export(exports_task_lifecycle, {
13189
13378
  startTask: () => startTask,
13190
13379
  spawnNextRecurrence: () => spawnNextRecurrence,
13191
13380
  lockTask: () => lockTask,
13381
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
13192
13382
  getTasksChangedSince: () => getTasksChangedSince,
13193
13383
  getTaskLockStatus: () => getTaskLockStatus,
13194
13384
  getStaleTasks: () => getStaleTasks,
@@ -13457,6 +13647,38 @@ function unlockTask(id, agentId, db) {
13457
13647
  WHERE id = ?`, [timestamp2, id]);
13458
13648
  return true;
13459
13649
  }
13650
+ function handoffStaleTaskLock(input, db) {
13651
+ const d = db || getDatabase();
13652
+ const prepared = prepareStaleLockHandoff(input);
13653
+ const receipt = buildStaleLockHandoffReceipt(prepared);
13654
+ const history = staleLockHandoffHistory(receipt, null);
13655
+ const transfer = d.transaction(() => {
13656
+ const result = d.run(`UPDATE tasks
13657
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
13658
+ WHERE id = ?
13659
+ AND locked_by = ?
13660
+ AND locked_at = ?
13661
+ AND julianday(locked_at) < julianday(?)
13662
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
13663
+ prepared.new_holder,
13664
+ prepared.operation_timestamp,
13665
+ prepared.operation_timestamp,
13666
+ prepared.task_id,
13667
+ prepared.expected_holder,
13668
+ prepared.expected_lock_version,
13669
+ prepared.stale_cutoff
13670
+ ]);
13671
+ if (result.changes === 0) {
13672
+ const current = getTask(prepared.task_id, d);
13673
+ if (!current)
13674
+ throw new TaskNotFoundError(prepared.task_id);
13675
+ throwStaleLockHandoffConflict(current, prepared);
13676
+ }
13677
+ insertTaskHistory(history, d);
13678
+ });
13679
+ transfer();
13680
+ return receipt;
13681
+ }
13460
13682
  function getTaskLockStatus(id, db) {
13461
13683
  const d = db || getDatabase();
13462
13684
  const task = getTask(id, d);
@@ -13757,6 +13979,7 @@ var init_task_lifecycle = __esm(() => {
13757
13979
  init_task_crud();
13758
13980
  init_task_graph();
13759
13981
  init_prewrite_secrets();
13982
+ init_stale_lock_handoff();
13760
13983
  });
13761
13984
 
13762
13985
  // src/db/task-crud.ts
@@ -15309,6 +15532,48 @@ function updatePlan(id, input, db) {
15309
15532
  return updatePlanStored(id, input, d);
15310
15533
  })();
15311
15534
  }
15535
+ function nextPlanCompletionTimestamp(expectedUpdatedAt) {
15536
+ const expected = Date.parse(expectedUpdatedAt);
15537
+ const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
15538
+ return new Date(Math.max(Date.now(), minimum)).toISOString();
15539
+ }
15540
+ function completePlanAtRevision(id, expectedUpdatedAt, db) {
15541
+ const d = db || getDatabase();
15542
+ return d.transaction(() => {
15543
+ guardPlanRowsSqlite([id], d);
15544
+ const plan = getPlan(id, d);
15545
+ if (!plan)
15546
+ throw new PlanNotFoundError(id);
15547
+ if (plan.updated_at !== expectedUpdatedAt) {
15548
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
15549
+ }
15550
+ if (plan.status === "completed")
15551
+ return { plan, applied: false };
15552
+ const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
15553
+ const result = d.run(`UPDATE plans
15554
+ SET status = 'completed', updated_at = ?
15555
+ WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
15556
+ if (result.changes !== 1) {
15557
+ const current = getPlan(id, d);
15558
+ if (!current)
15559
+ throw new PlanNotFoundError(id);
15560
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
15561
+ }
15562
+ const completed = getPlan(id, d);
15563
+ emitLocalEventHooksQuiet({
15564
+ type: "plan.updated",
15565
+ payload: {
15566
+ id,
15567
+ old_status: plan.status,
15568
+ new_status: completed.status,
15569
+ name: completed.name,
15570
+ project_id: completed.project_id
15571
+ },
15572
+ databasePath: databasePathFromDatabase(d)
15573
+ });
15574
+ return { plan: completed, applied: true };
15575
+ })();
15576
+ }
15312
15577
  function deletePlan(id, db) {
15313
15578
  const d = db || getDatabase();
15314
15579
  const plan = getPlan(id, d);
@@ -17287,6 +17552,7 @@ __export(exports_tasks, {
17287
17552
  insertTaskTags: () => insertTaskTags,
17288
17553
  importTaskBoardBundle: () => importTaskBoardBundle,
17289
17554
  importCalendarIcs: () => importCalendarIcs,
17555
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
17290
17556
  getTimeReport: () => getTimeReport,
17291
17557
  getTimeLogs: () => getTimeLogs,
17292
17558
  getTasksChangedSince: () => getTasksChangedSince,
@@ -20744,6 +21010,7 @@ var init_http_client = __esm(() => {
20744
21010
  // src/cli/cloud-router.ts
20745
21011
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
20746
21012
  import { normalizeStorageMode } from "@hasna/contracts/mode";
21013
+ import { randomUUID as randomUUID3 } from "crypto";
20747
21014
  import { resolve as resolvePath } from "path";
20748
21015
  function cleanMode(value) {
20749
21016
  const normalized = value?.trim().toLowerCase();
@@ -21146,7 +21413,10 @@ async function cloudGetTask(client, id) {
21146
21413
  }
21147
21414
  async function cloudCreateTask(client, input) {
21148
21415
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
21149
- const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
21416
+ const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.transport.post("/tasks", input, {
21417
+ idempotencyKey: randomUUID3(),
21418
+ retry: false
21419
+ }), ["PARENT_TASK_NOT_FOUND"]));
21150
21420
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
21151
21421
  throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} returned a task create ` + "response without a stored task id; no success row or local SQLite fallback is permitted");
21152
21422
  }
@@ -21157,7 +21427,7 @@ async function cloudCreateTask(client, input) {
21157
21427
  return persisted;
21158
21428
  }
21159
21429
  async function cloudUpdateTask(client, id, patch) {
21160
- return unwrapTask(await client.update("tasks", id, patch));
21430
+ return unwrapTask(await client.transport.patch(`/tasks/${encodeURIComponent(id)}`, patch));
21161
21431
  }
21162
21432
  async function cloudDeleteTask(client, id) {
21163
21433
  try {
@@ -21368,7 +21638,7 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
21368
21638
  return input.toLowerCase();
21369
21639
  return (await cloudResolveTaskList(client, ref, projectId)).id;
21370
21640
  }
21371
- var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21641
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21372
21642
  var init_cloud_router = __esm(() => {
21373
21643
  init_types();
21374
21644
  init_redaction();
@@ -21385,6 +21655,7 @@ var init_cloud_router = __esm(() => {
21385
21655
  hybrid: "http"
21386
21656
  };
21387
21657
  completionCapabilityCache = new Map;
21658
+ retryCapabilityCache = new Map;
21388
21659
  gitRefCapabilityCache = new Map;
21389
21660
  SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
21390
21661
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -35481,7 +35752,7 @@ var package_default;
35481
35752
  var init_package = __esm(() => {
35482
35753
  package_default = {
35483
35754
  name: "@hasna/todos",
35484
- version: "0.15.18",
35755
+ version: "0.15.20",
35485
35756
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35486
35757
  type: "module",
35487
35758
  main: "dist/index.js",
@@ -35552,6 +35823,8 @@ var init_package = __esm(() => {
35552
35823
  "dev:mcp": "bun run src/mcp/index.ts",
35553
35824
  "dev:serve": "bun run src/server/index.ts",
35554
35825
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
35826
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
35827
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
35555
35828
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
35556
35829
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
35557
35830
  },
@@ -45042,6 +45315,58 @@ var init_plan_project_links = __esm(() => {
45042
45315
  init_tasks();
45043
45316
  });
45044
45317
 
45318
+ // src/storage/audit-history-import.ts
45319
+ function auditHistoryRowsAreFieldIdentical(left, right) {
45320
+ return AUDIT_HISTORY_FIELDS.every((field) => {
45321
+ const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
45322
+ const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
45323
+ return leftValue === rightValue;
45324
+ });
45325
+ }
45326
+ function divergentAuditHistoryReplayError(id) {
45327
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
45328
+ }
45329
+ function forbiddenAuditHistoryTombstoneError(id) {
45330
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
45331
+ }
45332
+ function parseAuditHistoryImportFailure(message) {
45333
+ const divergentPrefix = `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row `;
45334
+ const divergentSuffix = " differs from stored row";
45335
+ if (message.startsWith(divergentPrefix) && message.endsWith(divergentSuffix)) {
45336
+ return {
45337
+ code: AUDIT_HISTORY_DIVERGENT_REPLAY,
45338
+ auditHistoryId: message.slice(divergentPrefix.length, -divergentSuffix.length),
45339
+ conflict: true,
45340
+ status: 409
45341
+ };
45342
+ }
45343
+ const tombstonePrefix = `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone `;
45344
+ const tombstoneSuffix = " is not allowed";
45345
+ if (message.startsWith(tombstonePrefix) && message.endsWith(tombstoneSuffix)) {
45346
+ return {
45347
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
45348
+ auditHistoryId: message.slice(tombstonePrefix.length, -tombstoneSuffix.length),
45349
+ conflict: false,
45350
+ status: 400
45351
+ };
45352
+ }
45353
+ return null;
45354
+ }
45355
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY", AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN", AUDIT_HISTORY_FIELDS;
45356
+ var init_audit_history_import = __esm(() => {
45357
+ AUDIT_HISTORY_FIELDS = [
45358
+ "id",
45359
+ "task_id",
45360
+ "action",
45361
+ "field",
45362
+ "old_value",
45363
+ "new_value",
45364
+ "agent_id",
45365
+ "created_at",
45366
+ "machine_id"
45367
+ ];
45368
+ });
45369
+
45045
45370
  // src/storage/sqlite-snapshot.ts
45046
45371
  function exportSqliteTodosStorageSnapshot(db) {
45047
45372
  const d = db ?? getDatabase();
@@ -45075,9 +45400,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45075
45400
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
45076
45401
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
45077
45402
  }
45403
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
45404
+ result.errors.push(...auditImport.errors);
45078
45405
  if (result.errors.length > 0)
45079
45406
  return result;
45080
- const applyRows = (objectType2, table, columns, rows, updateClockColumn, afterUpsert) => {
45407
+ result.skipped += auditImport.identicalReplayCount;
45408
+ const applyRows = (objectType2, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
45081
45409
  for (const row of rows) {
45082
45410
  try {
45083
45411
  const record = asRecord2(row);
@@ -45086,7 +45414,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45086
45414
  result.skipped += 1;
45087
45415
  continue;
45088
45416
  }
45089
- const state = upsertById(d, table, columns, record, updateClockColumn);
45417
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
45090
45418
  if (state === "inserted")
45091
45419
  result.inserted += 1;
45092
45420
  else if (state === "updated")
@@ -45103,19 +45431,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45103
45431
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
45104
45432
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
45105
45433
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
45106
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
45434
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
45107
45435
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
45108
45436
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
45109
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", (row, changed) => {
45437
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", true, (row, changed) => {
45110
45438
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
45111
45439
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
45112
45440
  }
45113
45441
  });
45114
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
45442
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
45115
45443
  applyTombstones(d, snapshot.tombstones ?? [], result);
45116
45444
  return result;
45117
45445
  }
45118
- function upsertById(db, table, columns, row, updateClockColumn) {
45446
+ function preflightAuditHistoryImport(db, rows, tombstones) {
45447
+ const errors2 = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
45448
+ const rowsToInsert = [];
45449
+ const seen = new Map;
45450
+ let identicalReplayCount = 0;
45451
+ for (const rawRow of rows) {
45452
+ try {
45453
+ const row = asRecord2(rawRow);
45454
+ if (typeof row.id !== "string" || !row.id) {
45455
+ throw new Error("task_history row is missing id");
45456
+ }
45457
+ const prior = seen.get(row.id);
45458
+ if (prior) {
45459
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
45460
+ identicalReplayCount += 1;
45461
+ else
45462
+ errors2.push(divergentAuditHistoryReplayError(row.id));
45463
+ continue;
45464
+ }
45465
+ seen.set(row.id, row);
45466
+ const existing = getAuditHistoryById(db, row.id);
45467
+ if (!existing) {
45468
+ rowsToInsert.push(row);
45469
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
45470
+ identicalReplayCount += 1;
45471
+ } else {
45472
+ errors2.push(divergentAuditHistoryReplayError(row.id));
45473
+ }
45474
+ } catch (error) {
45475
+ errors2.push(error instanceof Error ? error.message : String(error));
45476
+ }
45477
+ }
45478
+ return { rowsToInsert, identicalReplayCount, errors: errors2 };
45479
+ }
45480
+ function insertAuditHistoryRows(db, rows, result) {
45481
+ for (const rawRow of rows) {
45482
+ try {
45483
+ const row = asRecord2(rawRow);
45484
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
45485
+ if (!presentColumns.includes("id"))
45486
+ presentColumns.unshift("id");
45487
+ const placeholders2 = presentColumns.map(() => "?").join(", ");
45488
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
45489
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders2})`, values).changes;
45490
+ if (changes > 0) {
45491
+ result.inserted += 1;
45492
+ continue;
45493
+ }
45494
+ const existing = getAuditHistoryById(db, String(row["id"]));
45495
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
45496
+ result.skipped += 1;
45497
+ } else {
45498
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
45499
+ }
45500
+ } catch (error) {
45501
+ result.errors.push(error instanceof Error ? error.message : String(error));
45502
+ }
45503
+ }
45504
+ }
45505
+ function getAuditHistoryById(db, id) {
45506
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
45507
+ }
45508
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
45119
45509
  const id = row["id"];
45120
45510
  if (typeof id !== "string" || !id)
45121
45511
  throw new Error(`${table} row is missing id`);
@@ -45127,7 +45517,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
45127
45517
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
45128
45518
  const updateColumns = presentColumns.filter((column) => column !== "id");
45129
45519
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
45130
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
45520
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
45131
45521
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})
45132
45522
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})`;
45133
45523
  const changes = db.run(sql, values).changes;
@@ -45212,7 +45602,7 @@ function tableForTombstone(objectType2) {
45212
45602
  return "task_templates";
45213
45603
  if (objectType2 === "template_tasks")
45214
45604
  return "template_tasks";
45215
- return "task_history";
45605
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType2)}`);
45216
45606
  }
45217
45607
  function listRows(db, table, columns) {
45218
45608
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -45259,6 +45649,7 @@ var init_sqlite_snapshot = __esm(() => {
45259
45649
  init_tasks();
45260
45650
  init_templates();
45261
45651
  init_storage_tombstones();
45652
+ init_audit_history_import();
45262
45653
  PROJECT_COLUMNS = [
45263
45654
  "id",
45264
45655
  "name",
@@ -45523,6 +45914,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45523
45914
  unlockTask(id, agentId, database());
45524
45915
  return true;
45525
45916
  },
45917
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
45526
45918
  delete: (id) => deleteTask(id, database()),
45527
45919
  start: (id, agentId) => startTask(id, agentId, database()),
45528
45920
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -45546,6 +45938,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45546
45938
  get: (id) => getPlan(id, database()),
45547
45939
  list: (projectId) => listPlans(projectId, database()),
45548
45940
  update: (id, input) => updatePlan(id, input, database()),
45941
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
45549
45942
  delete: (id) => deletePlan(id, database())
45550
45943
  },
45551
45944
  planProjectLinks: {
@@ -46441,7 +46834,7 @@ var init_api_keys = __esm(() => {
46441
46834
  });
46442
46835
 
46443
46836
  // src/storage/postgres-adapter.ts
46444
- import { randomUUID as randomUUID3 } from "crypto";
46837
+ import { randomUUID as randomUUID4 } from "crypto";
46445
46838
  function createPostgresTodosStorageAdapter(options) {
46446
46839
  const store = new PostgresJsonRecordStore(options);
46447
46840
  const adapter = {
@@ -46470,6 +46863,7 @@ function createPostgresTodosStorageAdapter(options) {
46470
46863
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
46471
46864
  lock: (id, agentId) => lockTask2(id, agentId, store),
46472
46865
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
46866
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
46473
46867
  getByFingerprint: (fingerprint3) => store.getTaskByFingerprint(fingerprint3)
46474
46868
  },
46475
46869
  dependencies: {
@@ -46506,6 +46900,7 @@ function createPostgresTodosStorageAdapter(options) {
46506
46900
  get: (id) => store.get("plans", id),
46507
46901
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
46508
46902
  update: (id, input) => updatePlan2(id, input, store),
46903
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
46509
46904
  delete: (id, context) => store.deletePlan(id, context)
46510
46905
  },
46511
46906
  planProjectLinks: {
@@ -46615,6 +47010,91 @@ class PostgresJsonRecordStore {
46615
47010
  LIMIT 1`, [this.service, type, id]);
46616
47011
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
46617
47012
  }
47013
+ async handoffStaleLock(input, context = {}) {
47014
+ const prepared = prepareStaleLockHandoff(input);
47015
+ const receipt = buildStaleLockHandoffReceipt(prepared);
47016
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
47017
+ await this.ensureSchema();
47018
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
47019
+ target AS MATERIALIZED (
47020
+ SELECT payload
47021
+ FROM ${this.tableName}
47022
+ WHERE service = $1
47023
+ AND object_type = 'tasks'
47024
+ AND object_id = $2
47025
+ AND deleted_at IS NULL
47026
+ FOR UPDATE
47027
+ ),
47028
+ updated AS (
47029
+ UPDATE ${this.tableName} AS task_record
47030
+ SET payload = jsonb_set(
47031
+ jsonb_set(
47032
+ jsonb_set(
47033
+ jsonb_set(
47034
+ task_record.payload,
47035
+ '{locked_by}',
47036
+ to_jsonb($6::text),
47037
+ true
47038
+ ),
47039
+ '{locked_at}',
47040
+ to_jsonb($7::text),
47041
+ true
47042
+ ),
47043
+ '{updated_at}',
47044
+ to_jsonb($7::text),
47045
+ true
47046
+ ),
47047
+ '{version}',
47048
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
47049
+ true
47050
+ ),
47051
+ updated_at = $7::timestamptz,
47052
+ source_machine_id = $10,
47053
+ version = COALESCE(task_record.version, 0) + 1
47054
+ FROM target
47055
+ WHERE task_record.service = $1
47056
+ AND task_record.object_type = 'tasks'
47057
+ AND task_record.object_id = $2
47058
+ AND task_record.deleted_at IS NULL
47059
+ AND target.payload->>'locked_by' = $3
47060
+ AND target.payload->>'locked_at' = $4
47061
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
47062
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
47063
+ RETURNING task_record.payload
47064
+ ),
47065
+ audit AS (
47066
+ INSERT INTO ${this.tableName} (
47067
+ service, object_type, object_id, payload, updated_at,
47068
+ deleted_at, source_machine_id, version
47069
+ )
47070
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
47071
+ NULL, $10, NULL
47072
+ FROM updated
47073
+ RETURNING payload
47074
+ )
47075
+ SELECT
47076
+ (SELECT payload FROM target) AS current_payload,
47077
+ (SELECT payload FROM updated) AS updated_payload,
47078
+ (SELECT payload FROM audit) AS audit_payload`, [
47079
+ this.service,
47080
+ prepared.task_id,
47081
+ prepared.expected_holder,
47082
+ prepared.expected_lock_version,
47083
+ prepared.stale_cutoff,
47084
+ prepared.new_holder,
47085
+ prepared.operation_timestamp,
47086
+ receipt.receipt_id,
47087
+ jsonbParam(history),
47088
+ this.machineId(context)
47089
+ ]);
47090
+ const row = result.rows[0];
47091
+ if (!row?.current_payload)
47092
+ throw new TaskNotFoundError(prepared.task_id);
47093
+ if (!row.updated_payload || !row.audit_payload) {
47094
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
47095
+ }
47096
+ return receipt;
47097
+ }
46618
47098
  async list(type) {
46619
47099
  return (await this.listRecords(type)).map((record) => record.payload);
46620
47100
  }
@@ -46884,6 +47364,28 @@ class PostgresJsonRecordStore {
46884
47364
  }
46885
47365
  return value;
46886
47366
  }
47367
+ async insertImmutableAuditHistory(value, context = {}) {
47368
+ await this.ensureSchema();
47369
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
47370
+ service, object_type, object_id, payload, updated_at,
47371
+ deleted_at, source_machine_id, version
47372
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
47373
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
47374
+ RETURNING object_id`, [
47375
+ this.service,
47376
+ "audit_history",
47377
+ value.id,
47378
+ jsonbParam(value),
47379
+ value.created_at,
47380
+ context.requestId ?? this.sourceMachineId ?? null
47381
+ ]);
47382
+ if (inserted.rows.length > 0)
47383
+ return "inserted";
47384
+ const existing = await this.get("audit_history", value.id);
47385
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
47386
+ return "identical";
47387
+ throw new Error(divergentAuditHistoryReplayError(value.id));
47388
+ }
46887
47389
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
46888
47390
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
46889
47391
  if (planIds.length === 0)
@@ -46993,6 +47495,54 @@ class PostgresJsonRecordStore {
46993
47495
  throw new PlanNotFoundError(value.id);
46994
47496
  return payloadRecord2(row.payload);
46995
47497
  }
47498
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
47499
+ await this.ensureSchema();
47500
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
47501
+ SELECT date_trunc(
47502
+ 'milliseconds',
47503
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
47504
+ ) AS completed_at
47505
+ ), stored AS (
47506
+ UPDATE ${this.tableName} AS record SET
47507
+ payload = record.payload || jsonb_build_object(
47508
+ 'status', 'completed',
47509
+ 'updated_at', to_char(
47510
+ next_clock.completed_at AT TIME ZONE 'UTC',
47511
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
47512
+ )
47513
+ ),
47514
+ updated_at = next_clock.completed_at,
47515
+ deleted_at = NULL,
47516
+ source_machine_id = COALESCE($4, record.source_machine_id),
47517
+ version = COALESCE(record.version, 0) + 1
47518
+ FROM next_clock
47519
+ WHERE record.service = $1
47520
+ AND record.object_type = 'plans'
47521
+ AND record.object_id = $2
47522
+ AND record.deleted_at IS NULL
47523
+ AND record.payload->>'updated_at' = $3::text
47524
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
47525
+ RETURNING record.payload
47526
+ )
47527
+ SELECT payload FROM stored`, [
47528
+ this.service,
47529
+ id,
47530
+ expectedUpdatedAt,
47531
+ context.requestId ?? this.sourceMachineId ?? null
47532
+ ]);
47533
+ const payload = result.rows[0]?.payload;
47534
+ if (payload)
47535
+ return { plan: payloadRecord2(payload), applied: true };
47536
+ const current = await this.get("plans", id);
47537
+ if (!current)
47538
+ throw new PlanNotFoundError(id);
47539
+ if (current.updated_at !== expectedUpdatedAt) {
47540
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
47541
+ }
47542
+ if (current.status === "completed")
47543
+ return { plan: current, applied: false };
47544
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
47545
+ }
46996
47546
  async createTemplateWithTasks(template, tasks, context = {}) {
46997
47547
  await this.ensureSchema();
46998
47548
  const records = [
@@ -47662,7 +48212,7 @@ async function createTask3(input, store, context) {
47662
48212
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
47663
48213
  const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
47664
48214
  const task2 = {
47665
- id: randomUUID3(),
48215
+ id: randomUUID4(),
47666
48216
  short_id: shortId,
47667
48217
  project_id: effectiveProjectId,
47668
48218
  parent_id: input.parent_id ?? null,
@@ -47908,7 +48458,7 @@ async function addVerification(input, store, context) {
47908
48458
  throw new Error(`Task not found: ${input.task_id}`);
47909
48459
  const timestamp4 = new Date().toISOString();
47910
48460
  const verification = {
47911
- id: randomUUID3(),
48461
+ id: randomUUID4(),
47912
48462
  task_id: input.task_id,
47913
48463
  command: input.command,
47914
48464
  status: input.status ?? "unknown",
@@ -47929,7 +48479,7 @@ async function addCommit(input, store, context) {
47929
48479
  throw new Error(`Task not found: ${input.task_id}`);
47930
48480
  const timestamp4 = new Date().toISOString();
47931
48481
  const commit = {
47932
- id: randomUUID3(),
48482
+ id: randomUUID4(),
47933
48483
  task_id: input.task_id,
47934
48484
  sha: input.sha,
47935
48485
  message: input.message ?? null,
@@ -47952,7 +48502,7 @@ async function addGitRef(input, store, context) {
47952
48502
  throw new Error(`Task not found: ${input.task_id}`);
47953
48503
  const timestamp4 = new Date().toISOString();
47954
48504
  const gitRef = {
47955
- id: randomUUID3(),
48505
+ id: randomUUID4(),
47956
48506
  task_id: input.task_id,
47957
48507
  ref_type: input.ref_type,
47958
48508
  name: input.name,
@@ -48022,7 +48572,7 @@ async function createProject2(input, store, context) {
48022
48572
  if (!derivedSlug || !taskListId)
48023
48573
  throw new Error("Project name and task-list slug must be non-empty");
48024
48574
  const project = {
48025
- id: randomUUID3(),
48575
+ id: randomUUID4(),
48026
48576
  name: input.name,
48027
48577
  path: input.path,
48028
48578
  description: input.description ?? null,
@@ -48054,7 +48604,7 @@ async function createPlan2(input, store, context) {
48054
48604
  store
48055
48605
  });
48056
48606
  return store.upsert("plans", {
48057
- id: randomUUID3(),
48607
+ id: randomUUID4(),
48058
48608
  slug,
48059
48609
  project_id: projectId,
48060
48610
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -48106,7 +48656,7 @@ async function registerAgent2(input, store, context) {
48106
48656
  }
48107
48657
  const timestamp4 = new Date().toISOString();
48108
48658
  const agent = {
48109
- id: existing?.id ?? randomUUID3().slice(0, 8),
48659
+ id: existing?.id ?? randomUUID4().slice(0, 8),
48110
48660
  name: canonicalName,
48111
48661
  description: input.description ?? existing?.description ?? null,
48112
48662
  role: input.role ?? existing?.role ?? null,
@@ -48179,7 +48729,7 @@ async function createTaskList2(input, store, context) {
48179
48729
  if (!slug)
48180
48730
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
48181
48731
  return store.upsert("task_lists", {
48182
- id: randomUUID3(),
48732
+ id: randomUUID4(),
48183
48733
  project_id: input.project_id ?? context?.projectId ?? null,
48184
48734
  slug,
48185
48735
  name: input.name,
@@ -48214,7 +48764,7 @@ async function updateTaskList2(id, input, store) {
48214
48764
  async function createTemplate2(input, store, context) {
48215
48765
  const timestamp4 = new Date().toISOString();
48216
48766
  const template = {
48217
- id: randomUUID3(),
48767
+ id: randomUUID4(),
48218
48768
  name: input.name,
48219
48769
  title_pattern: input.title_pattern,
48220
48770
  description: input.description ?? null,
@@ -48235,7 +48785,7 @@ async function createTemplate2(input, store, context) {
48235
48785
  }
48236
48786
  function buildTemplateTasks(templateId, inputs, timestamp4) {
48237
48787
  return inputs.map((input, position) => ({
48238
- id: randomUUID3(),
48788
+ id: randomUUID4(),
48239
48789
  template_id: templateId,
48240
48790
  position,
48241
48791
  title_pattern: input.title_pattern,
@@ -48268,7 +48818,7 @@ async function updateTemplate2(id, input, store) {
48268
48818
  }
48269
48819
  async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
48270
48820
  const entry2 = {
48271
- id: randomUUID3(),
48821
+ id: randomUUID4(),
48272
48822
  task_id: taskId,
48273
48823
  action,
48274
48824
  field: field ?? null,
@@ -48282,7 +48832,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
48282
48832
  }
48283
48833
  async function addComment2(input, store, context) {
48284
48834
  const comment = {
48285
- id: randomUUID3(),
48835
+ id: randomUUID4(),
48286
48836
  task_id: input.task_id,
48287
48837
  agent_id: input.agent_id ?? context?.agentId ?? null,
48288
48838
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -48324,6 +48874,11 @@ async function importSnapshot(snapshot, store, context) {
48324
48874
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
48325
48875
  if (result.errors.length > 0)
48326
48876
  return result;
48877
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
48878
+ result.errors.push(...auditHistory.errors);
48879
+ if (result.errors.length > 0)
48880
+ return result;
48881
+ result.skipped += auditHistory.identical;
48327
48882
  const entries = [
48328
48883
  ...snapshot.tasks.map((row) => ["tasks", row]),
48329
48884
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -48332,9 +48887,20 @@ async function importSnapshot(snapshot, store, context) {
48332
48887
  ...snapshot.agents.map((row) => ["agents", row]),
48333
48888
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
48334
48889
  ...snapshot.templates.map((row) => ["templates", row]),
48335
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
48336
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
48890
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
48337
48891
  ];
48892
+ for (const row of auditHistory.rowsToInsert) {
48893
+ try {
48894
+ const outcome = await store.insertImmutableAuditHistory(row, context);
48895
+ if (outcome === "inserted")
48896
+ result.inserted += 1;
48897
+ else
48898
+ result.skipped += 1;
48899
+ } catch (error) {
48900
+ result.errors.push(error instanceof Error ? error.message : String(error));
48901
+ return result;
48902
+ }
48903
+ }
48338
48904
  for (const [type, row] of entries) {
48339
48905
  try {
48340
48906
  const existing = await store.get(type, row.id);
@@ -48368,6 +48934,32 @@ async function importSnapshot(snapshot, store, context) {
48368
48934
  }
48369
48935
  return result;
48370
48936
  }
48937
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
48938
+ const errors2 = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
48939
+ const rowsToInsert = [];
48940
+ const seen = new Map;
48941
+ let identical = 0;
48942
+ for (const row of rows) {
48943
+ const prior = seen.get(row.id);
48944
+ if (prior) {
48945
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
48946
+ identical += 1;
48947
+ else
48948
+ errors2.push(divergentAuditHistoryReplayError(row.id));
48949
+ continue;
48950
+ }
48951
+ seen.set(row.id, row);
48952
+ const existing = await store.get("audit_history", row.id);
48953
+ if (!existing) {
48954
+ rowsToInsert.push(row);
48955
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
48956
+ identical += 1;
48957
+ } else {
48958
+ errors2.push(divergentAuditHistoryReplayError(row.id));
48959
+ }
48960
+ }
48961
+ return { rowsToInsert, identical, errors: errors2 };
48962
+ }
48371
48963
  async function requireRecord(type, id, store) {
48372
48964
  const record = await store.get(type, id);
48373
48965
  if (!record)
@@ -48474,9 +49066,11 @@ var init_postgres_adapter = __esm(() => {
48474
49066
  init_types();
48475
49067
  init_creator_identity();
48476
49068
  init_plan_project_link_contract();
49069
+ init_stale_lock_handoff();
48477
49070
  init_postgres_sync();
48478
49071
  init_integrity();
48479
49072
  init_redaction();
49073
+ init_audit_history_import();
48480
49074
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
48481
49075
  });
48482
49076
 
@@ -53821,6 +54415,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
53821
54415
  ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
53822
54416
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
53823
54417
  TaskComment: taskCommentSchema,
54418
+ StaleLockHandoffInput: staleLockHandoffInputSchema,
54419
+ StaleLockHandoffReceipt: staleLockHandoffReceiptSchema,
53824
54420
  TaskGitRef: taskGitRefSchema,
53825
54421
  Plan: planSchema,
53826
54422
  PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
@@ -55028,6 +55624,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55028
55624
  }
55029
55625
  }
55030
55626
  },
55627
+ "/v1/tasks/{id}/stale-lock-handoff": {
55628
+ post: {
55629
+ operationId: "handoffStaleTaskLock",
55630
+ summary: "Atomically transfer one exact stale task lock",
55631
+ description: "Compares one full task UUID, current holder, and exact locked_at version, verifies the lock is strictly older than the supplied threshold, then transfers it directly and writes an immutable task-history receipt in the same backend transaction.",
55632
+ parameters: [
55633
+ {
55634
+ name: "id",
55635
+ in: "path",
55636
+ required: true,
55637
+ schema: { type: "string", format: "uuid" },
55638
+ description: "Exact full task UUID. Short ids and prefixes are rejected."
55639
+ }
55640
+ ],
55641
+ requestBody: {
55642
+ required: true,
55643
+ content: {
55644
+ "application/json": {
55645
+ schema: { $ref: "#/components/schemas/StaleLockHandoffInput" }
55646
+ }
55647
+ }
55648
+ },
55649
+ responses: {
55650
+ "200": {
55651
+ content: {
55652
+ "application/json": {
55653
+ schema: {
55654
+ type: "object",
55655
+ additionalProperties: false,
55656
+ required: ["receipt"],
55657
+ properties: {
55658
+ receipt: { $ref: "#/components/schemas/StaleLockHandoffReceipt" }
55659
+ }
55660
+ }
55661
+ }
55662
+ }
55663
+ },
55664
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55665
+ "403": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55666
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55667
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
55668
+ "501": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
55669
+ }
55670
+ }
55671
+ },
55031
55672
  "/v1/tasks/{id}/refs": {
55032
55673
  get: {
55033
55674
  operationId: "listTaskGitRefs",
@@ -55534,8 +56175,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55534
56175
  "/v1/import": {
55535
56176
  post: {
55536
56177
  operationId: "importSnapshot",
55537
- summary: "Bulk-ingest a full or partial snapshot (idempotent upsert by id)",
55538
- description: "Upserts every record carried in the body by primary key. All record arrays are optional and default to []; a caller may backfill a single object type (e.g. just tasks) or a complete snapshot. Re-posting the same rows never duplicates. Requires the todos:write scope.",
56178
+ summary: "Bulk-ingest a snapshot or atomically complete one observed plan",
56179
+ description: "Upserts every snapshot record by primary key, or accepts exactly one planCompletions operation that changes only plan status under an expected_updated_at CAS. Snapshot records and planCompletions are mutually exclusive. Requires the todos:write scope.",
55539
56180
  requestBody: {
55540
56181
  required: true,
55541
56182
  content: {
@@ -55554,7 +56195,22 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55554
56195
  templates: { type: "array", items: { type: "object" } },
55555
56196
  templateTasks: { type: "array", items: { $ref: "#/components/schemas/TemplateTask" } },
55556
56197
  auditHistory: { type: "array", items: { type: "object" } },
55557
- tombstones: { type: "array", items: { type: "object" } }
56198
+ tombstones: { type: "array", items: { type: "object" } },
56199
+ planCompletions: {
56200
+ type: "array",
56201
+ minItems: 1,
56202
+ maxItems: 1,
56203
+ items: {
56204
+ type: "object",
56205
+ additionalProperties: false,
56206
+ required: ["id", "expected_updated_at", "status"],
56207
+ properties: {
56208
+ id: { type: "string" },
56209
+ expected_updated_at: { type: "string", format: "date-time" },
56210
+ status: { type: "string", enum: ["completed"] }
56211
+ }
56212
+ }
56213
+ }
55558
56214
  }
55559
56215
  }
55560
56216
  }
@@ -55577,6 +56233,26 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55577
56233
  skipped: { type: "number" },
55578
56234
  errors: { type: "array", items: { type: "string" } }
55579
56235
  }
56236
+ },
56237
+ planCompletions: {
56238
+ type: "array",
56239
+ items: {
56240
+ type: "object",
56241
+ required: [
56242
+ "id",
56243
+ "status",
56244
+ "expected_updated_at",
56245
+ "result_updated_at",
56246
+ "applied"
56247
+ ],
56248
+ properties: {
56249
+ id: { type: "string" },
56250
+ status: { type: "string", enum: ["completed"] },
56251
+ expected_updated_at: { type: "string", format: "date-time" },
56252
+ result_updated_at: { type: "string", format: "date-time" },
56253
+ applied: { type: "boolean" }
56254
+ }
56255
+ }
55580
56256
  }
55581
56257
  }
55582
56258
  }
@@ -55589,7 +56265,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55589
56265
  }
55590
56266
  };
55591
56267
  }
55592
- var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
56268
+ var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, staleLockHandoffInputSchema, staleLockHandoffReceiptSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
55593
56269
  var init_openapi = __esm(() => {
55594
56270
  init_package_version();
55595
56271
  init_types();
@@ -55608,6 +56284,8 @@ var init_openapi = __esm(() => {
55608
56284
  reason: { type: "string", nullable: true },
55609
56285
  tags: { type: "array", items: { type: "string" } },
55610
56286
  version: { type: "number" },
56287
+ locked_by: { type: "string", nullable: true },
56288
+ locked_at: { type: "string", format: "date-time", nullable: true },
55611
56289
  created_at: { type: "string" },
55612
56290
  updated_at: { type: "string" }
55613
56291
  }
@@ -55772,6 +56450,69 @@ var init_openapi = __esm(() => {
55772
56450
  created_at: { type: "string", format: "date-time" }
55773
56451
  }
55774
56452
  };
56453
+ staleLockHandoffInputSchema = {
56454
+ type: "object",
56455
+ additionalProperties: false,
56456
+ required: [
56457
+ "expected_holder",
56458
+ "expected_lock_version",
56459
+ "stale_after_seconds",
56460
+ "new_holder",
56461
+ "reason"
56462
+ ],
56463
+ properties: {
56464
+ expected_holder: { type: "string", minLength: 1 },
56465
+ expected_lock_version: {
56466
+ type: "string",
56467
+ format: "date-time",
56468
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
56469
+ description: "Exact authoritative locked_at token read from the task; no default or normalization is applied."
56470
+ },
56471
+ stale_after_seconds: {
56472
+ type: "integer",
56473
+ minimum: 1,
56474
+ description: "Lock age threshold supplied by the caller. The lock must be strictly older at the CAS instant."
56475
+ },
56476
+ new_holder: {
56477
+ type: "string",
56478
+ minLength: 1,
56479
+ description: "Must match the agent bound to the authenticated API key."
56480
+ },
56481
+ reason: { type: "string", minLength: 1, maxLength: 4096 }
56482
+ }
56483
+ };
56484
+ staleLockHandoffReceiptSchema = {
56485
+ type: "object",
56486
+ additionalProperties: false,
56487
+ required: [
56488
+ "schema_version",
56489
+ "receipt_id",
56490
+ "task_id",
56491
+ "actor",
56492
+ "previous_holder",
56493
+ "previous_lock_version",
56494
+ "new_holder",
56495
+ "new_lock_version",
56496
+ "stale_after_seconds",
56497
+ "stale_cutoff",
56498
+ "reason",
56499
+ "created_at"
56500
+ ],
56501
+ properties: {
56502
+ schema_version: { type: "string", enum: ["todos.stale-lock-handoff.v1"] },
56503
+ receipt_id: { type: "string", format: "uuid" },
56504
+ task_id: { type: "string", format: "uuid" },
56505
+ actor: { type: "string" },
56506
+ previous_holder: { type: "string" },
56507
+ previous_lock_version: { type: "string", format: "date-time" },
56508
+ new_holder: { type: "string" },
56509
+ new_lock_version: { type: "string", format: "date-time" },
56510
+ stale_after_seconds: { type: "integer", minimum: 1 },
56511
+ stale_cutoff: { type: "string", format: "date-time" },
56512
+ reason: { type: "string" },
56513
+ created_at: { type: "string", format: "date-time" }
56514
+ }
56515
+ };
55775
56516
  taskGitRefSchema = {
55776
56517
  type: "object",
55777
56518
  additionalProperties: false,
@@ -56773,6 +57514,65 @@ function normalizeImportSnapshot(raw) {
56773
57514
  function countSnapshotRecords(s) {
56774
57515
  return s.tasks.length + s.projects.length + (s.projectMachinePaths?.length ?? 0) + s.plans.length + s.agents.length + s.taskLists.length + s.templates.length + s.templateTasks.length + s.auditHistory.length + (s.tombstones?.length ?? 0);
56775
57516
  }
57517
+ function validatePlanCompletionImports(raw) {
57518
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
57519
+ return { present: false, operations: [] };
57520
+ }
57521
+ const body2 = raw;
57522
+ if (!Object.prototype.hasOwnProperty.call(body2, "planCompletions")) {
57523
+ return { present: false, operations: [] };
57524
+ }
57525
+ if (!Array.isArray(body2["planCompletions"]) || body2["planCompletions"].length !== 1) {
57526
+ return {
57527
+ present: true,
57528
+ operations: [],
57529
+ error: "planCompletions must contain exactly one completion operation"
57530
+ };
57531
+ }
57532
+ const operation = body2["planCompletions"][0];
57533
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
57534
+ return { present: true, operations: [], error: "plan completion must be an object" };
57535
+ }
57536
+ const record = operation;
57537
+ const allowed = new Set(["id", "expected_updated_at", "status"]);
57538
+ const unknown = Object.keys(record).find((key2) => !allowed.has(key2));
57539
+ if (unknown) {
57540
+ return { present: true, operations: [], error: `unknown plan completion field: ${unknown}` };
57541
+ }
57542
+ if (typeof record["id"] !== "string" || !record["id"].trim()) {
57543
+ return { present: true, operations: [], error: "plan completion id must be a non-empty string" };
57544
+ }
57545
+ if (record["status"] !== "completed") {
57546
+ return { present: true, operations: [], error: "plan completion status must be completed" };
57547
+ }
57548
+ const expectedUpdatedAt = typeof record["expected_updated_at"] === "string" ? record["expected_updated_at"] : "";
57549
+ const timestampMatch = RFC3339_DATE_TIME.exec(expectedUpdatedAt);
57550
+ const parsedTimestamp = Date.parse(expectedUpdatedAt);
57551
+ if (!timestampMatch || Number.isNaN(parsedTimestamp)) {
57552
+ return {
57553
+ present: true,
57554
+ operations: [],
57555
+ error: "plan completion expected_updated_at must be an RFC 3339 date-time with an explicit offset"
57556
+ };
57557
+ }
57558
+ const [, year, month, day] = timestampMatch;
57559
+ const calendarProbe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
57560
+ if (calendarProbe.getUTCFullYear() !== Number(year) || calendarProbe.getUTCMonth() !== Number(month) - 1 || calendarProbe.getUTCDate() !== Number(day)) {
57561
+ return {
57562
+ present: true,
57563
+ operations: [],
57564
+ error: "plan completion expected_updated_at names a date that does not exist"
57565
+ };
57566
+ }
57567
+ return {
57568
+ present: true,
57569
+ operations: [{
57570
+ id: record["id"],
57571
+ expected_updated_at: expectedUpdatedAt,
57572
+ status: "completed"
57573
+ }]
57574
+ };
57575
+ }
56776
57576
  async function handleV1Request(req, url, dependencies = {}) {
56777
57577
  const path = url.pathname;
56778
57578
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -56954,6 +57754,45 @@ async function handleV1Request(req, url, dependencies = {}) {
56954
57754
  return error(405, `method ${method} not allowed on /v1/tasks`);
56955
57755
  }
56956
57756
  if (action) {
57757
+ if (action === "stale-lock-handoff") {
57758
+ if (method !== "POST") {
57759
+ return error(405, "method must be POST on /v1/tasks/:id/stale-lock-handoff");
57760
+ }
57761
+ const exactId = normalizeExactTaskId(id);
57762
+ if (!principal.agent) {
57763
+ return error(403, "stale-lock handoff requires an authenticated agent-bound key", {
57764
+ code: "STALE_LOCK_HANDOFF_ACTOR_MISMATCH"
57765
+ });
57766
+ }
57767
+ if (typeof store.tasks.handoffStaleLock !== "function") {
57768
+ return error(501, "stale-lock handoff is not supported by this storage backend");
57769
+ }
57770
+ const body3 = await readJson3(req) ?? {};
57771
+ const allowed = new Set([
57772
+ "expected_holder",
57773
+ "expected_lock_version",
57774
+ "stale_after_seconds",
57775
+ "new_holder",
57776
+ "reason"
57777
+ ]);
57778
+ const unknown = Object.keys(body3).find((key2) => !allowed.has(key2));
57779
+ if (unknown) {
57780
+ return error(400, `unknown stale-lock handoff field: ${unknown}`, {
57781
+ code: "STALE_LOCK_HANDOFF_INVALID_INPUT",
57782
+ field: unknown
57783
+ });
57784
+ }
57785
+ const receipt = await store.tasks.handoffStaleLock({
57786
+ task_id: exactId,
57787
+ actor: principal.agent,
57788
+ expected_holder: body3.expected_holder,
57789
+ expected_lock_version: body3.expected_lock_version,
57790
+ stale_after_seconds: body3.stale_after_seconds,
57791
+ new_holder: body3.new_holder,
57792
+ reason: body3.reason
57793
+ }, contextFromPrincipal(principal));
57794
+ return json5({ receipt });
57795
+ }
56957
57796
  if (action === "comments") {
56958
57797
  if (method === "GET") {
56959
57798
  if (!await store.tasks.get(id))
@@ -57709,10 +58548,57 @@ async function handleV1Request(req, url, dependencies = {}) {
57709
58548
  return error(400, "invalid JSON body");
57710
58549
  const snapshot = normalizeImportSnapshot(raw);
57711
58550
  const received = countSnapshotRecords(snapshot);
58551
+ const completionImports = validatePlanCompletionImports(raw);
58552
+ if (completionImports.present) {
58553
+ if (completionImports.error)
58554
+ return error(400, completionImports.error);
58555
+ if (received !== 0) {
58556
+ return error(400, "planCompletions cannot be combined with snapshot record arrays");
58557
+ }
58558
+ if (typeof store.plans.completeAtRevision !== "function") {
58559
+ return error(501, "atomic plan completion is not supported by this storage backend");
58560
+ }
58561
+ const operation = completionImports.operations[0];
58562
+ const completed = await store.plans.completeAtRevision(operation.id, operation.expected_updated_at, contextFromPrincipal(principal));
58563
+ return json5({
58564
+ result: {
58565
+ inserted: 0,
58566
+ updated: completed.applied ? 1 : 0,
58567
+ deleted: 0,
58568
+ skipped: completed.applied ? 0 : 1,
58569
+ errors: []
58570
+ },
58571
+ received: 1,
58572
+ planCompletions: [{
58573
+ id: operation.id,
58574
+ status: "completed",
58575
+ expected_updated_at: operation.expected_updated_at,
58576
+ result_updated_at: completed.plan.updated_at,
58577
+ applied: completed.applied
58578
+ }]
58579
+ });
58580
+ }
57712
58581
  if (received === 0) {
57713
58582
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
57714
58583
  }
58584
+ const forbiddenAuditTombstone = (snapshot.tombstones ?? []).find((tombstone) => tombstone.object_type === "audit_history");
58585
+ if (forbiddenAuditTombstone) {
58586
+ return error(400, forbiddenAuditHistoryTombstoneError(forbiddenAuditTombstone.object_id), {
58587
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
58588
+ conflict: false,
58589
+ audit_history_id: forbiddenAuditTombstone.object_id
58590
+ });
58591
+ }
57715
58592
  const result = await store.sync.importSnapshot(snapshot, contextFromPrincipal(principal));
58593
+ const auditFailureMessage = result.errors.find((message) => parseAuditHistoryImportFailure(message) !== null);
58594
+ if (auditFailureMessage) {
58595
+ const failure = parseAuditHistoryImportFailure(auditFailureMessage);
58596
+ return error(failure.status, auditFailureMessage, {
58597
+ code: failure.code,
58598
+ conflict: failure.conflict,
58599
+ audit_history_id: failure.auditHistoryId
58600
+ });
58601
+ }
57716
58602
  return json5({ result, received });
57717
58603
  }
57718
58604
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
@@ -57735,6 +58621,26 @@ async function handleV1Request(req, url, dependencies = {}) {
57735
58621
  if (e instanceof TaskNotFoundError) {
57736
58622
  return error(404, e.message, { code: TaskNotFoundError.code });
57737
58623
  }
58624
+ if (e instanceof StaleLockHandoffError) {
58625
+ const status2 = e.code === "STALE_LOCK_HANDOFF_INVALID_TASK_ID" || e.code === "STALE_LOCK_HANDOFF_INVALID_INPUT" ? 400 : e.code === "STALE_LOCK_HANDOFF_ACTOR_MISMATCH" ? 403 : 409;
58626
+ return error(status2, e.message, {
58627
+ code: e.code,
58628
+ conflict: status2 === 409,
58629
+ ...e.details
58630
+ });
58631
+ }
58632
+ if (e instanceof PlanNotFoundError) {
58633
+ return error(404, e.message, { code: PlanNotFoundError.code });
58634
+ }
58635
+ if (e instanceof PlanRevisionConflictError) {
58636
+ return error(409, e.message, {
58637
+ code: PlanRevisionConflictError.code,
58638
+ conflict: true,
58639
+ plan_id: e.planId,
58640
+ expected_updated_at: e.expectedUpdatedAt,
58641
+ current_updated_at: e.currentUpdatedAt
58642
+ });
58643
+ }
57738
58644
  if (e instanceof LockError)
57739
58645
  return error(409, e.message, { code: LockError.code });
57740
58646
  if (e instanceof TaskNotStartableError) {
@@ -57757,6 +58663,8 @@ var init_v1 = __esm(() => {
57757
58663
  init_redaction();
57758
58664
  init_project_task_list_ensure();
57759
58665
  init_plan_project_link();
58666
+ init_stale_lock_handoff();
58667
+ init_audit_history_import();
57760
58668
  JSON_HEADERS4 = { "Content-Type": "application/json" };
57761
58669
  RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
57762
58670
  });