@hasna/todos 0.15.19 → 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 (43) 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/query-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  5. package/dist/cli/index.js +851 -47
  6. package/dist/cli/stage-a.d.ts +21 -10
  7. package/dist/cli/stage-a.d.ts.map +1 -1
  8. package/dist/contracts.js +228 -16
  9. package/dist/db/audit.d.ts +8 -0
  10. package/dist/db/audit.d.ts.map +1 -1
  11. package/dist/db/task-lifecycle.d.ts +7 -1
  12. package/dist/db/task-lifecycle.d.ts.map +1 -1
  13. package/dist/db/tasks.d.ts +1 -1
  14. package/dist/db/tasks.d.ts.map +1 -1
  15. package/dist/index.js +479 -20
  16. package/dist/lib/cli-help.d.ts +3 -2
  17. package/dist/lib/cli-help.d.ts.map +1 -1
  18. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  19. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  20. package/dist/mcp/index.js +702 -36
  21. package/dist/mcp.js +3 -1
  22. package/dist/project-registration.js +1147 -686
  23. package/dist/registry.js +228 -16
  24. package/dist/release-provenance.json +5 -5
  25. package/dist/sdk/index.js +7 -0
  26. package/dist/sdk/v1.generated.d.ts +27 -0
  27. package/dist/sdk/v1.generated.d.ts.map +1 -1
  28. package/dist/server/index.js +1035 -369
  29. package/dist/server/openapi.d.ts +182 -0
  30. package/dist/server/openapi.d.ts.map +1 -1
  31. package/dist/server/v1.d.ts.map +1 -1
  32. package/dist/storage/audit-history-import.d.ts +14 -0
  33. package/dist/storage/audit-history-import.d.ts.map +1 -0
  34. package/dist/storage/interfaces.d.ts +12 -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/sqlite-snapshot.d.ts.map +1 -1
  39. package/dist/storage.js +476 -19
  40. package/dist/task-manifest.js +11 -1
  41. package/dist/types/index.d.ts +43 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/package.json +3 -1
package/dist/cli/index.js CHANGED
@@ -2123,7 +2123,7 @@ var package_default;
2123
2123
  var init_package = __esm(() => {
2124
2124
  package_default = {
2125
2125
  name: "@hasna/todos",
2126
- version: "0.15.19",
2126
+ version: "0.15.20",
2127
2127
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
2128
2128
  type: "module",
2129
2129
  main: "dist/index.js",
@@ -2194,6 +2194,8 @@ var init_package = __esm(() => {
2194
2194
  "dev:mcp": "bun run src/mcp/index.ts",
2195
2195
  "dev:serve": "bun run src/server/index.ts",
2196
2196
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
2197
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
2198
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
2197
2199
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
2198
2200
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
2199
2201
  },
@@ -2269,7 +2271,7 @@ function isBlockingDependencyStatus(status) {
2269
2271
  function isTerminalStatus(status) {
2270
2272
  return status === "completed" || status === "failed" || status === "cancelled";
2271
2273
  }
2272
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
2274
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
2273
2275
  var init_types = __esm(() => {
2274
2276
  TASK_STATUSES = [
2275
2277
  "pending",
@@ -2392,6 +2394,16 @@ var init_types = __esm(() => {
2392
2394
  this.name = "LockError";
2393
2395
  }
2394
2396
  };
2397
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
2398
+ code;
2399
+ details;
2400
+ constructor(code, message, details = {}) {
2401
+ super(message);
2402
+ this.code = code;
2403
+ this.details = details;
2404
+ this.name = "StaleLockHandoffError";
2405
+ }
2406
+ };
2395
2407
  AgentNotFoundError = class AgentNotFoundError extends Error {
2396
2408
  agentId;
2397
2409
  static code = "AGENT_NOT_FOUND";
@@ -5480,6 +5492,7 @@ var init_http_client = __esm(() => {
5480
5492
  // src/cli/cloud-router.ts
5481
5493
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
5482
5494
  import { normalizeStorageMode } from "@hasna/contracts/mode";
5495
+ import { randomUUID } from "crypto";
5483
5496
  import { resolve as resolvePath } from "path";
5484
5497
  function cleanMode(value) {
5485
5498
  const normalized = value?.trim().toLowerCase();
@@ -5832,6 +5845,45 @@ async function requestRawCloudTaskPage(client, filter) {
5832
5845
  total: parseCloudTaskTotal(res.raw)
5833
5846
  };
5834
5847
  }
5848
+ function cloudPlanTaskListError(planId, detail) {
5849
+ return new Error(`REMOTE_PLAN_TASK_LIST_INCOMPLETE: hosted authority returned an incomplete task set for plan ${planId}; ` + `${detail}; refusing an incomplete plan result`);
5850
+ }
5851
+ function appendExactPlanTaskPage(target, seen, page, planId) {
5852
+ for (const task of page) {
5853
+ if (task.plan_id !== planId) {
5854
+ throw cloudPlanTaskListError(planId, `response includes task ${task.id} from plan ${task.plan_id ?? "null"}`);
5855
+ }
5856
+ if (seen.has(task.id)) {
5857
+ throw cloudPlanTaskListError(planId, `pagination repeats task id ${task.id}`);
5858
+ }
5859
+ seen.add(task.id);
5860
+ target.push(task);
5861
+ }
5862
+ }
5863
+ async function cloudListPlanTasks(client, planId) {
5864
+ const filter = { plan_id: planId, include_subtasks: true };
5865
+ const firstPage = await requestRawCloudTaskPage(client, filter);
5866
+ const tasks = [];
5867
+ const seen = new Set;
5868
+ appendExactPlanTaskPage(tasks, seen, firstPage.tasks, planId);
5869
+ if (firstPage.total === undefined)
5870
+ return tasks;
5871
+ const total = firstPage.total;
5872
+ if (tasks.length > total) {
5873
+ throw cloudPlanTaskListError(planId, `first page contains ${tasks.length} rows but reports total ${total}`);
5874
+ }
5875
+ while (tasks.length < total) {
5876
+ const page = await requestRawCloudTaskPage(client, { ...filter, offset: tasks.length });
5877
+ if (page.total !== total) {
5878
+ throw cloudPlanTaskListError(planId, `pagination total changed from ${total} to ${String(page.total)}`);
5879
+ }
5880
+ if (page.tasks.length === 0 || tasks.length + page.tasks.length > total) {
5881
+ throw cloudPlanTaskListError(planId, "pagination did not make bounded progress toward the reported total");
5882
+ }
5883
+ appendExactPlanTaskPage(tasks, seen, page.tasks, planId);
5884
+ }
5885
+ return tasks;
5886
+ }
5835
5887
  function cloudTaskListFilterError(code, taskListId, detail) {
5836
5888
  return new Error(`${code}: hosted authority returned rows outside requested task_list_id ${taskListId}; ${detail}; refusing an incomplete exact-list result`);
5837
5889
  }
@@ -5948,7 +6000,10 @@ async function cloudGetTask(client, id) {
5948
6000
  }
5949
6001
  async function cloudCreateTask(client, input) {
5950
6002
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
5951
- const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
6003
+ const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.transport.post("/tasks", input, {
6004
+ idempotencyKey: randomUUID(),
6005
+ retry: false
6006
+ }), ["PARENT_TASK_NOT_FOUND"]));
5952
6007
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
5953
6008
  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");
5954
6009
  }
@@ -5959,7 +6014,7 @@ async function cloudCreateTask(client, input) {
5959
6014
  return persisted;
5960
6015
  }
5961
6016
  async function cloudUpdateTask(client, id, patch) {
5962
- return unwrapTask(await client.update("tasks", id, patch));
6017
+ return unwrapTask(await client.transport.patch(`/tasks/${encodeURIComponent(id)}`, patch));
5963
6018
  }
5964
6019
  async function cloudDeleteTask(client, id) {
5965
6020
  try {
@@ -6710,6 +6765,19 @@ async function cloudUnlockTask(client, id, agentId, force = false) {
6710
6765
  }
6711
6766
  return true;
6712
6767
  }
6768
+ async function cloudHandoffStaleTaskLock(client, input) {
6769
+ const raw = await client.transport.post(`/tasks/${encodeURIComponent(input.task_id)}/stale-lock-handoff`, {
6770
+ expected_holder: input.expected_holder,
6771
+ expected_lock_version: input.expected_lock_version,
6772
+ stale_after_seconds: input.stale_after_seconds,
6773
+ new_holder: input.new_holder,
6774
+ reason: input.reason
6775
+ });
6776
+ if (raw && typeof raw === "object" && "receipt" in raw) {
6777
+ return raw.receipt;
6778
+ }
6779
+ throw new Error("STALE_LOCK_HANDOFF_RECEIPT_MISSING: remote response did not include receipt");
6780
+ }
6713
6781
  async function cloudGetDependencies(client, id) {
6714
6782
  const raw = await client.transport.get(`/tasks/${encodeURIComponent(id)}/dependencies`);
6715
6783
  const env = raw ?? {};
@@ -7190,6 +7258,12 @@ var init_cloud_router = __esm(() => {
7190
7258
  });
7191
7259
 
7192
7260
  // src/cli/stage-a.ts
7261
+ function applyTodosCliAuthorityEnvironment(authority, env = process.env) {
7262
+ if (authority.route !== "local" || authority.selected_by !== "local-only-command")
7263
+ return;
7264
+ env.HASNA_TODOS_STORAGE_MODE = "sqlite";
7265
+ env.TODOS_STORAGE_MODE = "sqlite";
7266
+ }
7193
7267
  function isTodosCliCommandVisibleForRoute(command, route) {
7194
7268
  if (route === "local")
7195
7269
  return true;
@@ -7399,7 +7473,7 @@ function nearestCommands(command, limit = 3) {
7399
7473
  const threshold = command.length <= 4 ? 1 : command.length <= 8 ? 2 : 3;
7400
7474
  return [...COMMAND_CAPABILITY_MATRIX.entries()].filter(([, owner]) => owner !== "local-only").map(([candidate]) => candidate).map((candidate) => ({ candidate, distance: editDistance(command, candidate) })).filter(({ distance }) => distance <= threshold).sort((left, right) => left.distance - right.distance || left.candidate.localeCompare(right.candidate)).slice(0, limit).map(({ candidate }) => candidate);
7401
7475
  }
7402
- function assertRemoteCommandSupported(invocation) {
7476
+ function assertInvocationRoutable(invocation) {
7403
7477
  if (invocation.invalidGlobalOption) {
7404
7478
  throw new Error(`REMOTE_COMMAND_UNSUPPORTED: the global option ${invocation.invalidGlobalOption} was given without a value; ` + `pass one as \`${invocation.invalidGlobalOption} <value>\``);
7405
7479
  }
@@ -7413,8 +7487,12 @@ function assertRemoteCommandSupported(invocation) {
7413
7487
  const didYouMean = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
7414
7488
  throw new Error(`UNKNOWN_COMMAND: \`${command}\` is not a built-in todos command on the /v1 route.${didYouMean} ` + "Run `todos --help` for the commands available here; verbs contributed by optional packages are local-only. " + "(This is not a connectivity, storage-mode or credential problem.)");
7415
7489
  }
7490
+ return owner;
7491
+ }
7492
+ function assertRemoteCommandSupported(invocation, owner) {
7493
+ const command = invocation.command;
7416
7494
  if (command && owner === "local-only") {
7417
- throw new Error(`REMOTE_COMMAND_UNSUPPORTED: \`${command}\` is a local-only command and the Todos /v1 authority does not ` + "serve it; local SQLite fallback is disabled. Run `todos --help` to see the commands this route supports.");
7495
+ throw new Error(`LOCAL_COMMAND_ROUTING_INVARIANT: \`${command}\` must select the local command route before remote authority validation`);
7418
7496
  }
7419
7497
  if (!command || !commandSupportsRemote(invocation)) {
7420
7498
  const blame = command ? disqualifyingArgument(invocation) : null;
@@ -7432,7 +7510,11 @@ function initializeTodosCliAuthority(args = process.argv.slice(2), env = process
7432
7510
  const status = getTodosRemoteAuthorityConfigStatus(env);
7433
7511
  return { route: "remote-diagnostic", v1_base_url: status.v1_base_url };
7434
7512
  }
7435
- assertRemoteCommandSupported(invocation);
7513
+ const owner = assertInvocationRoutable(invocation);
7514
+ if (owner === "local-only") {
7515
+ return { route: "local", v1_base_url: null, selected_by: "local-only-command" };
7516
+ }
7517
+ assertRemoteCommandSupported(invocation, owner);
7436
7518
  const client = getTodosCloudClient(env);
7437
7519
  if (!client) {
7438
7520
  throw new Error("REMOTE_API_UNAVAILABLE: remote mode did not resolve an HTTP client; local SQLite fallback is disabled");
@@ -7566,6 +7648,7 @@ var init_stage_a = __esm(() => {
7566
7648
  "sprint",
7567
7649
  "stale",
7568
7650
  "standup",
7651
+ "stale-lock-handoff",
7569
7652
  "start",
7570
7653
  "status",
7571
7654
  "steal",
@@ -7698,6 +7781,7 @@ var init_stage_a = __esm(() => {
7698
7781
  "tag",
7699
7782
  "task",
7700
7783
  "task-lists",
7784
+ "stale-lock-handoff",
7701
7785
  "template-export",
7702
7786
  "template-import",
7703
7787
  "template-preview",
@@ -7705,6 +7789,7 @@ var init_stage_a = __esm(() => {
7705
7789
  "timeline",
7706
7790
  "tl",
7707
7791
  "unlock",
7792
+ "unassign",
7708
7793
  "untag",
7709
7794
  "update"
7710
7795
  ]);
@@ -13590,7 +13675,7 @@ __export(exports_event_hooks, {
13590
13675
  emitLocalEventHooks: () => emitLocalEventHooks,
13591
13676
  LOCAL_EVENT_TYPES: () => LOCAL_EVENT_TYPES
13592
13677
  });
13593
- import { createHash as createHash4, randomUUID } from "crypto";
13678
+ import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
13594
13679
  import { appendFileSync, mkdirSync as mkdirSync4 } from "fs";
13595
13680
  import { dirname as dirname4, resolve as resolve7 } from "path";
13596
13681
  import { createConnection } from "net";
@@ -13651,7 +13736,7 @@ function canonicalEvent(input) {
13651
13736
  }
13652
13737
  function buildEnvelope(type, payload, timestamp2 = new Date().toISOString()) {
13653
13738
  const base = {
13654
- id: randomUUID(),
13739
+ id: randomUUID2(),
13655
13740
  type,
13656
13741
  timestamp: timestamp2,
13657
13742
  payload: redactValue(payload ?? {}),
@@ -13854,7 +13939,7 @@ import { existsSync as existsSync6 } from "fs";
13854
13939
  import { homedir as homedir2 } from "os";
13855
13940
  import { join as join5 } from "path";
13856
13941
  import { createHmac, timingSafeEqual } from "crypto";
13857
- import { randomUUID as randomUUID2 } from "crypto";
13942
+ import { randomUUID as randomUUID3 } from "crypto";
13858
13943
  import { spawn } from "child_process";
13859
13944
  import { randomUUID as randomUUID22 } from "crypto";
13860
13945
  function getPathValue(input, path) {
@@ -14212,7 +14297,7 @@ async function dispatchChannel(event, channel, options = {}) {
14212
14297
  function createDeliveryResult(event, channel, attempts) {
14213
14298
  const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
14214
14299
  return {
14215
- id: randomUUID2(),
14300
+ id: randomUUID3(),
14216
14301
  eventId: event.id,
14217
14302
  channelId: channel.id,
14218
14303
  transport: channel.transport,
@@ -15214,6 +15299,7 @@ var init_activity_audit = __esm(() => {
15214
15299
  var exports_audit = {};
15215
15300
  __export(exports_audit, {
15216
15301
  logTaskChange: () => logTaskChange,
15302
+ insertTaskHistory: () => insertTaskHistory,
15217
15303
  getTaskHistory: () => getTaskHistory,
15218
15304
  getRecentActivity: () => getRecentActivity,
15219
15305
  getRecap: () => getRecap
@@ -15221,28 +15307,55 @@ __export(exports_audit, {
15221
15307
  function sanitizeHistoryValue(value, context) {
15222
15308
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
15223
15309
  }
15224
- function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
15310
+ function insertTaskHistory(entry, db) {
15225
15311
  const d = db || getDatabase();
15226
- const id = uuid();
15227
- const timestamp2 = now();
15228
- const machineId = currentStorageMachineId(d);
15229
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
15230
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
15312
+ const safeEntry = {
15313
+ ...entry,
15314
+ field: entry.field || null,
15315
+ old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
15316
+ new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
15317
+ agent_id: entry.agent_id || null,
15318
+ machine_id: entry.machine_id ?? currentStorageMachineId(d)
15319
+ };
15231
15320
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
15232
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
15321
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
15322
+ safeEntry.id,
15323
+ safeEntry.task_id,
15324
+ safeEntry.action,
15325
+ safeEntry.field,
15326
+ safeEntry.old_value,
15327
+ safeEntry.new_value,
15328
+ safeEntry.agent_id,
15329
+ safeEntry.created_at,
15330
+ safeEntry.machine_id ?? null
15331
+ ]);
15233
15332
  try {
15234
15333
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
15235
15334
  logActivity2({
15236
15335
  entity_type: "task",
15237
- entity_id: taskId,
15238
- action,
15239
- field,
15240
- old_value: safeOldValue,
15241
- new_value: safeNewValue,
15242
- actor_id: agentId ?? undefined
15336
+ entity_id: safeEntry.task_id,
15337
+ action: safeEntry.action,
15338
+ field: safeEntry.field ?? undefined,
15339
+ old_value: safeEntry.old_value,
15340
+ new_value: safeEntry.new_value,
15341
+ actor_id: safeEntry.agent_id ?? undefined
15243
15342
  }, d);
15244
15343
  } catch {}
15245
- 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 };
15344
+ return safeEntry;
15345
+ }
15346
+ function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
15347
+ const d = db || getDatabase();
15348
+ return insertTaskHistory({
15349
+ id: uuid(),
15350
+ task_id: taskId,
15351
+ action,
15352
+ field: field || null,
15353
+ old_value: oldValue ?? null,
15354
+ new_value: newValue ?? null,
15355
+ agent_id: agentId || null,
15356
+ created_at: now(),
15357
+ machine_id: currentStorageMachineId(d)
15358
+ }, d);
15246
15359
  }
15247
15360
  function getTaskHistory(taskId, db) {
15248
15361
  const d = db || getDatabase();
@@ -16303,6 +16416,145 @@ var init_task_graph = __esm(() => {
16303
16416
  init_task_crud();
16304
16417
  });
16305
16418
 
16419
+ // src/lib/stale-lock-handoff.ts
16420
+ function normalizeExactTaskId(value) {
16421
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
16422
+ 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 });
16423
+ }
16424
+ return value.trim().toLowerCase();
16425
+ }
16426
+ function requireNonEmptyString(value, field) {
16427
+ if (typeof value !== "string" || !value.trim()) {
16428
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
16429
+ }
16430
+ const trimmed = value.trim();
16431
+ if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
16432
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
16433
+ }
16434
+ return trimmed;
16435
+ }
16436
+ function requireCanonicalLockVersion(value) {
16437
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
16438
+ 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" });
16439
+ }
16440
+ const parsed = Date.parse(value);
16441
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
16442
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
16443
+ }
16444
+ return value;
16445
+ }
16446
+ function requireStaleThreshold(value) {
16447
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
16448
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
16449
+ }
16450
+ return Number(value);
16451
+ }
16452
+ function prepareStaleLockHandoff(input, options = {}) {
16453
+ const taskId = normalizeExactTaskId(input.task_id);
16454
+ const actor = requireNonEmptyString(input.actor, "actor");
16455
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
16456
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
16457
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
16458
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
16459
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
16460
+ if (!reason) {
16461
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
16462
+ }
16463
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
16464
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
16465
+ }
16466
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
16467
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
16468
+ }
16469
+ const operationTimestamp = options.now ?? new Date().toISOString();
16470
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
16471
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
16472
+ }
16473
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
16474
+ return {
16475
+ task_id: taskId,
16476
+ actor,
16477
+ expected_holder: expectedHolder,
16478
+ expected_lock_version: expectedLockVersion,
16479
+ stale_after_seconds: staleAfterSeconds,
16480
+ new_holder: newHolder,
16481
+ reason,
16482
+ operation_timestamp: operationTimestamp,
16483
+ stale_cutoff: staleCutoff,
16484
+ receipt_id: options.receiptId ?? crypto.randomUUID()
16485
+ };
16486
+ }
16487
+ function buildStaleLockHandoffReceipt(input) {
16488
+ return {
16489
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
16490
+ receipt_id: input.receipt_id,
16491
+ task_id: input.task_id,
16492
+ actor: input.actor,
16493
+ previous_holder: input.expected_holder,
16494
+ previous_lock_version: input.expected_lock_version,
16495
+ new_holder: input.new_holder,
16496
+ new_lock_version: input.operation_timestamp,
16497
+ stale_after_seconds: input.stale_after_seconds,
16498
+ stale_cutoff: input.stale_cutoff,
16499
+ reason: input.reason,
16500
+ created_at: input.operation_timestamp
16501
+ };
16502
+ }
16503
+ function staleLockHandoffHistory(receipt, machineId) {
16504
+ return {
16505
+ id: receipt.receipt_id,
16506
+ task_id: receipt.task_id,
16507
+ action: STALE_LOCK_HANDOFF_ACTION,
16508
+ field: STALE_LOCK_HANDOFF_FIELD,
16509
+ old_value: JSON.stringify({
16510
+ holder: receipt.previous_holder,
16511
+ lock_version: receipt.previous_lock_version
16512
+ }),
16513
+ new_value: JSON.stringify(receipt),
16514
+ agent_id: receipt.actor,
16515
+ created_at: receipt.created_at,
16516
+ machine_id: machineId
16517
+ };
16518
+ }
16519
+ function throwStaleLockHandoffConflict(task, input) {
16520
+ if (!task.locked_by || !task.locked_at) {
16521
+ 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 });
16522
+ }
16523
+ if (task.locked_at !== input.expected_lock_version) {
16524
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
16525
+ task_id: input.task_id,
16526
+ expected_lock_version: input.expected_lock_version,
16527
+ current_lock_version: task.locked_at
16528
+ });
16529
+ }
16530
+ if (task.locked_by !== input.expected_holder) {
16531
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
16532
+ task_id: input.task_id,
16533
+ expected_holder: input.expected_holder,
16534
+ current_holder: task.locked_by
16535
+ });
16536
+ }
16537
+ if (isTerminalStatus(task.status)) {
16538
+ 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 });
16539
+ }
16540
+ if (task.locked_at >= input.stale_cutoff) {
16541
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
16542
+ task_id: input.task_id,
16543
+ current_lock_version: task.locked_at,
16544
+ stale_cutoff: input.stale_cutoff
16545
+ });
16546
+ }
16547
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
16548
+ }
16549
+ 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;
16550
+ var init_stale_lock_handoff = __esm(() => {
16551
+ init_types();
16552
+ init_creator_identity();
16553
+ init_prewrite_secrets();
16554
+ 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;
16555
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
16556
+ });
16557
+
16306
16558
  // src/db/task-lifecycle.ts
16307
16559
  var exports_task_lifecycle = {};
16308
16560
  __export(exports_task_lifecycle, {
@@ -16311,6 +16563,7 @@ __export(exports_task_lifecycle, {
16311
16563
  startTask: () => startTask,
16312
16564
  spawnNextRecurrence: () => spawnNextRecurrence,
16313
16565
  lockTask: () => lockTask,
16566
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
16314
16567
  getTasksChangedSince: () => getTasksChangedSince,
16315
16568
  getTaskLockStatus: () => getTaskLockStatus,
16316
16569
  getStaleTasks: () => getStaleTasks,
@@ -16579,6 +16832,38 @@ function unlockTask(id, agentId, db) {
16579
16832
  WHERE id = ?`, [timestamp2, id]);
16580
16833
  return true;
16581
16834
  }
16835
+ function handoffStaleTaskLock(input, db) {
16836
+ const d = db || getDatabase();
16837
+ const prepared = prepareStaleLockHandoff(input);
16838
+ const receipt = buildStaleLockHandoffReceipt(prepared);
16839
+ const history = staleLockHandoffHistory(receipt, null);
16840
+ const transfer = d.transaction(() => {
16841
+ const result = d.run(`UPDATE tasks
16842
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
16843
+ WHERE id = ?
16844
+ AND locked_by = ?
16845
+ AND locked_at = ?
16846
+ AND julianday(locked_at) < julianday(?)
16847
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
16848
+ prepared.new_holder,
16849
+ prepared.operation_timestamp,
16850
+ prepared.operation_timestamp,
16851
+ prepared.task_id,
16852
+ prepared.expected_holder,
16853
+ prepared.expected_lock_version,
16854
+ prepared.stale_cutoff
16855
+ ]);
16856
+ if (result.changes === 0) {
16857
+ const current = getTask(prepared.task_id, d);
16858
+ if (!current)
16859
+ throw new TaskNotFoundError(prepared.task_id);
16860
+ throwStaleLockHandoffConflict(current, prepared);
16861
+ }
16862
+ insertTaskHistory(history, d);
16863
+ });
16864
+ transfer();
16865
+ return receipt;
16866
+ }
16582
16867
  function getTaskLockStatus(id, db) {
16583
16868
  const d = db || getDatabase();
16584
16869
  const task = getTask(id, d);
@@ -16879,6 +17164,7 @@ var init_task_lifecycle = __esm(() => {
16879
17164
  init_task_crud();
16880
17165
  init_task_graph();
16881
17166
  init_prewrite_secrets();
17167
+ init_stale_lock_handoff();
16882
17168
  });
16883
17169
 
16884
17170
  // src/db/task-crud.ts
@@ -20488,6 +20774,7 @@ __export(exports_tasks, {
20488
20774
  insertTaskTags: () => insertTaskTags,
20489
20775
  importTaskBoardBundle: () => importTaskBoardBundle,
20490
20776
  importCalendarIcs: () => importCalendarIcs,
20777
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
20491
20778
  getTimeReport: () => getTimeReport,
20492
20779
  getTimeLogs: () => getTimeLogs,
20493
20780
  getTasksChangedSince: () => getTasksChangedSince,
@@ -22130,7 +22417,7 @@ function registerTaskCommands(program2) {
22130
22417
  }
22131
22418
  });
22132
22419
  const task = program2.command("task").description("Task subcommands for deterministic automation");
22133
- task.command("upsert").description("Create or update a task by stable metadata fingerprint").requiredOption("--fingerprint <key>", "Stable dedupe fingerprint").requiredOption("--title <text>", "Task title").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("-s, --status <status>", "Task status").option("--list <id>", "Task list ID").option("--task-list <id>", "Task list ID (alias for --list)").option("-t, --tags <tags>", "Comma-separated tags").option("--tag <tags>", "Comma-separated tags (alias for --tags)").option("--metadata-json <json>", "JSON object merged into task metadata").option("--working-dir <path>", "Working directory to store on create/update").option("--project <id>", "Assign to project by ID, slug, or path").option("--assign <agent>", "Assign to agent").option("--assign-seat", "Allow --assign to name a durable seat (a seat queue has no session watching it)").option("--expectation-id <id>", "Expectation metadata ID").option("--expectation-fingerprint <key>", "Expectation metadata fingerprint").option("--evidence-paths <paths>", "Comma-separated evidence paths").option("--origin-loop-id <id>", "Origin loop ID").option("--origin-run-id <id>", "Origin run ID").option("--expected <json-or-text>", "Expected value metadata").option("--observed <json-or-text>", "Observed value metadata").option("--acceptance <json-or-text>", "Acceptance metadata").action(async (opts) => {
22420
+ task.command("upsert").description("Create or update a task by stable metadata fingerprint").requiredOption("--fingerprint <key>", "Stable dedupe fingerprint").requiredOption("--title <text>", "Task title").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("-s, --status <status>", "Task status").option("--list <id>", "Task list ID").option("--task-list <id>", "Task list ID (alias for --list)").option("--plan <id>", "Assign to a plan").option("-t, --tags <tags>", "Comma-separated tags").option("--tag <tags>", "Comma-separated tags (alias for --tags)").option("--metadata-json <json>", "JSON object merged into task metadata").option("--working-dir <path>", "Working directory to store on create/update").option("--project <id>", "Assign to project by ID, slug, or path").option("--assign <agent>", "Assign to agent").option("--assign-seat", "Allow --assign to name a durable seat (a seat queue has no session watching it)").option("--expectation-id <id>", "Expectation metadata ID").option("--expectation-fingerprint <key>", "Expectation metadata fingerprint").option("--evidence-paths <paths>", "Comma-separated evidence paths").option("--origin-loop-id <id>", "Origin loop ID").option("--origin-run-id <id>", "Origin run ID").option("--expected <json-or-text>", "Expected value metadata").option("--observed <json-or-text>", "Observed value metadata").option("--acceptance <json-or-text>", "Acceptance metadata").action(async (opts) => {
22134
22421
  const globalOpts = program2.opts();
22135
22422
  opts.tags = opts.tags || opts.tag;
22136
22423
  opts.list = opts.list || opts.taskList;
@@ -22144,6 +22431,10 @@ function registerTaskCommands(program2) {
22144
22431
  try {
22145
22432
  const projectId2 = explicitProject ? await cloudResolveProjectRef(cloud, explicitProject) : undefined;
22146
22433
  const taskListId2 = opts.list ? await cloudResolveTaskListRef(cloud, opts.list, projectId2) : undefined;
22434
+ const plan = opts.plan ? await cloudResolvePlan(cloud, opts.plan, projectId2) : null;
22435
+ if (opts.plan && !plan) {
22436
+ throw new Error(`Could not resolve plan ID or slug: ${opts.plan}`);
22437
+ }
22147
22438
  cloudResult = await cloudUpsertTaskByFingerprint(cloud, {
22148
22439
  fingerprint: opts.fingerprint,
22149
22440
  title: opts.title,
@@ -22155,7 +22446,8 @@ function registerTaskCommands(program2) {
22155
22446
  metadata: buildExpectationMetadata(opts),
22156
22447
  working_dir: opts.workingDir ? resolve9(opts.workingDir) : process.cwd(),
22157
22448
  project_id: projectId2,
22158
- assigned_to: opts.assign
22449
+ assigned_to: opts.assign,
22450
+ plan_id: plan?.id
22159
22451
  });
22160
22452
  } catch (e) {
22161
22453
  handleError(e);
@@ -22177,6 +22469,7 @@ function registerTaskCommands(program2) {
22177
22469
  }
22178
22470
  return id;
22179
22471
  })() : undefined;
22472
+ const planId = opts.plan ? resolvePlanId(opts.plan) : undefined;
22180
22473
  let result;
22181
22474
  try {
22182
22475
  result = upsertTaskByFingerprint({
@@ -22191,6 +22484,7 @@ function registerTaskCommands(program2) {
22191
22484
  working_dir: opts.workingDir ? resolve9(opts.workingDir) : process.cwd(),
22192
22485
  project_id: projectId,
22193
22486
  assigned_to: opts.assign,
22487
+ plan_id: planId,
22194
22488
  agent_id: globalOpts.agent,
22195
22489
  session_id: globalOpts.session
22196
22490
  });
@@ -23184,6 +23478,44 @@ ${chalk3.cyan(sid)} ${statusColor(task2.status)} ${prioColor(task2.priority)} ${
23184
23478
  console.log(chalk3.green("Lock released."));
23185
23479
  }
23186
23480
  });
23481
+ program2.command("stale-lock-handoff <id>").description("Atomically transfer one exact stale lock by holder and locked_at version").requiredOption("--expected-holder <agent>", "Exact current locked_by value").requiredOption("--expected-lock-version <timestamp>", "Exact current locked_at value (canonical UTC)").requiredOption("--stale-after-seconds <seconds>", "Required lock age threshold; no default").requiredOption("--new-holder <agent>", "New holder; must match the authenticated/--agent identity").requiredOption("--reason <text>", "Non-empty audit reason").action(async (id, opts) => {
23482
+ const globalOpts = program2.opts();
23483
+ const taskId = normalizeExactTaskId(id);
23484
+ const actor = resolveClaimIdentity("handoff a stale lock on", globalOpts.agent);
23485
+ const staleAfterSeconds = Number(opts.staleAfterSeconds);
23486
+ const cloud = getTodosCloudClient();
23487
+ let receipt;
23488
+ try {
23489
+ receipt = cloud ? await cloudHandoffStaleTaskLock(cloud, {
23490
+ task_id: taskId,
23491
+ expected_holder: opts.expectedHolder,
23492
+ expected_lock_version: opts.expectedLockVersion,
23493
+ stale_after_seconds: staleAfterSeconds,
23494
+ new_holder: opts.newHolder,
23495
+ reason: opts.reason
23496
+ }) : handoffStaleTaskLock({
23497
+ task_id: taskId,
23498
+ actor,
23499
+ expected_holder: opts.expectedHolder,
23500
+ expected_lock_version: opts.expectedLockVersion,
23501
+ stale_after_seconds: staleAfterSeconds,
23502
+ new_holder: opts.newHolder,
23503
+ reason: opts.reason
23504
+ });
23505
+ } catch (e) {
23506
+ handleError(e);
23507
+ }
23508
+ if (globalOpts.json) {
23509
+ output({ receipt }, true);
23510
+ return;
23511
+ }
23512
+ console.log(chalk3.green(`Stale lock transferred on task ${escapeTerminalControls(receipt.task_id)}.`));
23513
+ console.log(` ${escapeTerminalControls(receipt.previous_holder)} @ ${escapeTerminalControls(receipt.previous_lock_version)}`);
23514
+ console.log(` -> ${escapeTerminalControls(receipt.new_holder)} @ ${escapeTerminalControls(receipt.new_lock_version)}`);
23515
+ console.log(` stale after ${receipt.stale_after_seconds}s (cutoff ${escapeTerminalControls(receipt.stale_cutoff)})`);
23516
+ console.log(` receipt ${escapeTerminalControls(receipt.receipt_id)}`);
23517
+ console.log(` reason ${escapeTerminalControls(receipt.reason)}`);
23518
+ });
23187
23519
  program2.command("delete <id>").description("Delete a task").action(async (id) => {
23188
23520
  const globalOpts = program2.opts();
23189
23521
  const cloud = getTodosCloudClient();
@@ -23368,6 +23700,7 @@ var init_task_commands = __esm(() => {
23368
23700
  init_agents();
23369
23701
  init_helpers();
23370
23702
  init_output_redaction();
23703
+ init_stale_lock_handoff();
23371
23704
  });
23372
23705
 
23373
23706
  // src/lib/plan-artifacts.ts
@@ -24355,6 +24688,58 @@ var init_plan_project_links = __esm(() => {
24355
24688
  init_tasks();
24356
24689
  });
24357
24690
 
24691
+ // src/storage/audit-history-import.ts
24692
+ function auditHistoryRowsAreFieldIdentical(left, right) {
24693
+ return AUDIT_HISTORY_FIELDS.every((field) => {
24694
+ const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
24695
+ const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
24696
+ return leftValue === rightValue;
24697
+ });
24698
+ }
24699
+ function divergentAuditHistoryReplayError(id) {
24700
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
24701
+ }
24702
+ function forbiddenAuditHistoryTombstoneError(id) {
24703
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
24704
+ }
24705
+ function parseAuditHistoryImportFailure(message) {
24706
+ const divergentPrefix = `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row `;
24707
+ const divergentSuffix = " differs from stored row";
24708
+ if (message.startsWith(divergentPrefix) && message.endsWith(divergentSuffix)) {
24709
+ return {
24710
+ code: AUDIT_HISTORY_DIVERGENT_REPLAY,
24711
+ auditHistoryId: message.slice(divergentPrefix.length, -divergentSuffix.length),
24712
+ conflict: true,
24713
+ status: 409
24714
+ };
24715
+ }
24716
+ const tombstonePrefix = `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone `;
24717
+ const tombstoneSuffix = " is not allowed";
24718
+ if (message.startsWith(tombstonePrefix) && message.endsWith(tombstoneSuffix)) {
24719
+ return {
24720
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
24721
+ auditHistoryId: message.slice(tombstonePrefix.length, -tombstoneSuffix.length),
24722
+ conflict: false,
24723
+ status: 400
24724
+ };
24725
+ }
24726
+ return null;
24727
+ }
24728
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY", AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN", AUDIT_HISTORY_FIELDS;
24729
+ var init_audit_history_import = __esm(() => {
24730
+ AUDIT_HISTORY_FIELDS = [
24731
+ "id",
24732
+ "task_id",
24733
+ "action",
24734
+ "field",
24735
+ "old_value",
24736
+ "new_value",
24737
+ "agent_id",
24738
+ "created_at",
24739
+ "machine_id"
24740
+ ];
24741
+ });
24742
+
24358
24743
  // src/storage/sqlite-snapshot.ts
24359
24744
  function exportSqliteTodosStorageSnapshot(db) {
24360
24745
  const d = db ?? getDatabase();
@@ -24388,8 +24773,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
24388
24773
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
24389
24774
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
24390
24775
  }
24776
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
24777
+ result.errors.push(...auditImport.errors);
24391
24778
  if (result.errors.length > 0)
24392
24779
  return result;
24780
+ result.skipped += auditImport.identicalReplayCount;
24393
24781
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
24394
24782
  for (const row of rows) {
24395
24783
  try {
@@ -24424,10 +24812,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
24424
24812
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
24425
24813
  }
24426
24814
  });
24427
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
24815
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
24428
24816
  applyTombstones(d, snapshot.tombstones ?? [], result);
24429
24817
  return result;
24430
24818
  }
24819
+ function preflightAuditHistoryImport(db, rows, tombstones) {
24820
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
24821
+ const rowsToInsert = [];
24822
+ const seen = new Map;
24823
+ let identicalReplayCount = 0;
24824
+ for (const rawRow of rows) {
24825
+ try {
24826
+ const row = asRecord(rawRow);
24827
+ if (typeof row.id !== "string" || !row.id) {
24828
+ throw new Error("task_history row is missing id");
24829
+ }
24830
+ const prior = seen.get(row.id);
24831
+ if (prior) {
24832
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
24833
+ identicalReplayCount += 1;
24834
+ else
24835
+ errors.push(divergentAuditHistoryReplayError(row.id));
24836
+ continue;
24837
+ }
24838
+ seen.set(row.id, row);
24839
+ const existing = getAuditHistoryById(db, row.id);
24840
+ if (!existing) {
24841
+ rowsToInsert.push(row);
24842
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
24843
+ identicalReplayCount += 1;
24844
+ } else {
24845
+ errors.push(divergentAuditHistoryReplayError(row.id));
24846
+ }
24847
+ } catch (error) {
24848
+ errors.push(error instanceof Error ? error.message : String(error));
24849
+ }
24850
+ }
24851
+ return { rowsToInsert, identicalReplayCount, errors };
24852
+ }
24853
+ function insertAuditHistoryRows(db, rows, result) {
24854
+ for (const rawRow of rows) {
24855
+ try {
24856
+ const row = asRecord(rawRow);
24857
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
24858
+ if (!presentColumns.includes("id"))
24859
+ presentColumns.unshift("id");
24860
+ const placeholders = presentColumns.map(() => "?").join(", ");
24861
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
24862
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
24863
+ if (changes > 0) {
24864
+ result.inserted += 1;
24865
+ continue;
24866
+ }
24867
+ const existing = getAuditHistoryById(db, String(row["id"]));
24868
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
24869
+ result.skipped += 1;
24870
+ } else {
24871
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
24872
+ }
24873
+ } catch (error) {
24874
+ result.errors.push(error instanceof Error ? error.message : String(error));
24875
+ }
24876
+ }
24877
+ }
24878
+ function getAuditHistoryById(db, id) {
24879
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
24880
+ }
24431
24881
  function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
24432
24882
  const id = row["id"];
24433
24883
  if (typeof id !== "string" || !id)
@@ -24525,7 +24975,7 @@ function tableForTombstone(objectType) {
24525
24975
  return "task_templates";
24526
24976
  if (objectType === "template_tasks")
24527
24977
  return "template_tasks";
24528
- return "task_history";
24978
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
24529
24979
  }
24530
24980
  function listRows(db, table, columns) {
24531
24981
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -24572,6 +25022,7 @@ var init_sqlite_snapshot = __esm(() => {
24572
25022
  init_tasks();
24573
25023
  init_templates();
24574
25024
  init_storage_tombstones();
25025
+ init_audit_history_import();
24575
25026
  PROJECT_COLUMNS = [
24576
25027
  "id",
24577
25028
  "name",
@@ -24836,6 +25287,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
24836
25287
  unlockTask(id, agentId, database());
24837
25288
  return true;
24838
25289
  },
25290
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
24839
25291
  delete: (id) => deleteTask(id, database()),
24840
25292
  start: (id, agentId) => startTask(id, agentId, database()),
24841
25293
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -25592,7 +26044,7 @@ function registerPlanTemplateCommands(program2) {
25592
26044
  if (!plan2) {
25593
26045
  handleError(new Error(`Plan not found: ${opts.show}`));
25594
26046
  }
25595
- const tasks2 = await cloudListTasks(cloud, { plan_id: plan2.id, include_subtasks: true });
26047
+ const tasks2 = await cloudListPlanTasks(cloud, plan2.id);
25596
26048
  if (globalOpts.json) {
25597
26049
  output({ plan: plan2, tasks: tasks2, artifact: null }, true);
25598
26050
  return;
@@ -34444,7 +34896,7 @@ var init_postgres_sync = __esm(() => {
34444
34896
  });
34445
34897
 
34446
34898
  // src/storage/postgres-adapter.ts
34447
- import { randomUUID as randomUUID3 } from "crypto";
34899
+ import { randomUUID as randomUUID4 } from "crypto";
34448
34900
  function createPostgresTodosStorageAdapter(options) {
34449
34901
  const store = new PostgresJsonRecordStore(options);
34450
34902
  const adapter = {
@@ -34473,6 +34925,7 @@ function createPostgresTodosStorageAdapter(options) {
34473
34925
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
34474
34926
  lock: (id, agentId) => lockTask2(id, agentId, store),
34475
34927
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
34928
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
34476
34929
  getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
34477
34930
  },
34478
34931
  dependencies: {
@@ -34619,6 +35072,91 @@ class PostgresJsonRecordStore {
34619
35072
  LIMIT 1`, [this.service, type, id]);
34620
35073
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
34621
35074
  }
35075
+ async handoffStaleLock(input, context = {}) {
35076
+ const prepared = prepareStaleLockHandoff(input);
35077
+ const receipt = buildStaleLockHandoffReceipt(prepared);
35078
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
35079
+ await this.ensureSchema();
35080
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
35081
+ target AS MATERIALIZED (
35082
+ SELECT payload
35083
+ FROM ${this.tableName}
35084
+ WHERE service = $1
35085
+ AND object_type = 'tasks'
35086
+ AND object_id = $2
35087
+ AND deleted_at IS NULL
35088
+ FOR UPDATE
35089
+ ),
35090
+ updated AS (
35091
+ UPDATE ${this.tableName} AS task_record
35092
+ SET payload = jsonb_set(
35093
+ jsonb_set(
35094
+ jsonb_set(
35095
+ jsonb_set(
35096
+ task_record.payload,
35097
+ '{locked_by}',
35098
+ to_jsonb($6::text),
35099
+ true
35100
+ ),
35101
+ '{locked_at}',
35102
+ to_jsonb($7::text),
35103
+ true
35104
+ ),
35105
+ '{updated_at}',
35106
+ to_jsonb($7::text),
35107
+ true
35108
+ ),
35109
+ '{version}',
35110
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
35111
+ true
35112
+ ),
35113
+ updated_at = $7::timestamptz,
35114
+ source_machine_id = $10,
35115
+ version = COALESCE(task_record.version, 0) + 1
35116
+ FROM target
35117
+ WHERE task_record.service = $1
35118
+ AND task_record.object_type = 'tasks'
35119
+ AND task_record.object_id = $2
35120
+ AND task_record.deleted_at IS NULL
35121
+ AND target.payload->>'locked_by' = $3
35122
+ AND target.payload->>'locked_at' = $4
35123
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
35124
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
35125
+ RETURNING task_record.payload
35126
+ ),
35127
+ audit AS (
35128
+ INSERT INTO ${this.tableName} (
35129
+ service, object_type, object_id, payload, updated_at,
35130
+ deleted_at, source_machine_id, version
35131
+ )
35132
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
35133
+ NULL, $10, NULL
35134
+ FROM updated
35135
+ RETURNING payload
35136
+ )
35137
+ SELECT
35138
+ (SELECT payload FROM target) AS current_payload,
35139
+ (SELECT payload FROM updated) AS updated_payload,
35140
+ (SELECT payload FROM audit) AS audit_payload`, [
35141
+ this.service,
35142
+ prepared.task_id,
35143
+ prepared.expected_holder,
35144
+ prepared.expected_lock_version,
35145
+ prepared.stale_cutoff,
35146
+ prepared.new_holder,
35147
+ prepared.operation_timestamp,
35148
+ receipt.receipt_id,
35149
+ jsonbParam(history),
35150
+ this.machineId(context)
35151
+ ]);
35152
+ const row = result.rows[0];
35153
+ if (!row?.current_payload)
35154
+ throw new TaskNotFoundError(prepared.task_id);
35155
+ if (!row.updated_payload || !row.audit_payload) {
35156
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
35157
+ }
35158
+ return receipt;
35159
+ }
34622
35160
  async list(type) {
34623
35161
  return (await this.listRecords(type)).map((record) => record.payload);
34624
35162
  }
@@ -34888,6 +35426,28 @@ class PostgresJsonRecordStore {
34888
35426
  }
34889
35427
  return value;
34890
35428
  }
35429
+ async insertImmutableAuditHistory(value, context = {}) {
35430
+ await this.ensureSchema();
35431
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
35432
+ service, object_type, object_id, payload, updated_at,
35433
+ deleted_at, source_machine_id, version
35434
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
35435
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
35436
+ RETURNING object_id`, [
35437
+ this.service,
35438
+ "audit_history",
35439
+ value.id,
35440
+ jsonbParam(value),
35441
+ value.created_at,
35442
+ context.requestId ?? this.sourceMachineId ?? null
35443
+ ]);
35444
+ if (inserted.rows.length > 0)
35445
+ return "inserted";
35446
+ const existing = await this.get("audit_history", value.id);
35447
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
35448
+ return "identical";
35449
+ throw new Error(divergentAuditHistoryReplayError(value.id));
35450
+ }
34891
35451
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
34892
35452
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
34893
35453
  if (planIds.length === 0)
@@ -35714,7 +36274,7 @@ async function createTask2(input, store, context) {
35714
36274
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
35715
36275
  const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
35716
36276
  const task = {
35717
- id: randomUUID3(),
36277
+ id: randomUUID4(),
35718
36278
  short_id: shortId,
35719
36279
  project_id: effectiveProjectId,
35720
36280
  parent_id: input.parent_id ?? null,
@@ -35960,7 +36520,7 @@ async function addVerification(input, store, context) {
35960
36520
  throw new Error(`Task not found: ${input.task_id}`);
35961
36521
  const timestamp2 = new Date().toISOString();
35962
36522
  const verification = {
35963
- id: randomUUID3(),
36523
+ id: randomUUID4(),
35964
36524
  task_id: input.task_id,
35965
36525
  command: input.command,
35966
36526
  status: input.status ?? "unknown",
@@ -35981,7 +36541,7 @@ async function addCommit(input, store, context) {
35981
36541
  throw new Error(`Task not found: ${input.task_id}`);
35982
36542
  const timestamp2 = new Date().toISOString();
35983
36543
  const commit = {
35984
- id: randomUUID3(),
36544
+ id: randomUUID4(),
35985
36545
  task_id: input.task_id,
35986
36546
  sha: input.sha,
35987
36547
  message: input.message ?? null,
@@ -36004,7 +36564,7 @@ async function addGitRef(input, store, context) {
36004
36564
  throw new Error(`Task not found: ${input.task_id}`);
36005
36565
  const timestamp2 = new Date().toISOString();
36006
36566
  const gitRef = {
36007
- id: randomUUID3(),
36567
+ id: randomUUID4(),
36008
36568
  task_id: input.task_id,
36009
36569
  ref_type: input.ref_type,
36010
36570
  name: input.name,
@@ -36074,7 +36634,7 @@ async function createProject2(input, store, context) {
36074
36634
  if (!derivedSlug || !taskListId)
36075
36635
  throw new Error("Project name and task-list slug must be non-empty");
36076
36636
  const project = {
36077
- id: randomUUID3(),
36637
+ id: randomUUID4(),
36078
36638
  name: input.name,
36079
36639
  path: input.path,
36080
36640
  description: input.description ?? null,
@@ -36106,7 +36666,7 @@ async function createPlan2(input, store, context) {
36106
36666
  store
36107
36667
  });
36108
36668
  return store.upsert("plans", {
36109
- id: randomUUID3(),
36669
+ id: randomUUID4(),
36110
36670
  slug,
36111
36671
  project_id: projectId,
36112
36672
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -36158,7 +36718,7 @@ async function registerAgent2(input, store, context) {
36158
36718
  }
36159
36719
  const timestamp2 = new Date().toISOString();
36160
36720
  const agent = {
36161
- id: existing?.id ?? randomUUID3().slice(0, 8),
36721
+ id: existing?.id ?? randomUUID4().slice(0, 8),
36162
36722
  name: canonicalName,
36163
36723
  description: input.description ?? existing?.description ?? null,
36164
36724
  role: input.role ?? existing?.role ?? null,
@@ -36231,7 +36791,7 @@ async function createTaskList2(input, store, context) {
36231
36791
  if (!slug)
36232
36792
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
36233
36793
  return store.upsert("task_lists", {
36234
- id: randomUUID3(),
36794
+ id: randomUUID4(),
36235
36795
  project_id: input.project_id ?? context?.projectId ?? null,
36236
36796
  slug,
36237
36797
  name: input.name,
@@ -36266,7 +36826,7 @@ async function updateTaskList2(id, input, store) {
36266
36826
  async function createTemplate2(input, store, context) {
36267
36827
  const timestamp2 = new Date().toISOString();
36268
36828
  const template = {
36269
- id: randomUUID3(),
36829
+ id: randomUUID4(),
36270
36830
  name: input.name,
36271
36831
  title_pattern: input.title_pattern,
36272
36832
  description: input.description ?? null,
@@ -36287,7 +36847,7 @@ async function createTemplate2(input, store, context) {
36287
36847
  }
36288
36848
  function buildTemplateTasks(templateId, inputs, timestamp2) {
36289
36849
  return inputs.map((input, position) => ({
36290
- id: randomUUID3(),
36850
+ id: randomUUID4(),
36291
36851
  template_id: templateId,
36292
36852
  position,
36293
36853
  title_pattern: input.title_pattern,
@@ -36320,7 +36880,7 @@ async function updateTemplate2(id, input, store) {
36320
36880
  }
36321
36881
  async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
36322
36882
  const entry2 = {
36323
- id: randomUUID3(),
36883
+ id: randomUUID4(),
36324
36884
  task_id: taskId,
36325
36885
  action,
36326
36886
  field: field ?? null,
@@ -36334,7 +36894,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
36334
36894
  }
36335
36895
  async function addComment2(input, store, context) {
36336
36896
  const comment = {
36337
- id: randomUUID3(),
36897
+ id: randomUUID4(),
36338
36898
  task_id: input.task_id,
36339
36899
  agent_id: input.agent_id ?? context?.agentId ?? null,
36340
36900
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -36376,6 +36936,11 @@ async function importSnapshot(snapshot, store, context) {
36376
36936
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
36377
36937
  if (result.errors.length > 0)
36378
36938
  return result;
36939
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
36940
+ result.errors.push(...auditHistory.errors);
36941
+ if (result.errors.length > 0)
36942
+ return result;
36943
+ result.skipped += auditHistory.identical;
36379
36944
  const entries = [
36380
36945
  ...snapshot.tasks.map((row) => ["tasks", row]),
36381
36946
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -36384,9 +36949,20 @@ async function importSnapshot(snapshot, store, context) {
36384
36949
  ...snapshot.agents.map((row) => ["agents", row]),
36385
36950
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
36386
36951
  ...snapshot.templates.map((row) => ["templates", row]),
36387
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
36388
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
36952
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
36389
36953
  ];
36954
+ for (const row of auditHistory.rowsToInsert) {
36955
+ try {
36956
+ const outcome = await store.insertImmutableAuditHistory(row, context);
36957
+ if (outcome === "inserted")
36958
+ result.inserted += 1;
36959
+ else
36960
+ result.skipped += 1;
36961
+ } catch (error) {
36962
+ result.errors.push(error instanceof Error ? error.message : String(error));
36963
+ return result;
36964
+ }
36965
+ }
36390
36966
  for (const [type, row] of entries) {
36391
36967
  try {
36392
36968
  const existing = await store.get(type, row.id);
@@ -36420,6 +36996,32 @@ async function importSnapshot(snapshot, store, context) {
36420
36996
  }
36421
36997
  return result;
36422
36998
  }
36999
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
37000
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
37001
+ const rowsToInsert = [];
37002
+ const seen = new Map;
37003
+ let identical = 0;
37004
+ for (const row of rows) {
37005
+ const prior = seen.get(row.id);
37006
+ if (prior) {
37007
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
37008
+ identical += 1;
37009
+ else
37010
+ errors.push(divergentAuditHistoryReplayError(row.id));
37011
+ continue;
37012
+ }
37013
+ seen.set(row.id, row);
37014
+ const existing = await store.get("audit_history", row.id);
37015
+ if (!existing) {
37016
+ rowsToInsert.push(row);
37017
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
37018
+ identical += 1;
37019
+ } else {
37020
+ errors.push(divergentAuditHistoryReplayError(row.id));
37021
+ }
37022
+ }
37023
+ return { rowsToInsert, identical, errors };
37024
+ }
36423
37025
  async function requireRecord(type, id, store) {
36424
37026
  const record = await store.get(type, id);
36425
37027
  if (!record)
@@ -36526,9 +37128,11 @@ var init_postgres_adapter = __esm(() => {
36526
37128
  init_types();
36527
37129
  init_creator_identity();
36528
37130
  init_plan_project_link_contract();
37131
+ init_stale_lock_handoff();
36529
37132
  init_postgres_sync();
36530
37133
  init_integrity();
36531
37134
  init_redaction();
37135
+ init_audit_history_import();
36532
37136
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
36533
37137
  });
36534
37138
 
@@ -46532,6 +47136,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
46532
47136
  ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
46533
47137
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
46534
47138
  TaskComment: taskCommentSchema,
47139
+ StaleLockHandoffInput: staleLockHandoffInputSchema,
47140
+ StaleLockHandoffReceipt: staleLockHandoffReceiptSchema,
46535
47141
  TaskGitRef: taskGitRefSchema,
46536
47142
  Plan: planSchema,
46537
47143
  PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
@@ -47739,6 +48345,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
47739
48345
  }
47740
48346
  }
47741
48347
  },
48348
+ "/v1/tasks/{id}/stale-lock-handoff": {
48349
+ post: {
48350
+ operationId: "handoffStaleTaskLock",
48351
+ summary: "Atomically transfer one exact stale task lock",
48352
+ 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.",
48353
+ parameters: [
48354
+ {
48355
+ name: "id",
48356
+ in: "path",
48357
+ required: true,
48358
+ schema: { type: "string", format: "uuid" },
48359
+ description: "Exact full task UUID. Short ids and prefixes are rejected."
48360
+ }
48361
+ ],
48362
+ requestBody: {
48363
+ required: true,
48364
+ content: {
48365
+ "application/json": {
48366
+ schema: { $ref: "#/components/schemas/StaleLockHandoffInput" }
48367
+ }
48368
+ }
48369
+ },
48370
+ responses: {
48371
+ "200": {
48372
+ content: {
48373
+ "application/json": {
48374
+ schema: {
48375
+ type: "object",
48376
+ additionalProperties: false,
48377
+ required: ["receipt"],
48378
+ properties: {
48379
+ receipt: { $ref: "#/components/schemas/StaleLockHandoffReceipt" }
48380
+ }
48381
+ }
48382
+ }
48383
+ }
48384
+ },
48385
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
48386
+ "403": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
48387
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
48388
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
48389
+ "501": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
48390
+ }
48391
+ }
48392
+ },
47742
48393
  "/v1/tasks/{id}/refs": {
47743
48394
  get: {
47744
48395
  operationId: "listTaskGitRefs",
@@ -48335,7 +48986,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
48335
48986
  }
48336
48987
  };
48337
48988
  }
48338
- var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
48989
+ var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, staleLockHandoffInputSchema, staleLockHandoffReceiptSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
48339
48990
  var init_openapi = __esm(() => {
48340
48991
  init_package_version();
48341
48992
  init_types();
@@ -48354,6 +49005,8 @@ var init_openapi = __esm(() => {
48354
49005
  reason: { type: "string", nullable: true },
48355
49006
  tags: { type: "array", items: { type: "string" } },
48356
49007
  version: { type: "number" },
49008
+ locked_by: { type: "string", nullable: true },
49009
+ locked_at: { type: "string", format: "date-time", nullable: true },
48357
49010
  created_at: { type: "string" },
48358
49011
  updated_at: { type: "string" }
48359
49012
  }
@@ -48518,6 +49171,69 @@ var init_openapi = __esm(() => {
48518
49171
  created_at: { type: "string", format: "date-time" }
48519
49172
  }
48520
49173
  };
49174
+ staleLockHandoffInputSchema = {
49175
+ type: "object",
49176
+ additionalProperties: false,
49177
+ required: [
49178
+ "expected_holder",
49179
+ "expected_lock_version",
49180
+ "stale_after_seconds",
49181
+ "new_holder",
49182
+ "reason"
49183
+ ],
49184
+ properties: {
49185
+ expected_holder: { type: "string", minLength: 1 },
49186
+ expected_lock_version: {
49187
+ type: "string",
49188
+ format: "date-time",
49189
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
49190
+ description: "Exact authoritative locked_at token read from the task; no default or normalization is applied."
49191
+ },
49192
+ stale_after_seconds: {
49193
+ type: "integer",
49194
+ minimum: 1,
49195
+ description: "Lock age threshold supplied by the caller. The lock must be strictly older at the CAS instant."
49196
+ },
49197
+ new_holder: {
49198
+ type: "string",
49199
+ minLength: 1,
49200
+ description: "Must match the agent bound to the authenticated API key."
49201
+ },
49202
+ reason: { type: "string", minLength: 1, maxLength: 4096 }
49203
+ }
49204
+ };
49205
+ staleLockHandoffReceiptSchema = {
49206
+ type: "object",
49207
+ additionalProperties: false,
49208
+ required: [
49209
+ "schema_version",
49210
+ "receipt_id",
49211
+ "task_id",
49212
+ "actor",
49213
+ "previous_holder",
49214
+ "previous_lock_version",
49215
+ "new_holder",
49216
+ "new_lock_version",
49217
+ "stale_after_seconds",
49218
+ "stale_cutoff",
49219
+ "reason",
49220
+ "created_at"
49221
+ ],
49222
+ properties: {
49223
+ schema_version: { type: "string", enum: ["todos.stale-lock-handoff.v1"] },
49224
+ receipt_id: { type: "string", format: "uuid" },
49225
+ task_id: { type: "string", format: "uuid" },
49226
+ actor: { type: "string" },
49227
+ previous_holder: { type: "string" },
49228
+ previous_lock_version: { type: "string", format: "date-time" },
49229
+ new_holder: { type: "string" },
49230
+ new_lock_version: { type: "string", format: "date-time" },
49231
+ stale_after_seconds: { type: "integer", minimum: 1 },
49232
+ stale_cutoff: { type: "string", format: "date-time" },
49233
+ reason: { type: "string" },
49234
+ created_at: { type: "string", format: "date-time" }
49235
+ }
49236
+ };
48521
49237
  taskGitRefSchema = {
48522
49238
  type: "object",
48523
49239
  additionalProperties: false,
@@ -49412,6 +50128,45 @@ async function handleV1Request(req, url, dependencies = {}) {
49412
50128
  return error(405, `method ${method} not allowed on /v1/tasks`);
49413
50129
  }
49414
50130
  if (action) {
50131
+ if (action === "stale-lock-handoff") {
50132
+ if (method !== "POST") {
50133
+ return error(405, "method must be POST on /v1/tasks/:id/stale-lock-handoff");
50134
+ }
50135
+ const exactId = normalizeExactTaskId(id);
50136
+ if (!principal.agent) {
50137
+ return error(403, "stale-lock handoff requires an authenticated agent-bound key", {
50138
+ code: "STALE_LOCK_HANDOFF_ACTOR_MISMATCH"
50139
+ });
50140
+ }
50141
+ if (typeof store.tasks.handoffStaleLock !== "function") {
50142
+ return error(501, "stale-lock handoff is not supported by this storage backend");
50143
+ }
50144
+ const body3 = await readJson3(req) ?? {};
50145
+ const allowed = new Set([
50146
+ "expected_holder",
50147
+ "expected_lock_version",
50148
+ "stale_after_seconds",
50149
+ "new_holder",
50150
+ "reason"
50151
+ ]);
50152
+ const unknown = Object.keys(body3).find((key2) => !allowed.has(key2));
50153
+ if (unknown) {
50154
+ return error(400, `unknown stale-lock handoff field: ${unknown}`, {
50155
+ code: "STALE_LOCK_HANDOFF_INVALID_INPUT",
50156
+ field: unknown
50157
+ });
50158
+ }
50159
+ const receipt = await store.tasks.handoffStaleLock({
50160
+ task_id: exactId,
50161
+ actor: principal.agent,
50162
+ expected_holder: body3.expected_holder,
50163
+ expected_lock_version: body3.expected_lock_version,
50164
+ stale_after_seconds: body3.stale_after_seconds,
50165
+ new_holder: body3.new_holder,
50166
+ reason: body3.reason
50167
+ }, contextFromPrincipal(principal));
50168
+ return json5({ receipt });
50169
+ }
49415
50170
  if (action === "comments") {
49416
50171
  if (method === "GET") {
49417
50172
  if (!await store.tasks.get(id))
@@ -50200,7 +50955,24 @@ async function handleV1Request(req, url, dependencies = {}) {
50200
50955
  if (received === 0) {
50201
50956
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
50202
50957
  }
50958
+ const forbiddenAuditTombstone = (snapshot.tombstones ?? []).find((tombstone) => tombstone.object_type === "audit_history");
50959
+ if (forbiddenAuditTombstone) {
50960
+ return error(400, forbiddenAuditHistoryTombstoneError(forbiddenAuditTombstone.object_id), {
50961
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
50962
+ conflict: false,
50963
+ audit_history_id: forbiddenAuditTombstone.object_id
50964
+ });
50965
+ }
50203
50966
  const result = await store.sync.importSnapshot(snapshot, contextFromPrincipal(principal));
50967
+ const auditFailureMessage = result.errors.find((message) => parseAuditHistoryImportFailure(message) !== null);
50968
+ if (auditFailureMessage) {
50969
+ const failure = parseAuditHistoryImportFailure(auditFailureMessage);
50970
+ return error(failure.status, auditFailureMessage, {
50971
+ code: failure.code,
50972
+ conflict: failure.conflict,
50973
+ audit_history_id: failure.auditHistoryId
50974
+ });
50975
+ }
50204
50976
  return json5({ result, received });
50205
50977
  }
50206
50978
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
@@ -50223,6 +50995,14 @@ async function handleV1Request(req, url, dependencies = {}) {
50223
50995
  if (e instanceof TaskNotFoundError) {
50224
50996
  return error(404, e.message, { code: TaskNotFoundError.code });
50225
50997
  }
50998
+ if (e instanceof StaleLockHandoffError) {
50999
+ 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;
51000
+ return error(status2, e.message, {
51001
+ code: e.code,
51002
+ conflict: status2 === 409,
51003
+ ...e.details
51004
+ });
51005
+ }
50226
51006
  if (e instanceof PlanNotFoundError) {
50227
51007
  return error(404, e.message, { code: PlanNotFoundError.code });
50228
51008
  }
@@ -50257,6 +51037,8 @@ var init_v1 = __esm(() => {
50257
51037
  init_redaction();
50258
51038
  init_project_task_list_ensure();
50259
51039
  init_plan_project_link();
51040
+ init_stale_lock_handoff();
51041
+ init_audit_history_import();
50260
51042
  JSON_HEADERS4 = { "Content-Type": "application/json" };
50261
51043
  RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
50262
51044
  });
@@ -75778,8 +76560,29 @@ No task claimed (nothing available).`));
75778
76560
  handleError(new Error("Failed to assign"));
75779
76561
  }
75780
76562
  });
75781
- program2.command("unassign <id>").description("Remove task assignment").option("-j, --json", "Output as JSON").action((id, opts) => {
76563
+ program2.command("unassign <id>").description("Remove task assignment").option("-j, --json", "Output as JSON").action(async (id, opts) => {
75782
76564
  const globalOpts = program2.opts();
76565
+ const cloud = getTodosCloudClient();
76566
+ if (cloud) {
76567
+ try {
76568
+ const resolvedId2 = await resolveTaskIdForCommand(id, cloud);
76569
+ const task4 = await cloudGetTask(cloud, resolvedId2);
76570
+ if (!task4) {
76571
+ throw new Error(`Task not found: ${id}`);
76572
+ }
76573
+ const updated = await cloudUpdateTask(cloud, resolvedId2, {
76574
+ assigned_to: null
76575
+ });
76576
+ if (opts.json || globalOpts.json) {
76577
+ console.log(JSON.stringify(updated));
76578
+ return;
76579
+ }
76580
+ console.log(chalk9.green(`Unassigned: ${formatTaskLine(updated)}`));
76581
+ } catch (error2) {
76582
+ handleError(error2);
76583
+ }
76584
+ return;
76585
+ }
75783
76586
  const resolvedId = resolveTaskId(id);
75784
76587
  const db = getDatabase();
75785
76588
  const task3 = getTask(resolvedId, db);
@@ -88241,6 +89044,7 @@ program2.name("todos").description("Universal task management for AI coding agen
88241
89044
  var authority;
88242
89045
  try {
88243
89046
  authority = initializeTodosCliAuthority();
89047
+ applyTodosCliAuthorityEnvironment(authority);
88244
89048
  } catch (error2) {
88245
89049
  console.error(error2 instanceof Error ? error2.message : String(error2));
88246
89050
  process.exit(1);