@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/index.js CHANGED
@@ -3662,7 +3662,7 @@ function isBlockingDependencyStatus(status) {
3662
3662
  function isTerminalStatus(status) {
3663
3663
  return status === "completed" || status === "failed" || status === "cancelled";
3664
3664
  }
3665
- 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;
3665
+ 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;
3666
3666
  var init_types = __esm(() => {
3667
3667
  TASK_STATUSES = [
3668
3668
  "pending",
@@ -3785,6 +3785,16 @@ var init_types = __esm(() => {
3785
3785
  this.name = "LockError";
3786
3786
  }
3787
3787
  };
3788
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
3789
+ code;
3790
+ details;
3791
+ constructor(code, message, details = {}) {
3792
+ super(message);
3793
+ this.code = code;
3794
+ this.details = details;
3795
+ this.name = "StaleLockHandoffError";
3796
+ }
3797
+ };
3788
3798
  AgentNotFoundError = class AgentNotFoundError extends Error {
3789
3799
  agentId;
3790
3800
  static code = "AGENT_NOT_FOUND";
@@ -7503,28 +7513,55 @@ var init_activity_audit = __esm(() => {
7503
7513
  function sanitizeHistoryValue(value, context) {
7504
7514
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
7505
7515
  }
7506
- function logTaskChange(taskId, action, field2, oldValue, newValue, agentId, db) {
7516
+ function insertTaskHistory(entry, db) {
7507
7517
  const d = db || getDatabase();
7508
- const id = uuid();
7509
- const timestamp2 = now();
7510
- const machineId = currentStorageMachineId(d);
7511
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
7512
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
7518
+ const safeEntry = {
7519
+ ...entry,
7520
+ field: entry.field || null,
7521
+ old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
7522
+ new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
7523
+ agent_id: entry.agent_id || null,
7524
+ machine_id: entry.machine_id ?? currentStorageMachineId(d)
7525
+ };
7513
7526
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
7514
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field2 || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
7527
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
7528
+ safeEntry.id,
7529
+ safeEntry.task_id,
7530
+ safeEntry.action,
7531
+ safeEntry.field,
7532
+ safeEntry.old_value,
7533
+ safeEntry.new_value,
7534
+ safeEntry.agent_id,
7535
+ safeEntry.created_at,
7536
+ safeEntry.machine_id ?? null
7537
+ ]);
7515
7538
  try {
7516
7539
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
7517
7540
  logActivity2({
7518
7541
  entity_type: "task",
7519
- entity_id: taskId,
7520
- action,
7521
- field: field2,
7522
- old_value: safeOldValue,
7523
- new_value: safeNewValue,
7524
- actor_id: agentId ?? undefined
7542
+ entity_id: safeEntry.task_id,
7543
+ action: safeEntry.action,
7544
+ field: safeEntry.field ?? undefined,
7545
+ old_value: safeEntry.old_value,
7546
+ new_value: safeEntry.new_value,
7547
+ actor_id: safeEntry.agent_id ?? undefined
7525
7548
  }, d);
7526
7549
  } catch {}
7527
- return { id, task_id: taskId, action, field: field2 || null, old_value: safeOldValue, new_value: safeNewValue, agent_id: agentId || null, created_at: timestamp2, machine_id: machineId };
7550
+ return safeEntry;
7551
+ }
7552
+ function logTaskChange(taskId, action, field2, oldValue, newValue, agentId, db) {
7553
+ const d = db || getDatabase();
7554
+ return insertTaskHistory({
7555
+ id: uuid(),
7556
+ task_id: taskId,
7557
+ action,
7558
+ field: field2 || null,
7559
+ old_value: oldValue ?? null,
7560
+ new_value: newValue ?? null,
7561
+ agent_id: agentId || null,
7562
+ created_at: now(),
7563
+ machine_id: currentStorageMachineId(d)
7564
+ }, d);
7528
7565
  }
7529
7566
  function getTaskHistory(taskId, db) {
7530
7567
  const d = db || getDatabase();
@@ -8547,6 +8584,145 @@ var init_task_graph = __esm(() => {
8547
8584
  init_task_crud();
8548
8585
  });
8549
8586
 
8587
+ // src/lib/stale-lock-handoff.ts
8588
+ function normalizeExactTaskId(value) {
8589
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
8590
+ 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 });
8591
+ }
8592
+ return value.trim().toLowerCase();
8593
+ }
8594
+ function requireNonEmptyString(value, field2) {
8595
+ if (typeof value !== "string" || !value.trim()) {
8596
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field2} must be a non-empty string`, { field: field2 });
8597
+ }
8598
+ const trimmed = value.trim();
8599
+ if (field2 === "reason" && trimmed.length > MAX_REASON_LENGTH) {
8600
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field: field2, max_length: MAX_REASON_LENGTH });
8601
+ }
8602
+ return trimmed;
8603
+ }
8604
+ function requireCanonicalLockVersion(value) {
8605
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
8606
+ 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" });
8607
+ }
8608
+ const parsed = Date.parse(value);
8609
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
8610
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
8611
+ }
8612
+ return value;
8613
+ }
8614
+ function requireStaleThreshold(value) {
8615
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
8616
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
8617
+ }
8618
+ return Number(value);
8619
+ }
8620
+ function prepareStaleLockHandoff(input, options = {}) {
8621
+ const taskId = normalizeExactTaskId(input.task_id);
8622
+ const actor = requireNonEmptyString(input.actor, "actor");
8623
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
8624
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
8625
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
8626
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
8627
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
8628
+ if (!reason) {
8629
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
8630
+ }
8631
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
8632
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
8633
+ }
8634
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
8635
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
8636
+ }
8637
+ const operationTimestamp = options.now ?? new Date().toISOString();
8638
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
8639
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
8640
+ }
8641
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
8642
+ return {
8643
+ task_id: taskId,
8644
+ actor,
8645
+ expected_holder: expectedHolder,
8646
+ expected_lock_version: expectedLockVersion,
8647
+ stale_after_seconds: staleAfterSeconds,
8648
+ new_holder: newHolder,
8649
+ reason,
8650
+ operation_timestamp: operationTimestamp,
8651
+ stale_cutoff: staleCutoff,
8652
+ receipt_id: options.receiptId ?? crypto.randomUUID()
8653
+ };
8654
+ }
8655
+ function buildStaleLockHandoffReceipt(input) {
8656
+ return {
8657
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
8658
+ receipt_id: input.receipt_id,
8659
+ task_id: input.task_id,
8660
+ actor: input.actor,
8661
+ previous_holder: input.expected_holder,
8662
+ previous_lock_version: input.expected_lock_version,
8663
+ new_holder: input.new_holder,
8664
+ new_lock_version: input.operation_timestamp,
8665
+ stale_after_seconds: input.stale_after_seconds,
8666
+ stale_cutoff: input.stale_cutoff,
8667
+ reason: input.reason,
8668
+ created_at: input.operation_timestamp
8669
+ };
8670
+ }
8671
+ function staleLockHandoffHistory(receipt, machineId) {
8672
+ return {
8673
+ id: receipt.receipt_id,
8674
+ task_id: receipt.task_id,
8675
+ action: STALE_LOCK_HANDOFF_ACTION,
8676
+ field: STALE_LOCK_HANDOFF_FIELD,
8677
+ old_value: JSON.stringify({
8678
+ holder: receipt.previous_holder,
8679
+ lock_version: receipt.previous_lock_version
8680
+ }),
8681
+ new_value: JSON.stringify(receipt),
8682
+ agent_id: receipt.actor,
8683
+ created_at: receipt.created_at,
8684
+ machine_id: machineId
8685
+ };
8686
+ }
8687
+ function throwStaleLockHandoffConflict(task, input) {
8688
+ if (!task.locked_by || !task.locked_at) {
8689
+ 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 });
8690
+ }
8691
+ if (task.locked_at !== input.expected_lock_version) {
8692
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
8693
+ task_id: input.task_id,
8694
+ expected_lock_version: input.expected_lock_version,
8695
+ current_lock_version: task.locked_at
8696
+ });
8697
+ }
8698
+ if (task.locked_by !== input.expected_holder) {
8699
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
8700
+ task_id: input.task_id,
8701
+ expected_holder: input.expected_holder,
8702
+ current_holder: task.locked_by
8703
+ });
8704
+ }
8705
+ if (isTerminalStatus(task.status)) {
8706
+ 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 });
8707
+ }
8708
+ if (task.locked_at >= input.stale_cutoff) {
8709
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
8710
+ task_id: input.task_id,
8711
+ current_lock_version: task.locked_at,
8712
+ stale_cutoff: input.stale_cutoff
8713
+ });
8714
+ }
8715
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
8716
+ }
8717
+ 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;
8718
+ var init_stale_lock_handoff = __esm(() => {
8719
+ init_types();
8720
+ init_creator_identity();
8721
+ init_prewrite_secrets();
8722
+ 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;
8723
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
8724
+ });
8725
+
8550
8726
  // src/db/task-lifecycle.ts
8551
8727
  var exports_task_lifecycle = {};
8552
8728
  __export(exports_task_lifecycle, {
@@ -8555,6 +8731,7 @@ __export(exports_task_lifecycle, {
8555
8731
  startTask: () => startTask,
8556
8732
  spawnNextRecurrence: () => spawnNextRecurrence,
8557
8733
  lockTask: () => lockTask,
8734
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
8558
8735
  getTasksChangedSince: () => getTasksChangedSince,
8559
8736
  getTaskLockStatus: () => getTaskLockStatus,
8560
8737
  getStaleTasks: () => getStaleTasks,
@@ -8823,6 +9000,38 @@ function unlockTask(id, agentId, db) {
8823
9000
  WHERE id = ?`, [timestamp2, id]);
8824
9001
  return true;
8825
9002
  }
9003
+ function handoffStaleTaskLock(input, db) {
9004
+ const d = db || getDatabase();
9005
+ const prepared = prepareStaleLockHandoff(input);
9006
+ const receipt = buildStaleLockHandoffReceipt(prepared);
9007
+ const history = staleLockHandoffHistory(receipt, null);
9008
+ const transfer = d.transaction(() => {
9009
+ const result = d.run(`UPDATE tasks
9010
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
9011
+ WHERE id = ?
9012
+ AND locked_by = ?
9013
+ AND locked_at = ?
9014
+ AND julianday(locked_at) < julianday(?)
9015
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
9016
+ prepared.new_holder,
9017
+ prepared.operation_timestamp,
9018
+ prepared.operation_timestamp,
9019
+ prepared.task_id,
9020
+ prepared.expected_holder,
9021
+ prepared.expected_lock_version,
9022
+ prepared.stale_cutoff
9023
+ ]);
9024
+ if (result.changes === 0) {
9025
+ const current = getTask(prepared.task_id, d);
9026
+ if (!current)
9027
+ throw new TaskNotFoundError(prepared.task_id);
9028
+ throwStaleLockHandoffConflict(current, prepared);
9029
+ }
9030
+ insertTaskHistory(history, d);
9031
+ });
9032
+ transfer();
9033
+ return receipt;
9034
+ }
8826
9035
  function getTaskLockStatus(id, db) {
8827
9036
  const d = db || getDatabase();
8828
9037
  const task = getTask(id, d);
@@ -9123,6 +9332,7 @@ var init_task_lifecycle = __esm(() => {
9123
9332
  init_task_crud();
9124
9333
  init_task_graph();
9125
9334
  init_prewrite_secrets();
9335
+ init_stale_lock_handoff();
9126
9336
  });
9127
9337
 
9128
9338
  // src/db/task-crud.ts
@@ -12548,7 +12758,7 @@ var init_dispatches = __esm(() => {
12548
12758
  // package.json
12549
12759
  var package_default = {
12550
12760
  name: "@hasna/todos",
12551
- version: "0.15.19",
12761
+ version: "0.15.20",
12552
12762
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12553
12763
  type: "module",
12554
12764
  main: "dist/index.js",
@@ -12619,6 +12829,8 @@ var package_default = {
12619
12829
  "dev:mcp": "bun run src/mcp/index.ts",
12620
12830
  "dev:serve": "bun run src/server/index.ts",
12621
12831
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
12832
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
12833
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
12622
12834
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
12623
12835
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
12624
12836
  },
@@ -25619,6 +25831,36 @@ init_task_lists();
25619
25831
  init_tasks();
25620
25832
  init_templates();
25621
25833
  init_storage_tombstones();
25834
+
25835
+ // src/storage/audit-history-import.ts
25836
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
25837
+ var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
25838
+ var AUDIT_HISTORY_FIELDS = [
25839
+ "id",
25840
+ "task_id",
25841
+ "action",
25842
+ "field",
25843
+ "old_value",
25844
+ "new_value",
25845
+ "agent_id",
25846
+ "created_at",
25847
+ "machine_id"
25848
+ ];
25849
+ function auditHistoryRowsAreFieldIdentical(left, right) {
25850
+ return AUDIT_HISTORY_FIELDS.every((field2) => {
25851
+ const leftValue = field2 === "machine_id" ? left[field2] ?? null : left[field2];
25852
+ const rightValue = field2 === "machine_id" ? right[field2] ?? null : right[field2];
25853
+ return leftValue === rightValue;
25854
+ });
25855
+ }
25856
+ function divergentAuditHistoryReplayError(id) {
25857
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
25858
+ }
25859
+ function forbiddenAuditHistoryTombstoneError(id) {
25860
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
25861
+ }
25862
+
25863
+ // src/storage/sqlite-snapshot.ts
25622
25864
  var PROJECT_COLUMNS = [
25623
25865
  "id",
25624
25866
  "name",
@@ -25819,8 +26061,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25819
26061
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
25820
26062
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
25821
26063
  }
26064
+ const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
26065
+ result.errors.push(...auditImport.errors);
25822
26066
  if (result.errors.length > 0)
25823
26067
  return result;
26068
+ result.skipped += auditImport.identicalReplayCount;
25824
26069
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
25825
26070
  for (const row of rows) {
25826
26071
  try {
@@ -25855,10 +26100,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25855
26100
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
25856
26101
  }
25857
26102
  });
25858
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
26103
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
25859
26104
  applyTombstones(d, snapshot.tombstones ?? [], result);
25860
26105
  return result;
25861
26106
  }
26107
+ function preflightAuditHistoryImport(db, rows, tombstones) {
26108
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
26109
+ const rowsToInsert = [];
26110
+ const seen = new Map;
26111
+ let identicalReplayCount = 0;
26112
+ for (const rawRow of rows) {
26113
+ try {
26114
+ const row = asRecord2(rawRow);
26115
+ if (typeof row.id !== "string" || !row.id) {
26116
+ throw new Error("task_history row is missing id");
26117
+ }
26118
+ const prior = seen.get(row.id);
26119
+ if (prior) {
26120
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
26121
+ identicalReplayCount += 1;
26122
+ else
26123
+ errors.push(divergentAuditHistoryReplayError(row.id));
26124
+ continue;
26125
+ }
26126
+ seen.set(row.id, row);
26127
+ const existing = getAuditHistoryById(db, row.id);
26128
+ if (!existing) {
26129
+ rowsToInsert.push(row);
26130
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
26131
+ identicalReplayCount += 1;
26132
+ } else {
26133
+ errors.push(divergentAuditHistoryReplayError(row.id));
26134
+ }
26135
+ } catch (error) {
26136
+ errors.push(error instanceof Error ? error.message : String(error));
26137
+ }
26138
+ }
26139
+ return { rowsToInsert, identicalReplayCount, errors };
26140
+ }
26141
+ function insertAuditHistoryRows(db, rows, result) {
26142
+ for (const rawRow of rows) {
26143
+ try {
26144
+ const row = asRecord2(rawRow);
26145
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
26146
+ if (!presentColumns.includes("id"))
26147
+ presentColumns.unshift("id");
26148
+ const placeholders = presentColumns.map(() => "?").join(", ");
26149
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
26150
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
26151
+ if (changes > 0) {
26152
+ result.inserted += 1;
26153
+ continue;
26154
+ }
26155
+ const existing = getAuditHistoryById(db, String(row["id"]));
26156
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
26157
+ result.skipped += 1;
26158
+ } else {
26159
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
26160
+ }
26161
+ } catch (error) {
26162
+ result.errors.push(error instanceof Error ? error.message : String(error));
26163
+ }
26164
+ }
26165
+ }
26166
+ function getAuditHistoryById(db, id) {
26167
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
26168
+ }
25862
26169
  function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
25863
26170
  const id = row["id"];
25864
26171
  if (typeof id !== "string" || !id)
@@ -25956,7 +26263,7 @@ function tableForTombstone(objectType) {
25956
26263
  return "task_templates";
25957
26264
  if (objectType === "template_tasks")
25958
26265
  return "template_tasks";
25959
- return "task_history";
26266
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
25960
26267
  }
25961
26268
  function listRows(db, table, columns) {
25962
26269
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -26088,6 +26395,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
26088
26395
  unlockTask(id, agentId, database());
26089
26396
  return true;
26090
26397
  },
26398
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
26091
26399
  delete: (id) => deleteTask(id, database()),
26092
26400
  start: (id, agentId) => startTask(id, agentId, database()),
26093
26401
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -26185,6 +26493,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
26185
26493
  init_types();
26186
26494
  import { randomUUID as randomUUID3 } from "crypto";
26187
26495
  init_creator_identity();
26496
+ init_stale_lock_handoff();
26188
26497
 
26189
26498
  // src/storage/postgres-sync.ts
26190
26499
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
@@ -26620,6 +26929,7 @@ function createPostgresTodosStorageAdapter(options) {
26620
26929
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
26621
26930
  lock: (id, agentId) => lockTask2(id, agentId, store),
26622
26931
  unlock: (id, agentId) => unlockTask2(id, agentId, store),
26932
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
26623
26933
  getByFingerprint: (fingerprint2) => store.getTaskByFingerprint(fingerprint2)
26624
26934
  },
26625
26935
  dependencies: {
@@ -26766,6 +27076,91 @@ class PostgresJsonRecordStore {
26766
27076
  LIMIT 1`, [this.service, type, id]);
26767
27077
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
26768
27078
  }
27079
+ async handoffStaleLock(input, context = {}) {
27080
+ const prepared = prepareStaleLockHandoff(input);
27081
+ const receipt = buildStaleLockHandoffReceipt(prepared);
27082
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
27083
+ await this.ensureSchema();
27084
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
27085
+ target AS MATERIALIZED (
27086
+ SELECT payload
27087
+ FROM ${this.tableName}
27088
+ WHERE service = $1
27089
+ AND object_type = 'tasks'
27090
+ AND object_id = $2
27091
+ AND deleted_at IS NULL
27092
+ FOR UPDATE
27093
+ ),
27094
+ updated AS (
27095
+ UPDATE ${this.tableName} AS task_record
27096
+ SET payload = jsonb_set(
27097
+ jsonb_set(
27098
+ jsonb_set(
27099
+ jsonb_set(
27100
+ task_record.payload,
27101
+ '{locked_by}',
27102
+ to_jsonb($6::text),
27103
+ true
27104
+ ),
27105
+ '{locked_at}',
27106
+ to_jsonb($7::text),
27107
+ true
27108
+ ),
27109
+ '{updated_at}',
27110
+ to_jsonb($7::text),
27111
+ true
27112
+ ),
27113
+ '{version}',
27114
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
27115
+ true
27116
+ ),
27117
+ updated_at = $7::timestamptz,
27118
+ source_machine_id = $10,
27119
+ version = COALESCE(task_record.version, 0) + 1
27120
+ FROM target
27121
+ WHERE task_record.service = $1
27122
+ AND task_record.object_type = 'tasks'
27123
+ AND task_record.object_id = $2
27124
+ AND task_record.deleted_at IS NULL
27125
+ AND target.payload->>'locked_by' = $3
27126
+ AND target.payload->>'locked_at' = $4
27127
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
27128
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
27129
+ RETURNING task_record.payload
27130
+ ),
27131
+ audit AS (
27132
+ INSERT INTO ${this.tableName} (
27133
+ service, object_type, object_id, payload, updated_at,
27134
+ deleted_at, source_machine_id, version
27135
+ )
27136
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
27137
+ NULL, $10, NULL
27138
+ FROM updated
27139
+ RETURNING payload
27140
+ )
27141
+ SELECT
27142
+ (SELECT payload FROM target) AS current_payload,
27143
+ (SELECT payload FROM updated) AS updated_payload,
27144
+ (SELECT payload FROM audit) AS audit_payload`, [
27145
+ this.service,
27146
+ prepared.task_id,
27147
+ prepared.expected_holder,
27148
+ prepared.expected_lock_version,
27149
+ prepared.stale_cutoff,
27150
+ prepared.new_holder,
27151
+ prepared.operation_timestamp,
27152
+ receipt.receipt_id,
27153
+ jsonbParam(history),
27154
+ this.machineId(context)
27155
+ ]);
27156
+ const row = result.rows[0];
27157
+ if (!row?.current_payload)
27158
+ throw new TaskNotFoundError(prepared.task_id);
27159
+ if (!row.updated_payload || !row.audit_payload) {
27160
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
27161
+ }
27162
+ return receipt;
27163
+ }
26769
27164
  async list(type) {
26770
27165
  return (await this.listRecords(type)).map((record) => record.payload);
26771
27166
  }
@@ -27035,6 +27430,28 @@ class PostgresJsonRecordStore {
27035
27430
  }
27036
27431
  return value;
27037
27432
  }
27433
+ async insertImmutableAuditHistory(value, context = {}) {
27434
+ await this.ensureSchema();
27435
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
27436
+ service, object_type, object_id, payload, updated_at,
27437
+ deleted_at, source_machine_id, version
27438
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
27439
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
27440
+ RETURNING object_id`, [
27441
+ this.service,
27442
+ "audit_history",
27443
+ value.id,
27444
+ jsonbParam(value),
27445
+ value.created_at,
27446
+ context.requestId ?? this.sourceMachineId ?? null
27447
+ ]);
27448
+ if (inserted.rows.length > 0)
27449
+ return "inserted";
27450
+ const existing = await this.get("audit_history", value.id);
27451
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
27452
+ return "identical";
27453
+ throw new Error(divergentAuditHistoryReplayError(value.id));
27454
+ }
27038
27455
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
27039
27456
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
27040
27457
  if (planIds.length === 0)
@@ -28526,6 +28943,11 @@ async function importSnapshot(snapshot, store, context) {
28526
28943
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
28527
28944
  if (result.errors.length > 0)
28528
28945
  return result;
28946
+ const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
28947
+ result.errors.push(...auditHistory.errors);
28948
+ if (result.errors.length > 0)
28949
+ return result;
28950
+ result.skipped += auditHistory.identical;
28529
28951
  const entries = [
28530
28952
  ...snapshot.tasks.map((row) => ["tasks", row]),
28531
28953
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -28534,9 +28956,20 @@ async function importSnapshot(snapshot, store, context) {
28534
28956
  ...snapshot.agents.map((row) => ["agents", row]),
28535
28957
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
28536
28958
  ...snapshot.templates.map((row) => ["templates", row]),
28537
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
28538
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
28959
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
28539
28960
  ];
28961
+ for (const row of auditHistory.rowsToInsert) {
28962
+ try {
28963
+ const outcome = await store.insertImmutableAuditHistory(row, context);
28964
+ if (outcome === "inserted")
28965
+ result.inserted += 1;
28966
+ else
28967
+ result.skipped += 1;
28968
+ } catch (error) {
28969
+ result.errors.push(error instanceof Error ? error.message : String(error));
28970
+ return result;
28971
+ }
28972
+ }
28540
28973
  for (const [type, row] of entries) {
28541
28974
  try {
28542
28975
  const existing = await store.get(type, row.id);
@@ -28570,6 +29003,32 @@ async function importSnapshot(snapshot, store, context) {
28570
29003
  }
28571
29004
  return result;
28572
29005
  }
29006
+ async function preflightAuditHistoryImport2(rows, tombstones, store) {
29007
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
29008
+ const rowsToInsert = [];
29009
+ const seen = new Map;
29010
+ let identical = 0;
29011
+ for (const row of rows) {
29012
+ const prior = seen.get(row.id);
29013
+ if (prior) {
29014
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
29015
+ identical += 1;
29016
+ else
29017
+ errors.push(divergentAuditHistoryReplayError(row.id));
29018
+ continue;
29019
+ }
29020
+ seen.set(row.id, row);
29021
+ const existing = await store.get("audit_history", row.id);
29022
+ if (!existing) {
29023
+ rowsToInsert.push(row);
29024
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
29025
+ identical += 1;
29026
+ } else {
29027
+ errors.push(divergentAuditHistoryReplayError(row.id));
29028
+ }
29029
+ }
29030
+ return { rowsToInsert, identical, errors };
29031
+ }
28573
29032
  async function requireRecord(type, id, store) {
28574
29033
  const record = await store.get(type, id);
28575
29034
  if (!record)
@@ -36,8 +36,9 @@ export interface CreateCliManualOptions {
36
36
  /**
37
37
  * Predicate deciding whether a top-level command is advertised in the manual.
38
38
  * Defaults to advertising everything (local route). In a remote route the
39
- * caller passes the authority visibility predicate so the manual never
40
- * documents commands the CLI will reject at runtime.
39
+ * caller passes the authority visibility predicate so the manual describes
40
+ * the shared authority surface; explicitly named local-only commands select a
41
+ * separate local route.
41
42
  */
42
43
  isCommandVisible?: (topLevelCommand: string) => boolean;
43
44
  /** Whether the resolved route serves entirely from local state. */
@@ -1 +1 @@
1
- {"version":3,"file":"cli-help.d.ts","sourceRoot":"","sources":["../../src/lib/cli-help.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAEjD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,iBAAiB,EAAE,eAAe,EAAE,CAAC;IACrC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACjD,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AAED,eAAO,MAAM,iBAAiB,EAAE,eAAe,EAA4B,CAAC;AAoC5E,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,GAAE,MAAM,EAAO,GAAG,eAAe,EAAE,CAenG;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC;IACxD,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAOD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAoBjG;AAMD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CA+DjE;AA+BD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,eAAe,EACtB,gBAAgB,GAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAoB,GAClE,MAAM,CAwFR"}
1
+ {"version":3,"file":"cli-help.d.ts","sourceRoot":"","sources":["../../src/lib/cli-help.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAEjD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,iBAAiB,EAAE,eAAe,EAAE,CAAC;IACrC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACjD,QAAQ,EAAE,eAAe,EAAE,CAAC;CAC7B;AAED,eAAO,MAAM,iBAAiB,EAAE,eAAe,EAA4B,CAAC;AAoC5E,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,GAAE,MAAM,EAAO,GAAG,eAAe,EAAE,CAenG;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAO,CAAC;IACxD,mEAAmE;IACnE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAOD,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,GAAE,sBAA2B,GAAG,SAAS,CAoBjG;AAMD,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CA+DjE;AA+BD,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,eAAe,EACtB,gBAAgB,GAAE,CAAC,eAAe,EAAE,MAAM,KAAK,OAAoB,GAClE,MAAM,CAwFR"}