@hasna/todos 0.15.52 → 0.17.0

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 (206) hide show
  1. package/CHANGELOG.md +1993 -0
  2. package/README.md +356 -42
  3. package/dist/ai-tools.d.ts.map +1 -1
  4. package/dist/ai.d.ts.map +1 -1
  5. package/dist/cli/cloud-router.d.ts +162 -72
  6. package/dist/cli/cloud-router.d.ts.map +1 -1
  7. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  8. package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
  9. package/dist/cli/commands/delegate.d.ts.map +1 -1
  10. package/dist/cli/commands/environment-snapshots.d.ts.map +1 -1
  11. package/dist/cli/commands/machines-api.d.ts +4 -0
  12. package/dist/cli/commands/machines-api.d.ts.map +1 -0
  13. package/dist/cli/commands/machines.d.ts.map +1 -1
  14. package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
  15. package/dist/cli/commands/plan-commands.d.ts +3 -0
  16. package/dist/cli/commands/plan-commands.d.ts.map +1 -0
  17. package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
  18. package/dist/cli/commands/pr-group-commands.d.ts.map +1 -1
  19. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  20. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  21. package/dist/cli/commands/storage-commands.d.ts.map +1 -1
  22. package/dist/cli/commands/task-list-commands.d.ts +3 -0
  23. package/dist/cli/commands/task-list-commands.d.ts.map +1 -0
  24. package/dist/cli/commands/template-commands.d.ts +3 -0
  25. package/dist/cli/commands/template-commands.d.ts.map +1 -0
  26. package/dist/cli/helpers.d.ts.map +1 -1
  27. package/dist/cli/index.js +22072 -15949
  28. package/dist/cli/machine-api.d.ts +7 -0
  29. package/dist/cli/machine-api.d.ts.map +1 -0
  30. package/dist/cli/plan-read-api.d.ts +6 -0
  31. package/dist/cli/plan-read-api.d.ts.map +1 -0
  32. package/dist/cli/project-api.d.ts +119 -0
  33. package/dist/cli/project-api.d.ts.map +1 -0
  34. package/dist/cli/stage-a.d.ts +25 -5
  35. package/dist/cli/stage-a.d.ts.map +1 -1
  36. package/dist/cli/task-query-api.d.ts +21 -0
  37. package/dist/cli/task-query-api.d.ts.map +1 -0
  38. package/dist/cli/template-api.d.ts +7 -0
  39. package/dist/cli/template-api.d.ts.map +1 -0
  40. package/dist/cli/template-remote.d.ts +90 -0
  41. package/dist/cli/template-remote.d.ts.map +1 -0
  42. package/dist/cli-mcp-parity.d.ts.map +1 -1
  43. package/dist/contracts.js +908 -291
  44. package/dist/db/agents.d.ts +8 -0
  45. package/dist/db/agents.d.ts.map +1 -1
  46. package/dist/db/builtin-templates.d.ts +2 -33
  47. package/dist/db/builtin-templates.d.ts.map +1 -1
  48. package/dist/db/database.d.ts +6 -0
  49. package/dist/db/database.d.ts.map +1 -1
  50. package/dist/db/handoffs.d.ts.map +1 -1
  51. package/dist/db/identity-mapping.d.ts +25 -0
  52. package/dist/db/identity-mapping.d.ts.map +1 -1
  53. package/dist/db/machines.d.ts.map +1 -1
  54. package/dist/db/migrations.d.ts.map +1 -1
  55. package/dist/db/plans.d.ts.map +1 -1
  56. package/dist/db/retrospectives.d.ts.map +1 -1
  57. package/dist/db/schema.d.ts.map +1 -1
  58. package/dist/db/task-crud.d.ts.map +1 -1
  59. package/dist/db/task-lists.d.ts.map +1 -1
  60. package/dist/db/task-runs.d.ts +2 -0
  61. package/dist/db/task-runs.d.ts.map +1 -1
  62. package/dist/index.d.ts +2 -2
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +4341 -1756
  65. package/dist/lib/assignee-validation.d.ts.map +1 -1
  66. package/dist/lib/builtin-template-library.d.ts +32 -0
  67. package/dist/lib/builtin-template-library.d.ts.map +1 -0
  68. package/dist/lib/cli-reference.d.ts.map +1 -1
  69. package/dist/lib/config.d.ts +10 -13
  70. package/dist/lib/config.d.ts.map +1 -1
  71. package/dist/lib/db-backup.d.ts +1 -1
  72. package/dist/lib/db-backup.d.ts.map +1 -1
  73. package/dist/lib/env.d.ts +18 -0
  74. package/dist/lib/env.d.ts.map +1 -0
  75. package/dist/lib/environment-snapshots.d.ts.map +1 -1
  76. package/dist/lib/feature-manifest.d.ts.map +1 -1
  77. package/dist/lib/gatherer.d.ts.map +1 -1
  78. package/dist/lib/headless-boundaries.d.ts +4 -3
  79. package/dist/lib/headless-boundaries.d.ts.map +1 -1
  80. package/dist/lib/local-encryption.d.ts +12 -0
  81. package/dist/lib/local-encryption.d.ts.map +1 -1
  82. package/dist/lib/local-opt-in.d.ts +104 -0
  83. package/dist/lib/local-opt-in.d.ts.map +1 -0
  84. package/dist/lib/paths.d.ts +20 -1
  85. package/dist/lib/paths.d.ts.map +1 -1
  86. package/dist/lib/plan-artifacts.d.ts +1 -0
  87. package/dist/lib/plan-artifacts.d.ts.map +1 -1
  88. package/dist/lib/plan-client-boundary.d.ts +2 -0
  89. package/dist/lib/plan-client-boundary.d.ts.map +1 -0
  90. package/dist/lib/plan-schedule.d.ts +6 -0
  91. package/dist/lib/plan-schedule.d.ts.map +1 -0
  92. package/dist/lib/project-panel.d.ts +6 -0
  93. package/dist/lib/project-panel.d.ts.map +1 -1
  94. package/dist/lib/project-receipt.d.ts +3 -0
  95. package/dist/lib/project-receipt.d.ts.map +1 -0
  96. package/dist/lib/sandbox-profiles.d.ts.map +1 -1
  97. package/dist/lib/shared-plan-artifacts.d.ts +14 -0
  98. package/dist/lib/shared-plan-artifacts.d.ts.map +1 -0
  99. package/dist/lib/sync.d.ts.map +1 -1
  100. package/dist/lib/task-list-client-boundary.d.ts +3 -0
  101. package/dist/lib/task-list-client-boundary.d.ts.map +1 -0
  102. package/dist/lib/task-routing.d.ts.map +1 -1
  103. package/dist/lib/template-client-boundary.d.ts +2 -0
  104. package/dist/lib/template-client-boundary.d.ts.map +1 -0
  105. package/dist/mcp/index.d.ts +53 -0
  106. package/dist/mcp/index.d.ts.map +1 -1
  107. package/dist/mcp/index.js +50124 -60838
  108. package/dist/mcp/plan-api.d.ts +6 -0
  109. package/dist/mcp/plan-api.d.ts.map +1 -0
  110. package/dist/mcp/remote-authority.d.ts +49 -0
  111. package/dist/mcp/remote-authority.d.ts.map +1 -0
  112. package/dist/mcp/task-coordination-api.d.ts +15 -0
  113. package/dist/mcp/task-coordination-api.d.ts.map +1 -0
  114. package/dist/mcp/task-list-api.d.ts +8 -0
  115. package/dist/mcp/task-list-api.d.ts.map +1 -0
  116. package/dist/mcp/token-utils.d.ts.map +1 -1
  117. package/dist/mcp/tools/agents.d.ts.map +1 -1
  118. package/dist/mcp/tools/environment-snapshots.d.ts.map +1 -1
  119. package/dist/mcp/tools/machines.d.ts.map +1 -1
  120. package/dist/mcp/tools/task-adv-tools.d.ts.map +1 -1
  121. package/dist/mcp/tools/task-auto-tools.d.ts.map +1 -1
  122. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  123. package/dist/mcp/tools/task-meta-tools.d.ts.map +1 -1
  124. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  125. package/dist/mcp/tools/task-resources.d.ts.map +1 -1
  126. package/dist/mcp/tools/templates.d.ts.map +1 -1
  127. package/dist/mcp.d.ts +2 -2
  128. package/dist/mcp.d.ts.map +1 -1
  129. package/dist/mcp.js +45 -19
  130. package/dist/project-registration.js +3349 -645
  131. package/dist/registry.js +909 -292
  132. package/dist/release-provenance.json +6 -6
  133. package/dist/sdk/client.d.ts +53 -4
  134. package/dist/sdk/client.d.ts.map +1 -1
  135. package/dist/sdk/index.d.ts +2 -0
  136. package/dist/sdk/index.d.ts.map +1 -1
  137. package/dist/sdk/index.js +1253 -1088
  138. package/dist/sdk/resolve.d.ts +101 -0
  139. package/dist/sdk/resolve.d.ts.map +1 -0
  140. package/dist/sdk/types.d.ts +17 -2
  141. package/dist/sdk/types.d.ts.map +1 -1
  142. package/dist/sdk/v1.generated.d.ts +334 -3
  143. package/dist/sdk/v1.generated.d.ts.map +1 -1
  144. package/dist/server/auth-posture.d.ts +66 -7
  145. package/dist/server/auth-posture.d.ts.map +1 -1
  146. package/dist/server/client-ip.d.ts +39 -0
  147. package/dist/server/client-ip.d.ts.map +1 -0
  148. package/dist/server/cloud.d.ts +2 -1
  149. package/dist/server/cloud.d.ts.map +1 -1
  150. package/dist/server/index.d.ts +1 -1
  151. package/dist/server/index.js +24758 -16687
  152. package/dist/server/openapi.d.ts +2514 -469
  153. package/dist/server/openapi.d.ts.map +1 -1
  154. package/dist/server/port.d.ts +2 -2
  155. package/dist/server/port.d.ts.map +1 -1
  156. package/dist/server/rate-limit-config.d.ts +6 -0
  157. package/dist/server/rate-limit-config.d.ts.map +1 -0
  158. package/dist/server/routes.d.ts +3 -6
  159. package/dist/server/routes.d.ts.map +1 -1
  160. package/dist/server/serve.d.ts +2 -6
  161. package/dist/server/serve.d.ts.map +1 -1
  162. package/dist/server/v1.d.ts +3 -1
  163. package/dist/server/v1.d.ts.map +1 -1
  164. package/dist/storage/atomic-project-migration.d.ts +53 -0
  165. package/dist/storage/atomic-project-migration.d.ts.map +1 -0
  166. package/dist/storage/index.d.ts +4 -2
  167. package/dist/storage/index.d.ts.map +1 -1
  168. package/dist/storage/interfaces.d.ts +135 -3
  169. package/dist/storage/interfaces.d.ts.map +1 -1
  170. package/dist/storage/local-sqlite.d.ts.map +1 -1
  171. package/dist/storage/machine-registry.d.ts +36 -0
  172. package/dist/storage/machine-registry.d.ts.map +1 -0
  173. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  174. package/dist/storage/postgres-sync.d.ts +2 -2
  175. package/dist/storage/postgres-sync.d.ts.map +1 -1
  176. package/dist/storage/run-artifact-creation-upload.d.ts +50 -0
  177. package/dist/storage/run-artifact-creation-upload.d.ts.map +1 -0
  178. package/dist/storage/s3-artifact-sync.d.ts +18 -0
  179. package/dist/storage/s3-artifact-sync.d.ts.map +1 -1
  180. package/dist/storage/sqlite-snapshot.d.ts +2 -0
  181. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  182. package/dist/storage.d.ts +2 -2
  183. package/dist/storage.d.ts.map +1 -1
  184. package/dist/storage.js +3154 -350
  185. package/dist/task-manifest/postgres.d.ts +1 -1
  186. package/dist/task-manifest/sqlite.d.ts +1 -1
  187. package/dist/task-manifest.js +250 -73
  188. package/dist/task-subtree-transfer.js +198 -4
  189. package/dist/testing.d.ts +90 -29
  190. package/dist/testing.d.ts.map +1 -1
  191. package/dist/testing.js +34 -10
  192. package/dist/types/index.d.ts +43 -4
  193. package/dist/types/index.d.ts.map +1 -1
  194. package/docs/PLAN_API.md +93 -0
  195. package/docs/TASK_LIST_API.md +58 -0
  196. package/docs/TASK_QUERY_API.md +14 -0
  197. package/docs/TEMPLATE_API.md +18 -0
  198. package/docs/native-storage.md +405 -0
  199. package/package.json +18 -15
  200. package/postinstall.js +52 -1
  201. package/dashboard/dist/assets/index-BNQ4gJua.js +0 -342
  202. package/dashboard/dist/assets/index-DjzvHUWt.css +0 -1
  203. package/dashboard/dist/index.html +0 -13
  204. package/dashboard/dist/logo.jpg +0 -0
  205. package/dist/mcp/tools/code-tools.d.ts +0 -15
  206. package/dist/mcp/tools/code-tools.d.ts.map +0 -1
package/dist/storage.js CHANGED
@@ -253,7 +253,7 @@ function isBlockingDependencyStatus(status) {
253
253
  function isTerminalStatus(status) {
254
254
  return status === "completed" || status === "failed" || status === "cancelled";
255
255
  }
256
- 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;
256
+ 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, InputValidationError;
257
257
  var init_types = __esm(() => {
258
258
  TASK_STATUSES = [
259
259
  "pending",
@@ -268,7 +268,7 @@ var init_types = __esm(() => {
268
268
  "high",
269
269
  "critical"
270
270
  ];
271
- PLAN_STATUSES = ["active", "completed", "archived"];
271
+ PLAN_STATUSES = ["active", "completed", "archived", "planning", "cancelled"];
272
272
  VersionConflictError = class VersionConflictError extends Error {
273
273
  taskId;
274
274
  expectedVersion;
@@ -457,6 +457,72 @@ var init_types = __esm(() => {
457
457
  this.name = "DispatchNotFoundError";
458
458
  }
459
459
  };
460
+ InputValidationError = class InputValidationError extends Error {
461
+ static code = "INVALID_INPUT";
462
+ code = InputValidationError.code;
463
+ suggestion;
464
+ constructor(message, suggestion) {
465
+ super(message);
466
+ this.name = "InputValidationError";
467
+ this.suggestion = suggestion;
468
+ }
469
+ };
470
+ });
471
+
472
+ // src/lib/local-opt-in.ts
473
+ import {
474
+ clientTransportEnvKeys,
475
+ credentialOverrideEnvKey,
476
+ credentialPointerEnvKey,
477
+ CREDENTIAL_PROFILE_ENV_KEY
478
+ } from "@hasna/contracts/client";
479
+ function isTodosLocalOptIn(env = process.env) {
480
+ return TODOS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
481
+ }
482
+ function todosAuthorityEnvKeys() {
483
+ const keys = clientTransportEnvKeys("todos");
484
+ return [
485
+ ...keys.apiUrlKeys,
486
+ ...keys.apiKeyKeys,
487
+ credentialOverrideEnvKey("todos"),
488
+ credentialPointerEnvKey("todos"),
489
+ CREDENTIAL_PROFILE_ENV_KEY
490
+ ];
491
+ }
492
+ function hasTodosEnvAuthorityIntent(env = process.env) {
493
+ return todosAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
494
+ }
495
+ function selectsTodosLocalStore(env = process.env) {
496
+ return !hasTodosEnvAuthorityIntent(env) && isTodosLocalOptIn(env);
497
+ }
498
+ function todosResolverEnv(env) {
499
+ const blanks = todosAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
500
+ if (blanks.length === 0)
501
+ return env;
502
+ const next = { ...env };
503
+ for (const key of blanks)
504
+ delete next[key];
505
+ return next;
506
+ }
507
+ function isAmbientTodosEnv(env) {
508
+ if (typeof process !== "undefined" && env === process.env)
509
+ return true;
510
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
511
+ }
512
+ function todosResolverInputs(env, credentials = {}) {
513
+ const normalised = todosResolverEnv(env);
514
+ if (normalised === env)
515
+ return { env: normalised, credentials };
516
+ const keychain = { ...credentials.keychain };
517
+ if (keychain.enabled === undefined && keychain.run === undefined) {
518
+ keychain.enabled = isAmbientTodosEnv(env);
519
+ }
520
+ return { env: normalised, credentials: { ...credentials, keychain } };
521
+ }
522
+ var TODOS_LOCAL_OPT_IN_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
523
+ var init_local_opt_in = __esm(() => {
524
+ TODOS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_TODOS_LOCAL", "TODOS_LOCAL"];
525
+ CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
460
526
  });
461
527
 
462
528
  // src/project-registration/schema.ts
@@ -2028,16 +2094,18 @@ var init_migrations = __esm(() => {
2028
2094
  SET leaf_task_id = COALESCE(leaf_task_id, (
2029
2095
  SELECT attempt.leaf_task_id
2030
2096
  FROM pr_group_attempts AS attempt
2097
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2031
2098
  WHERE attempt.group_id = pr_groups.id
2032
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2099
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2033
2100
  attempt.created_at ASC, attempt.id ASC
2034
2101
  LIMIT 1
2035
2102
  )),
2036
2103
  branch = COALESCE(branch, (
2037
2104
  SELECT attempt.branch
2038
2105
  FROM pr_group_attempts AS attempt
2106
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2039
2107
  WHERE attempt.group_id = pr_groups.id
2040
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2108
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2041
2109
  attempt.created_at ASC, attempt.id ASC
2042
2110
  LIMIT 1
2043
2111
  ));
@@ -2060,16 +2128,18 @@ var init_migrations = __esm(() => {
2060
2128
  SET pr_number = COALESCE(pr_number, (
2061
2129
  SELECT attempt.pr_number
2062
2130
  FROM pr_group_attempts AS attempt
2131
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2063
2132
  WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
2064
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2133
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2065
2134
  attempt.created_at ASC, attempt.id ASC
2066
2135
  LIMIT 1
2067
2136
  )),
2068
2137
  base_sha = COALESCE(base_sha, (
2069
2138
  SELECT attempt.base_sha
2070
2139
  FROM pr_group_attempts AS attempt
2140
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
2071
2141
  WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
2072
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
2142
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
2073
2143
  attempt.created_at ASC, attempt.id ASC
2074
2144
  LIMIT 1
2075
2145
  ));
@@ -2108,7 +2178,7 @@ var init_migrations = __esm(() => {
2108
2178
  ));
2109
2179
 
2110
2180
  INSERT OR IGNORE INTO _migrations (id)
2111
- SELECT 66
2181
+ SELECT 67
2112
2182
  WHERE NOT EXISTS (
2113
2183
  SELECT 1 FROM pr_groups WHERE leaf_task_id IS NULL OR branch IS NULL
2114
2184
  )
@@ -2313,32 +2383,52 @@ var init_migrations = __esm(() => {
2313
2383
  });
2314
2384
 
2315
2385
  // src/db/schema.ts
2386
+ function runPrGroupLineageMigration(db, migration, rebuild) {
2387
+ if (db.inTransaction)
2388
+ throw new Error("PR-group migration requires its own transaction");
2389
+ const priorForeignKeys = db.query("PRAGMA foreign_keys").get().foreign_keys;
2390
+ try {
2391
+ if (rebuild)
2392
+ db.exec("PRAGMA foreign_keys = OFF");
2393
+ db.exec("BEGIN IMMEDIATE");
2394
+ for (const statement of migration.split(/;\s*(?:\n|$)/).map((value) => value.trim()).filter(Boolean)) {
2395
+ if (/^(?:PRAGMA foreign_keys\s*=|BEGIN IMMEDIATE$|COMMIT$)/i.test(statement))
2396
+ continue;
2397
+ db.run(statement);
2398
+ }
2399
+ const completed = db.query("SELECT id FROM _migrations WHERE id = ?").get(rebuild ? 68 : 67);
2400
+ if (!completed)
2401
+ throw new Error("PR-group lineage backfill is incomplete; migration rolled back");
2402
+ if (rebuild && db.query("PRAGMA foreign_key_check").all().length) {
2403
+ throw new Error("PR-group migration failed foreign-key integrity verification");
2404
+ }
2405
+ db.exec("COMMIT");
2406
+ } catch (error) {
2407
+ if (db.inTransaction)
2408
+ db.exec("ROLLBACK");
2409
+ throw error;
2410
+ } finally {
2411
+ db.exec(`PRAGMA foreign_keys = ${priorForeignKeys ? "ON" : "OFF"}`);
2412
+ }
2413
+ }
2316
2414
  function runMigrations(db) {
2317
- let strictMigrationFailure = null;
2318
- const executeMigration = (migration, index) => {
2415
+ let currentLevel = 0;
2416
+ try {
2417
+ currentLevel = db.query("SELECT MAX(id) as max_id FROM _migrations").get()?.max_id ?? 0;
2418
+ } catch {}
2419
+ for (let index = currentLevel;index < MIGRATIONS.length; index++) {
2319
2420
  try {
2320
- db.exec(migration);
2421
+ if (index === 66 || index === 67)
2422
+ runPrGroupLineageMigration(db, MIGRATIONS[index], index === 67);
2423
+ else
2424
+ db.exec(MIGRATIONS[index]);
2321
2425
  } catch (error) {
2322
- try {
2426
+ if (db.inTransaction)
2323
2427
  db.exec("ROLLBACK");
2324
- } catch {}
2325
- if (index + 1 >= 68)
2326
- strictMigrationFailure = error;
2327
- }
2328
- };
2329
- try {
2330
- const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
2331
- const currentLevel = result?.max_id ?? 0;
2332
- for (let i = currentLevel;i < MIGRATIONS.length; i++) {
2333
- executeMigration(MIGRATIONS[i], i);
2334
- }
2335
- } catch {
2336
- for (const [index, migration] of MIGRATIONS.entries()) {
2337
- executeMigration(migration, index);
2428
+ if (index + 1 >= 67)
2429
+ throw error;
2338
2430
  }
2339
2431
  }
2340
- if (strictMigrationFailure)
2341
- throw strictMigrationFailure;
2342
2432
  ensureSchema(db);
2343
2433
  }
2344
2434
  function planSlugBase(value) {
@@ -3650,16 +3740,18 @@ function ensureSchema(db) {
3650
3740
  SET leaf_task_id = COALESCE(leaf_task_id, (
3651
3741
  SELECT attempt.leaf_task_id
3652
3742
  FROM pr_group_attempts AS attempt
3743
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3653
3744
  WHERE attempt.group_id = pr_groups.id
3654
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3745
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3655
3746
  attempt.created_at ASC, attempt.id ASC
3656
3747
  LIMIT 1
3657
3748
  )),
3658
3749
  branch = COALESCE(branch, (
3659
3750
  SELECT attempt.branch
3660
3751
  FROM pr_group_attempts AS attempt
3752
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3661
3753
  WHERE attempt.group_id = pr_groups.id
3662
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3754
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3663
3755
  attempt.created_at ASC, attempt.id ASC
3664
3756
  LIMIT 1
3665
3757
  ));
@@ -3680,16 +3772,18 @@ function ensureSchema(db) {
3680
3772
  SET pr_number = COALESCE(pr_number, (
3681
3773
  SELECT attempt.pr_number
3682
3774
  FROM pr_group_attempts AS attempt
3775
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3683
3776
  WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
3684
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3777
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3685
3778
  attempt.created_at ASC, attempt.id ASC
3686
3779
  LIMIT 1
3687
3780
  )),
3688
3781
  base_sha = COALESCE(base_sha, (
3689
3782
  SELECT attempt.base_sha
3690
3783
  FROM pr_group_attempts AS attempt
3784
+ JOIN pr_groups AS owner ON owner.id = attempt.group_id
3691
3785
  WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
3692
- ORDER BY CASE WHEN attempt.id = pr_groups.active_attempt_id THEN 0 ELSE 1 END,
3786
+ ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
3693
3787
  attempt.created_at ASC, attempt.id ASC
3694
3788
  LIMIT 1
3695
3789
  ));
@@ -3801,6 +3895,28 @@ var init_schema = __esm(() => {
3801
3895
  init_migrations();
3802
3896
  });
3803
3897
 
3898
+ // src/lib/env.ts
3899
+ var alias = (canonical, legacy) => process.env[canonical] ?? process.env[legacy], env;
3900
+ var init_env = __esm(() => {
3901
+ env = {
3902
+ profile: () => alias("HASNA_TODOS_PROFILE", "TODOS_PROFILE"),
3903
+ toolGroups: () => alias("HASNA_TODOS_TOOL_GROUPS", "TODOS_TOOL_GROUPS"),
3904
+ machineId: () => alias("HASNA_TODOS_MACHINE_ID", "TODOS_MACHINE_ID"),
3905
+ machineName: () => alias("HASNA_TODOS_MACHINE_NAME", "TODOS_MACHINE_NAME"),
3906
+ rateLimitMax: () => alias("HASNA_TODOS_RATE_LIMIT_MAX", "TODOS_RATE_LIMIT_MAX"),
3907
+ trustProxy: () => alias("HASNA_TODOS_TRUST_PROXY", "TODOS_TRUST_PROXY"),
3908
+ autoProject: () => alias("HASNA_TODOS_AUTO_PROJECT", "TODOS_AUTO_PROJECT"),
3909
+ aiFormat: () => alias("HASNA_TODOS_AI_FORMAT", "TODOS_AI_FORMAT"),
3910
+ syncAgents: () => alias("HASNA_TODOS_SYNC_AGENTS", "TODOS_SYNC_AGENTS"),
3911
+ taskListId: () => alias("HASNA_TODOS_TASK_LIST_ID", "TODOS_TASK_LIST_ID"),
3912
+ claudeTaskList: () => alias("HASNA_TODOS_CLAUDE_TASK_LIST", "TODOS_CLAUDE_TASK_LIST"),
3913
+ sandboxProfilesPath: () => alias("HASNA_TODOS_SANDBOX_PROFILES_PATH", "TODOS_SANDBOX_PROFILES_PATH"),
3914
+ seatRosterPath: () => alias("HASNA_TODOS_SEAT_ROSTER_PATH", "TODOS_SEAT_ROSTER_PATH"),
3915
+ delegateNoticeChannel: () => alias("HASNA_TODOS_DELEGATE_NOTICE_CHANNEL", "TODOS_DELEGATE_NOTICE_CHANNEL"),
3916
+ delegateNotifyBin: () => alias("HASNA_TODOS_DELEGATE_NOTIFY_BIN", "TODOS_DELEGATE_NOTIFY_BIN")
3917
+ };
3918
+ });
3919
+
3804
3920
  // src/db/machines.ts
3805
3921
  import { existsSync } from "fs";
3806
3922
  import { hostname as osHostname, platform as osPlatform, arch as osArch } from "os";
@@ -3863,7 +3979,7 @@ function extractTopology(machine) {
3863
3979
  }
3864
3980
  function getOrCreateLocalMachine(db) {
3865
3981
  const d = db || getDatabase();
3866
- const name = process.env["TODOS_MACHINE_NAME"] || osHostname();
3982
+ const name = env.machineName() || osHostname();
3867
3983
  const host = osHostname();
3868
3984
  const plat = osPlatform();
3869
3985
  const existing = d.query("SELECT * FROM machines WHERE name = ?").get(name);
@@ -3932,7 +4048,7 @@ function registerMachine(name, opts, db) {
3932
4048
  }
3933
4049
  function updateMachineHeartbeat(idOrName, opts = {}, db) {
3934
4050
  const d = db || getDatabase();
3935
- const key = idOrName || process.env["TODOS_MACHINE_NAME"] || osHostname();
4051
+ const key = idOrName || env.machineName() || osHostname();
3936
4052
  const row = d.query("SELECT * FROM machines WHERE id = ? OR name = ?").get(key, key);
3937
4053
  if (!row) {
3938
4054
  return registerMachine(key, {
@@ -4084,6 +4200,7 @@ function backfillMachineId(db, force = false) {
4084
4200
  var _machineId = null, TABLES_WITH_MACHINE_ID;
4085
4201
  var init_machines = __esm(() => {
4086
4202
  init_database();
4203
+ init_env();
4087
4204
  TABLES_WITH_MACHINE_ID = [
4088
4205
  "projects",
4089
4206
  "tasks",
@@ -4207,20 +4324,17 @@ function ensureAgentIdentitySchema(db) {
4207
4324
  SELECT RAISE(ABORT, 'IDENTITY_SOURCE_LINEAGE_IMMUTABLE');
4208
4325
  END;
4209
4326
 
4327
+ -- Drop first so an older definition is replaced rather than silently
4328
+ -- retained (the upgrade path). AGENT_IDENTITY_HISTORY_TRIGGER_DDL then
4329
+ -- re-creates them with CREATE TRIGGER IF NOT EXISTS. The two statements
4330
+ -- do different jobs and both are kept: the DROP is what refreshes a
4331
+ -- stale definition, and the IF NOT EXISTS is what keeps the CREATE safe
4332
+ -- on its own \u2014 so a caller that reaches it without a preceding DROP, in
4333
+ -- its own connection or transaction, gets a no-op instead of
4334
+ -- "trigger ... already exists".
4210
4335
  DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_immutable;
4211
- CREATE TRIGGER trg_agent_identity_mapping_history_immutable
4212
- BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
4213
- ON agent_identity_source_mappings
4214
- BEGIN
4215
- SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4216
- END;
4217
-
4218
4336
  DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_append_only;
4219
- CREATE TRIGGER trg_agent_identity_mapping_history_append_only
4220
- BEFORE DELETE ON agent_identity_source_mappings
4221
- BEGIN
4222
- SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4223
- END;
4337
+ ${AGENT_IDENTITY_HISTORY_TRIGGER_DDL}
4224
4338
  `);
4225
4339
  db.run("INSERT OR IGNORE INTO _migrations (id) VALUES (?)", [IDENTITY_MIGRATION_ID]);
4226
4340
  });
@@ -4260,7 +4374,20 @@ function storeAgentAlias(agentId, label, aliasKind, status, db) {
4260
4374
  function recordAgentAlias(agentId, label, db) {
4261
4375
  return storeAgentAlias(agentId, label, "historical", "active", db);
4262
4376
  }
4263
- var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65;
4377
+ var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65, AGENT_IDENTITY_HISTORY_TRIGGER_DDL = `
4378
+ CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_immutable
4379
+ BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
4380
+ ON agent_identity_source_mappings
4381
+ BEGIN
4382
+ SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4383
+ END;
4384
+
4385
+ CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_append_only
4386
+ BEFORE DELETE ON agent_identity_source_mappings
4387
+ BEGIN
4388
+ SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
4389
+ END;
4390
+ `;
4264
4391
  var init_identity_mapping = __esm(() => {
4265
4392
  init_types();
4266
4393
  init_types();
@@ -4282,108 +4409,106 @@ var init_identity_mapping = __esm(() => {
4282
4409
  });
4283
4410
  });
4284
4411
 
4285
- // node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
4412
+ // src/lib/paths.ts
4413
+ import { existsSync as existsSync2 } from "fs";
4286
4414
  import { homedir } from "os";
4287
- import { join } from "path";
4288
- function assertApp(app) {
4415
+ import { isAbsolute, join, resolve as resolve2 } from "path";
4416
+ import { homedir as pathsResolverHomedir } from "os";
4417
+ import { join as pathsResolverJoin } from "path";
4418
+ function pathsResolverAssertApp(app) {
4289
4419
  if (typeof app !== "string" || app.length === 0) {
4290
4420
  throw new TypeError("paths: app must be a non-empty string");
4291
4421
  }
4292
- if (!APP_SLUG_RE.test(app)) {
4422
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
4293
4423
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
4294
4424
  }
4295
4425
  }
4296
- function envOf(options) {
4297
- return options.env ?? process.env;
4298
- }
4299
- function envValue(options, kind) {
4300
- const value = envOf(options)[KIND_ENV[kind]];
4301
- return typeof value === "string" && value.length > 0 ? value : undefined;
4302
- }
4303
- function isMacOS(platform) {
4304
- return platform === "darwin";
4426
+ function pathsResolverAssertKind(kind) {
4427
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
4428
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
4429
+ }
4305
4430
  }
4306
- function baseDir(kind, options) {
4307
- const override = envValue(options, kind);
4308
- if (override)
4431
+ function pathsResolverBaseDir(kind, options) {
4432
+ pathsResolverAssertKind(kind);
4433
+ const env2 = options.env ?? process.env;
4434
+ const override = env2[PATHS_RESOLVER_KIND_ENV[kind]];
4435
+ if (typeof override === "string" && override.length > 0)
4309
4436
  return override;
4310
- const home = options.home ?? homedir();
4437
+ const home = options.home ?? pathsResolverHomedir();
4311
4438
  const platform = options.platform ?? process.platform;
4312
- if (isMacOS(platform)) {
4439
+ if (platform === "darwin") {
4313
4440
  switch (kind) {
4314
4441
  case "config":
4315
4442
  case "data":
4316
- return join(home, "Library", "Application Support", "Hasna");
4443
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
4317
4444
  case "cache":
4318
- return join(home, "Library", "Caches", "Hasna");
4445
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
4319
4446
  case "state":
4320
- return join(home, "Library", "Logs", "Hasna");
4447
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
4321
4448
  }
4322
4449
  }
4323
4450
  switch (kind) {
4324
4451
  case "config":
4325
- return join(home, ".config", "hasna");
4452
+ return pathsResolverJoin(home, ".config", "hasna");
4326
4453
  case "data":
4327
- return join(home, ".local", "share", "hasna");
4454
+ return pathsResolverJoin(home, ".local", "share", "hasna");
4328
4455
  case "state":
4329
- return join(home, ".local", "state", "hasna");
4456
+ return pathsResolverJoin(home, ".local", "state", "hasna");
4330
4457
  case "cache":
4331
- return join(home, ".cache", "hasna");
4458
+ return pathsResolverJoin(home, ".cache", "hasna");
4332
4459
  }
4333
4460
  }
4334
- function resolvePath(kind, options) {
4335
- assertApp(options.app);
4336
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
4337
- return join(baseDir(kind, options), appSegment);
4461
+ function pathsResolverResolve(kind, options) {
4462
+ pathsResolverAssertApp(options.app);
4463
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
4464
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
4338
4465
  }
4339
4466
  function dataDir(options) {
4340
- return resolvePath("data", options);
4467
+ return pathsResolverResolve("data", options);
4341
4468
  }
4342
- var KIND_ENV, APP_SLUG_RE;
4343
- var init_dist = __esm(() => {
4344
- KIND_ENV = {
4345
- config: "HASNA_CONFIG_HOME",
4346
- data: "HASNA_DATA_HOME",
4347
- state: "HASNA_STATE_HOME",
4348
- cache: "HASNA_CACHE_HOME"
4349
- };
4350
- APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4351
- });
4352
-
4353
- // src/lib/paths.ts
4354
- import { existsSync as existsSync2 } from "fs";
4355
- import { homedir as homedir2 } from "os";
4356
- import { join as join2, resolve as resolve2 } from "path";
4357
- function effectiveHome(env = process.env) {
4358
- return env.HOME || env.USERPROFILE || homedir2();
4469
+ function effectiveHome(env2 = process.env) {
4470
+ return env2.HOME || env2.USERPROFILE || homedir();
4471
+ }
4472
+ function hasnaHomeRoot(env2 = process.env) {
4473
+ const override = env2["HASNA_HOME"]?.trim();
4474
+ if (override && isAbsolute(override))
4475
+ return override;
4476
+ return join(effectiveHome(env2), ".hasna");
4359
4477
  }
4360
- function legacyHomeDir(env = process.env) {
4361
- return join2(effectiveHome(env), ".hasna", "todos");
4478
+ function legacyHomeDir(env2 = process.env) {
4479
+ return join(hasnaHomeRoot(env2), "todos");
4362
4480
  }
4363
- function resolverHome(env = process.env) {
4364
- return dataDir({ app: "todos", home: effectiveHome(env), env });
4481
+ function resolverHome(env2 = process.env) {
4482
+ return dataDir({ app: "todos", home: effectiveHome(env2), env: env2 });
4365
4483
  }
4366
- function adoptResolverHome(resolved, env = process.env) {
4367
- const dataOverride = env.HASNA_DATA_HOME;
4484
+ function adoptResolverHome(resolved, env2 = process.env) {
4485
+ const dataOverride = env2.HASNA_DATA_HOME;
4368
4486
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
4369
4487
  return true;
4370
- return existsSync2(join2(resolved, "todos.db")) || existsSync2(join2(resolved, "config.json"));
4488
+ return existsSync2(join(resolved, "todos.db")) || existsSync2(join(resolved, "config.json"));
4371
4489
  }
4372
- function getTodosDir(env = process.env) {
4373
- const resolved = resolverHome(env);
4374
- return resolve2(adoptResolverHome(resolved, env) ? resolved : legacyHomeDir(env));
4490
+ function getTodosDir(env2 = process.env) {
4491
+ const resolved = resolverHome(env2);
4492
+ return resolve2(adoptResolverHome(resolved, env2) ? resolved : legacyHomeDir(env2));
4375
4493
  }
4494
+ var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE;
4376
4495
  var init_paths = __esm(() => {
4377
- init_dist();
4496
+ PATHS_RESOLVER_KIND_ENV = {
4497
+ config: "HASNA_CONFIG_HOME",
4498
+ data: "HASNA_DATA_HOME",
4499
+ state: "HASNA_STATE_HOME",
4500
+ cache: "HASNA_CACHE_HOME"
4501
+ };
4502
+ PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
4378
4503
  });
4379
4504
 
4380
4505
  // src/lib/sync-utils.ts
4381
4506
  import { existsSync as existsSync3, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
4382
4507
  import { createHash } from "crypto";
4383
- import { homedir as homedir3 } from "os";
4384
- import { join as join3 } from "path";
4508
+ import { homedir as homedir2 } from "os";
4509
+ import { join as join2 } from "path";
4385
4510
  function getHomeDir() {
4386
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir3();
4511
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
4387
4512
  }
4388
4513
  function getTodosGlobalDir() {
4389
4514
  return getTodosDir();
@@ -4409,14 +4534,14 @@ function writeJsonFile(path, data) {
4409
4534
  `);
4410
4535
  }
4411
4536
  function readHighWaterMark(dir) {
4412
- const path = join3(dir, ".highwatermark");
4537
+ const path = join2(dir, ".highwatermark");
4413
4538
  if (!existsSync3(path))
4414
4539
  return 1;
4415
4540
  const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
4416
4541
  return isNaN(val) ? 1 : val;
4417
4542
  }
4418
4543
  function writeHighWaterMark(dir, value) {
4419
- writeFileSync(join3(dir, ".highwatermark"), String(value));
4544
+ writeFileSync(join2(dir, ".highwatermark"), String(value));
4420
4545
  }
4421
4546
  function getFileMtimeMs(path) {
4422
4547
  try {
@@ -4563,20 +4688,23 @@ __export(exports_database, {
4563
4688
  resolvePartialId: () => resolvePartialId,
4564
4689
  resolveAssignedToAliases: () => resolveAssignedToAliases,
4565
4690
  resetDatabase: () => resetDatabase,
4691
+ refuseLocalStore: () => refuseLocalStore,
4566
4692
  now: () => now,
4567
4693
  lowerInClause: () => lowerInClause,
4568
4694
  lockExpiryCutoff: () => lockExpiryCutoff,
4569
4695
  isLockExpired: () => isLockExpired,
4696
+ isLocalStoreRefused: () => isLocalStoreRefused,
4570
4697
  getDatabasePath: () => getDatabasePath,
4571
4698
  getDatabase: () => getDatabase,
4572
4699
  closeDatabase: () => closeDatabase,
4573
4700
  clearExpiredLocks: () => clearExpiredLocks,
4574
4701
  assignedToAliasSet: () => assignedToAliasSet,
4702
+ allowLocalStore: () => allowLocalStore,
4575
4703
  LOCK_EXPIRY_MINUTES: () => LOCK_EXPIRY_MINUTES
4576
4704
  });
4577
4705
  import { Database } from "bun:sqlite";
4578
4706
  import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
4579
- import { dirname, join as join4, resolve as resolve3 } from "path";
4707
+ import { dirname, join as join3, resolve as resolve3 } from "path";
4580
4708
  function isInMemoryDb(path) {
4581
4709
  return path === ":memory:" || path.startsWith("file::memory:");
4582
4710
  }
@@ -4585,7 +4713,7 @@ function findNearestProjectDb(startDir) {
4585
4713
  const stopAt = gitRoot ? resolve3(gitRoot) : resolve3(startDir);
4586
4714
  let dir = resolve3(startDir);
4587
4715
  while (true) {
4588
- const candidate = join4(dir, ".hasna", "todos", "todos.db");
4716
+ const candidate = join3(dir, ".hasna", "todos", "todos.db");
4589
4717
  if (existsSync4(candidate))
4590
4718
  return candidate;
4591
4719
  if (dir === stopAt)
@@ -4600,7 +4728,7 @@ function findNearestProjectDb(startDir) {
4600
4728
  function findGitRoot(startDir) {
4601
4729
  let dir = resolve3(startDir);
4602
4730
  while (true) {
4603
- if (existsSync4(join4(dir, ".git")))
4731
+ if (existsSync4(join3(dir, ".git")))
4604
4732
  return dir;
4605
4733
  const parent = dirname(dir);
4606
4734
  if (parent === dir)
@@ -4610,7 +4738,7 @@ function findGitRoot(startDir) {
4610
4738
  return null;
4611
4739
  }
4612
4740
  function getGlobalDbPath() {
4613
- return join4(getTodosGlobalDir(), "todos.db");
4741
+ return join3(getTodosGlobalDir(), "todos.db");
4614
4742
  }
4615
4743
  function hasExplicitProjectArg(args = process.argv.slice(2)) {
4616
4744
  return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
@@ -4648,7 +4776,7 @@ function getDbPath() {
4648
4776
  if (process.env["TODOS_DB_SCOPE"] === "project") {
4649
4777
  const gitRoot = findGitRoot(cwd);
4650
4778
  if (gitRoot && canCreateScopedProjectDb()) {
4651
- return join4(gitRoot, ".hasna", "todos", "todos.db");
4779
+ return join3(gitRoot, ".hasna", "todos", "todos.db");
4652
4780
  }
4653
4781
  }
4654
4782
  return getGlobalDbPath();
@@ -4688,7 +4816,21 @@ function maybeInstallShadowCapture(db) {
4688
4816
  console.error(`[todos] shadow capture install failed: ${error instanceof Error ? error.message : String(error)}`);
4689
4817
  }
4690
4818
  }
4819
+ function refuseLocalStore(message) {
4820
+ localStoreRefusal = message;
4821
+ }
4822
+ function allowLocalStore() {
4823
+ localStoreRefusal = null;
4824
+ }
4825
+ function isLocalStoreRefused() {
4826
+ return localStoreRefusal !== null;
4827
+ }
4691
4828
  function getDatabase(dbPath) {
4829
+ if (localStoreRefusal !== null)
4830
+ throw new Error(localStoreRefusal);
4831
+ if (dbPath === undefined && !selectsTodosLocalStore()) {
4832
+ throw new Error("API_DATABASE_FALLBACK_FORBIDDEN: this operation must use the shared Todos API; implicit SQLite access is unavailable");
4833
+ }
4692
4834
  const path = dbPath || getDbPath();
4693
4835
  if (_db && _dbPath === path)
4694
4836
  return _db;
@@ -4826,8 +4968,9 @@ function lowerInClause(column, values, params) {
4826
4968
  params.push(...values.map((v) => v.toLowerCase()));
4827
4969
  return `LOWER(${column}) IN (${values.map(() => "?").join(",")})`;
4828
4970
  }
4829
- var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, ALLOWED_TABLES;
4971
+ var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, localStoreRefusal = null, ALLOWED_TABLES;
4830
4972
  var init_database = __esm(() => {
4973
+ init_local_opt_in();
4831
4974
  init_schema();
4832
4975
  init_machines();
4833
4976
  init_identity_mapping();
@@ -4838,9 +4981,9 @@ var init_database = __esm(() => {
4838
4981
 
4839
4982
  // src/lib/config.ts
4840
4983
  import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
4841
- import { dirname as dirname2, join as join5 } from "path";
4984
+ import { dirname as dirname2, join as join4 } from "path";
4842
4985
  function getConfigPath() {
4843
- return join5(getTodosGlobalDir(), "config.json");
4986
+ return join4(getTodosGlobalDir(), "config.json");
4844
4987
  }
4845
4988
  function normalizeAgent(agent) {
4846
4989
  return agent.trim().toLowerCase();
@@ -4891,21 +5034,6 @@ function normalizeApiUrl(value) {
4891
5034
  return null;
4892
5035
  return trimmed.replace(/\/+$/, "");
4893
5036
  }
4894
- function getLocalApiConfig(env = process.env) {
4895
- const config = loadConfig();
4896
- const envApiUrl = normalizeApiUrl(env["TODOS_URL"]);
4897
- const configApiUrl = normalizeApiUrl(config.apiUrl);
4898
- const apiUrl = envApiUrl ?? configApiUrl;
4899
- const apiKey = env["TODOS_API_KEY"] || config.apiKey || null;
4900
- return {
4901
- apiUrl,
4902
- apiKey,
4903
- source: {
4904
- apiUrl: envApiUrl ? "TODOS_URL" : configApiUrl ? "config" : "none",
4905
- apiKey: env["TODOS_API_KEY"] ? "TODOS_API_KEY" : config.apiKey ? "config" : "none"
4906
- }
4907
- };
4908
- }
4909
5037
  function getSyncAgentsFromConfig() {
4910
5038
  const config = loadConfig();
4911
5039
  const agents = config.sync_agents;
@@ -5756,11 +5884,11 @@ function writeAllowed(profile, root, writePath) {
5756
5884
  return isPathInside(scopeRoot, target);
5757
5885
  });
5758
5886
  }
5759
- function redactedEnvKeys(profile, env) {
5760
- if (!env)
5887
+ function redactedEnvKeys(profile, env2) {
5888
+ if (!env2)
5761
5889
  return [];
5762
5890
  const patterns = unique2([...DEFAULT_ENV_REDACTIONS, ...profile.env_redactions]).map((item) => item.toUpperCase());
5763
- return Object.keys(env).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5891
+ return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5764
5892
  }
5765
5893
  function checkWorkspacePermission(input = {}) {
5766
5894
  const status = getWorkspaceTrustStatus(input.path || process.cwd());
@@ -5898,18 +6026,18 @@ function profileByName(name, path) {
5898
6026
  const resolved = normalizePath2(path);
5899
6027
  return profiles.find((profile) => isPathInside2(profile.root, resolved)) || defaultProfile2("default", resolved);
5900
6028
  }
5901
- function redactedEnvKeys2(profile, env) {
5902
- if (!env)
6029
+ function redactedEnvKeys2(profile, env2) {
6030
+ if (!env2)
5903
6031
  return [];
5904
6032
  const patterns = unique3([...DEFAULT_ENV_REDACTIONS2, ...profile.env_redactions]).map((item) => item.toUpperCase());
5905
- return Object.keys(env).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
6033
+ return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
5906
6034
  }
5907
- function omittedEnvKeys(profile, env) {
5908
- if (!env)
6035
+ function omittedEnvKeys(profile, env2) {
6036
+ if (!env2)
5909
6037
  return [];
5910
6038
  if (profile.env_allowlist.includes("*"))
5911
6039
  return [];
5912
- return Object.keys(env).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
6040
+ return Object.keys(env2).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
5913
6041
  }
5914
6042
  function resolveFromCwd(cwd, path) {
5915
6043
  return normalizePath2(path.startsWith("/") ? path : `${cwd}/${path}`);
@@ -6292,15 +6420,23 @@ var init_event_hooks = __esm(() => {
6292
6420
  VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
6293
6421
  });
6294
6422
 
6295
- // node_modules/.bun/@hasna+events@0.1.16/node_modules/@hasna/events/dist/index.js
6423
+ // node_modules/.bun/@hasna+events@0.1.17/node_modules/@hasna/events/dist/index.js
6296
6424
  import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
6297
6425
  import { Buffer as Buffer2 } from "buffer";
6426
+ import { existsSync as existsSync22 } from "fs";
6427
+ import { join as join32 } from "path";
6298
6428
  import { existsSync as existsSync6 } from "fs";
6299
- import { homedir as homedir4 } from "os";
6300
- import { join as join6 } from "path";
6429
+ import { homedir as homedir22 } from "os";
6430
+ import { join as join22, resolve as resolve8 } from "path";
6431
+ import { homedir as homedir3 } from "os";
6432
+ import { join as join5 } from "path";
6301
6433
  import { createHmac, timingSafeEqual } from "crypto";
6434
+ import { lookup as dnsLookup } from "dns/promises";
6435
+ import { isIP } from "net";
6302
6436
  import { randomUUID as randomUUID2 } from "crypto";
6303
6437
  import { spawn } from "child_process";
6438
+ import { request as nodeHttpRequest } from "http";
6439
+ import { request as nodeHttpsRequest } from "https";
6304
6440
  import { randomUUID as randomUUID22 } from "crypto";
6305
6441
  function getPathValue(input, path) {
6306
6442
  return path.split(".").reduce((value, part) => {
@@ -6397,8 +6533,93 @@ function channelMatchesEvent(channel, event) {
6397
6533
  return true;
6398
6534
  return channel.filters.some((filter) => eventMatchesFilter(event, filter));
6399
6535
  }
6536
+ function assertApp(app) {
6537
+ if (typeof app !== "string" || app.length === 0) {
6538
+ throw new TypeError("paths: app must be a non-empty string");
6539
+ }
6540
+ if (!APP_SLUG_RE.test(app)) {
6541
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
6542
+ }
6543
+ }
6544
+ function envOf(options) {
6545
+ return options.env ?? process.env;
6546
+ }
6547
+ function envValue(options, kind) {
6548
+ const value = envOf(options)[KIND_ENV[kind]];
6549
+ return typeof value === "string" && value.length > 0 ? value : undefined;
6550
+ }
6551
+ function isMacOS(platform) {
6552
+ return platform === "darwin";
6553
+ }
6554
+ function baseDir(kind, options) {
6555
+ const override = envValue(options, kind);
6556
+ if (override)
6557
+ return override;
6558
+ const home = options.home ?? homedir3();
6559
+ const platform = options.platform ?? process.platform;
6560
+ if (isMacOS(platform)) {
6561
+ switch (kind) {
6562
+ case "config":
6563
+ case "data":
6564
+ return join5(home, "Library", "Application Support", "Hasna");
6565
+ case "cache":
6566
+ return join5(home, "Library", "Caches", "Hasna");
6567
+ case "state":
6568
+ return join5(home, "Library", "Logs", "Hasna");
6569
+ }
6570
+ }
6571
+ switch (kind) {
6572
+ case "config":
6573
+ return join5(home, ".config", "hasna");
6574
+ case "data":
6575
+ return join5(home, ".local", "share", "hasna");
6576
+ case "state":
6577
+ return join5(home, ".local", "state", "hasna");
6578
+ case "cache":
6579
+ return join5(home, ".cache", "hasna");
6580
+ }
6581
+ }
6582
+ function resolvePath(kind, options) {
6583
+ assertApp(options.app);
6584
+ const appSegment = options.internal === true ? join5("internal", options.app) : options.app;
6585
+ return join5(baseDir(kind, options), appSegment);
6586
+ }
6587
+ function dataDir2(options) {
6588
+ return resolvePath("data", options);
6589
+ }
6590
+ function effectiveHome2() {
6591
+ return process.env["HOME"] || process.env["USERPROFILE"] || homedir22();
6592
+ }
6593
+ function legacyHomeDir2() {
6594
+ return join22(effectiveHome2(), ".hasna", "events");
6595
+ }
6596
+ function resolverHome2() {
6597
+ return dataDir2({ app: "events", home: effectiveHome2() || undefined });
6598
+ }
6599
+ function adoptResolverHome2(resolved, env2 = process.env) {
6600
+ const dataOverride = env2.HASNA_DATA_HOME;
6601
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
6602
+ return true;
6603
+ return existsSync6(join22(resolved, EVENTS_STORE_SENTINEL_FILE));
6604
+ }
6605
+ function exactEventsHome() {
6606
+ const dir = process.env[HASNA_EVENTS_DIR_ENV];
6607
+ if (dir && dir.trim())
6608
+ return dir.trim();
6609
+ const home = process.env[HASNA_EVENTS_HOME_ENV];
6610
+ if (home && home.trim())
6611
+ return home.trim();
6612
+ return;
6613
+ }
6614
+ function getEventsHome() {
6615
+ const exact = exactEventsHome();
6616
+ if (exact)
6617
+ return resolve8(exact);
6618
+ const resolved = resolverHome2();
6619
+ return adoptResolverHome2(resolved) ? resolve8(resolved) : resolve8(legacyHomeDir2());
6620
+ }
6400
6621
  function getEventsDataDir(override) {
6401
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join6(homedir4(), ".hasna", "events");
6622
+ return override || getEventsHome();
6402
6623
  }
6403
6624
 
6404
6625
  class JsonEventsStore {
@@ -6407,12 +6628,12 @@ class JsonEventsStore {
6407
6628
  channelsPath;
6408
6629
  eventsPath;
6409
6630
  deliveriesPath;
6410
- constructor(dataDir2 = getEventsDataDir()) {
6411
- this.dataDir = dataDir2;
6412
- this.runtime = localJsonRuntime(dataDir2);
6413
- this.channelsPath = join6(dataDir2, "channels.json");
6414
- this.eventsPath = join6(dataDir2, "events.json");
6415
- this.deliveriesPath = join6(dataDir2, "deliveries.json");
6631
+ constructor(dataDir22 = getEventsDataDir()) {
6632
+ this.dataDir = dataDir22;
6633
+ this.runtime = localJsonRuntime(dataDir22);
6634
+ this.channelsPath = join32(dataDir22, "channels.json");
6635
+ this.eventsPath = join32(dataDir22, "events.json");
6636
+ this.deliveriesPath = join32(dataDir22, "deliveries.json");
6416
6637
  }
6417
6638
  async init() {
6418
6639
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -6529,7 +6750,7 @@ class JsonEventsStore {
6529
6750
  };
6530
6751
  }
6531
6752
  async ensureArrayFile(path) {
6532
- if (!existsSync6(path)) {
6753
+ if (!existsSync22(path)) {
6533
6754
  await writeFile(path, `[]
6534
6755
  `, { encoding: "utf-8", mode: 384 });
6535
6756
  }
@@ -6559,7 +6780,7 @@ class JsonEventsStore {
6559
6780
  });
6560
6781
  }
6561
6782
  }
6562
- function localJsonRuntime(dataDir2 = getEventsDataDir()) {
6783
+ function localJsonRuntime(dataDir22 = getEventsDataDir()) {
6563
6784
  return {
6564
6785
  mode: "local-files",
6565
6786
  name: "json-events-store",
@@ -6572,7 +6793,7 @@ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
6572
6793
  durable: true,
6573
6794
  idempotency: "best-effort-local",
6574
6795
  replayCursors: true,
6575
- description: `Local JSON files in ${dataDir2}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6796
+ description: `Local JSON files in ${dataDir22}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
6576
6797
  };
6577
6798
  }
6578
6799
  function encodeLocalJsonEventCursor(offset, options = {}) {
@@ -6643,6 +6864,177 @@ function signPayload(secret, timestamp2, body) {
6643
6864
  const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp2, body)).digest("hex");
6644
6865
  return `sha256=${digest}`;
6645
6866
  }
6867
+ function isPrivateAddress(address) {
6868
+ const normalized = stripZoneId(address);
6869
+ const version = isIP(normalized);
6870
+ if (version === 4) {
6871
+ const integer = ipv4ToInt(normalized);
6872
+ if (integer === undefined)
6873
+ return true;
6874
+ return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
6875
+ }
6876
+ if (version === 6) {
6877
+ const groups = ipv6Groups(normalized);
6878
+ if (!groups)
6879
+ return true;
6880
+ for (const prefix of IPV6_SPECIAL_PREFIXES) {
6881
+ if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
6882
+ continue;
6883
+ if (prefix.bits === 96 && groups[5] === 65535) {
6884
+ return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
6885
+ }
6886
+ if (prefix.bits === 16 && groups[0] === 8194) {
6887
+ return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
6888
+ }
6889
+ return true;
6890
+ }
6891
+ return false;
6892
+ }
6893
+ return true;
6894
+ }
6895
+ async function resolveWebhookTarget(url, policy = {}) {
6896
+ const hostname = normalizeHostname(url.hostname);
6897
+ const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
6898
+ if (allowlist.includes(hostname)) {
6899
+ const version2 = isIP(hostname);
6900
+ if (version2 === 4 || version2 === 6) {
6901
+ return { hostname, addresses: [hostname] };
6902
+ }
6903
+ const lookup2 = policy.lookup ?? defaultTargetLookup;
6904
+ let resolved2;
6905
+ try {
6906
+ resolved2 = await lookup2(hostname);
6907
+ } catch {
6908
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
6909
+ }
6910
+ if (!Array.isArray(resolved2) || resolved2.length === 0) {
6911
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
6912
+ }
6913
+ const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
6914
+ return { hostname, addresses };
6915
+ }
6916
+ const version = isIP(hostname);
6917
+ if (version === 4 || version === 6) {
6918
+ if (isPrivateAddress(hostname)) {
6919
+ throw new Error(`Webhook target ${hostname} is a private or special-use address`);
6920
+ }
6921
+ return { hostname, addresses: [hostname] };
6922
+ }
6923
+ const lookup = policy.lookup ?? defaultTargetLookup;
6924
+ let resolved;
6925
+ try {
6926
+ resolved = await lookup(hostname);
6927
+ } catch {
6928
+ throw new Error(`Webhook target ${hostname} could not be resolved`);
6929
+ }
6930
+ if (!Array.isArray(resolved) || resolved.length === 0) {
6931
+ throw new Error(`Webhook target ${hostname} resolved to no addresses`);
6932
+ }
6933
+ const allowed = [];
6934
+ for (const entry of resolved) {
6935
+ const address = normalizeHostname(entry.address);
6936
+ if (isPrivateAddress(address)) {
6937
+ if (allowlist.includes(address)) {
6938
+ allowed.push(address);
6939
+ continue;
6940
+ }
6941
+ throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
6942
+ }
6943
+ allowed.push(address);
6944
+ }
6945
+ if (allowed.length === 0) {
6946
+ throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
6947
+ }
6948
+ return { hostname, addresses: allowed };
6949
+ }
6950
+ function normalizeMaxRedirects(value) {
6951
+ if (value === undefined)
6952
+ return DEFAULT_MAX_REDIRECTS;
6953
+ if (!Number.isInteger(value) || value < 0)
6954
+ throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
6955
+ return value;
6956
+ }
6957
+ function normalizeHostname(hostname) {
6958
+ const lower = hostname.toLowerCase();
6959
+ if (lower.startsWith("[") && lower.endsWith("]"))
6960
+ return lower.slice(1, -1);
6961
+ return lower;
6962
+ }
6963
+ function stripZoneId(address) {
6964
+ const percent = address.indexOf("%");
6965
+ return percent === -1 ? address : address.slice(0, percent);
6966
+ }
6967
+ function ipv4ToInt(address) {
6968
+ const parts = address.split(".");
6969
+ if (parts.length !== 4)
6970
+ return;
6971
+ let value = 0;
6972
+ for (const part of parts) {
6973
+ if (!/^\d{1,3}$/.test(part))
6974
+ return;
6975
+ const octet = Number(part);
6976
+ if (octet > 255)
6977
+ return;
6978
+ value = value << 8 | octet;
6979
+ }
6980
+ return value >>> 0;
6981
+ }
6982
+ function ipv4IntToString(integer) {
6983
+ return [
6984
+ integer >>> 24 & 255,
6985
+ integer >>> 16 & 255,
6986
+ integer >>> 8 & 255,
6987
+ integer & 255
6988
+ ].join(".");
6989
+ }
6990
+ function ipv6Groups(address) {
6991
+ const raw = stripZoneId(address);
6992
+ const doubleColon = raw.indexOf("::");
6993
+ const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
6994
+ const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
6995
+ const parseGroups = (text) => {
6996
+ if (text === "")
6997
+ return [];
6998
+ const out = [];
6999
+ for (const part of text.split(":")) {
7000
+ if (part.includes(".")) {
7001
+ const v4 = ipv4ToInt(part);
7002
+ if (v4 === undefined)
7003
+ return;
7004
+ out.push(v4 >>> 16 & 65535, v4 & 65535);
7005
+ } else {
7006
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
7007
+ return;
7008
+ out.push(parseInt(part, 16));
7009
+ }
7010
+ }
7011
+ return out;
7012
+ };
7013
+ const head = parseGroups(headText);
7014
+ if (!head)
7015
+ return;
7016
+ const tail = parseGroups(tailText);
7017
+ if (!tail)
7018
+ return;
7019
+ const total = head.length + tail.length;
7020
+ if (doubleColon === -1) {
7021
+ return total === 8 ? head : undefined;
7022
+ }
7023
+ if (total >= 8)
7024
+ return;
7025
+ return [...head, ...new Array(8 - total).fill(0), ...tail];
7026
+ }
7027
+ function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
7028
+ let remaining = prefixBits;
7029
+ for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
7030
+ const take = Math.min(16, remaining);
7031
+ const mask = 65535 << 16 - take & 65535;
7032
+ if ((groups[index] & mask) !== (prefixGroups[index] & mask))
7033
+ return false;
7034
+ remaining -= take;
7035
+ }
7036
+ return true;
7037
+ }
6646
7038
  function now2() {
6647
7039
  return new Date().toISOString();
6648
7040
  }
@@ -6673,9 +7065,18 @@ function buildWebhookRequest(event, channel, options = {}) {
6673
7065
  }
6674
7066
  return { body, headers };
6675
7067
  }
7068
+ function normalizeWebhookUrl(raw) {
7069
+ const url = new URL(raw);
7070
+ if (url.username !== "" || url.password !== "") {
7071
+ url.username = "";
7072
+ url.password = "";
7073
+ }
7074
+ return url.toString();
7075
+ }
6676
7076
  async function dispatchWebhook(event, channel, options = {}) {
6677
7077
  if (!channel.webhook)
6678
7078
  throw new Error(`Channel ${channel.id} has no webhook config`);
7079
+ const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
6679
7080
  const startedAt = now2();
6680
7081
  let secret = channel.webhook.secret;
6681
7082
  if (channel.webhook.secretRef) {
@@ -6692,10 +7093,14 @@ async function dispatchWebhook(event, channel, options = {}) {
6692
7093
  }
6693
7094
  const timestamp2 = (options.now?.() ?? new Date).toISOString();
6694
7095
  const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
7096
+ const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
7097
+ if (validateTargets) {
7098
+ return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
7099
+ }
6695
7100
  const controller = new AbortController;
6696
7101
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
6697
7102
  try {
6698
- const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
7103
+ const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
6699
7104
  method: "POST",
6700
7105
  headers,
6701
7106
  body,
@@ -6723,6 +7128,130 @@ async function dispatchWebhook(event, channel, options = {}) {
6723
7128
  clearTimeout(timeout);
6724
7129
  }
6725
7130
  }
7131
+ function isRedirectStatus(status) {
7132
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
7133
+ }
7134
+ function redirectKeepsBody(status) {
7135
+ return status === 307 || status === 308;
7136
+ }
7137
+ async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
7138
+ const isHttps = target.protocol === "https:";
7139
+ if (!isHttps && target.protocol !== "http:") {
7140
+ throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
7141
+ }
7142
+ const defaultPort = isHttps ? 443 : 80;
7143
+ const port = target.port ? Number(target.port) : defaultPort;
7144
+ const requestOptions = {
7145
+ hostname: target.hostname,
7146
+ port,
7147
+ path: `${target.pathname}${target.search}`,
7148
+ method,
7149
+ headers,
7150
+ ...tls?.ca ? { ca: tls.ca } : {},
7151
+ lookup: (hostname, _options, callback) => {
7152
+ const entries = addresses.map((address) => ({
7153
+ address,
7154
+ family: address.includes(":") ? 6 : 4
7155
+ }));
7156
+ callback(null, entries);
7157
+ }
7158
+ };
7159
+ return new Promise((resolve22, reject) => {
7160
+ const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
7161
+ const onAbort = () => {
7162
+ const error = new Error("The operation was aborted.");
7163
+ error.name = "AbortError";
7164
+ request.destroy(error);
7165
+ };
7166
+ if (signal.aborted)
7167
+ onAbort();
7168
+ else
7169
+ signal.addEventListener("abort", onAbort, { once: true });
7170
+ request.on("error", reject);
7171
+ if (body !== undefined)
7172
+ request.write(body);
7173
+ request.end();
7174
+ function onResponse(response) {
7175
+ const chunks = [];
7176
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
7177
+ response.on("error", reject);
7178
+ response.on("end", () => {
7179
+ const headersRecord = {};
7180
+ for (const [name, value] of Object.entries(response.headers)) {
7181
+ if (typeof value === "string")
7182
+ headersRecord[name] = value;
7183
+ else if (Array.isArray(value))
7184
+ headersRecord[name] = value.join(", ");
7185
+ }
7186
+ resolve22(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
7187
+ });
7188
+ }
7189
+ });
7190
+ }
7191
+ async function dispatchValidatedWebhook(event, channel, input) {
7192
+ const { body, headers, startedAt, options } = input;
7193
+ const webhook = channel.webhook;
7194
+ if (!webhook)
7195
+ throw new Error(`Channel ${channel.id} has no webhook config`);
7196
+ const policy = options.webhookTargetPolicy ?? {};
7197
+ const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
7198
+ const controller = new AbortController;
7199
+ const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
7200
+ try {
7201
+ let target = new URL(normalizeWebhookUrl(webhook.url));
7202
+ let requestHeaders = headers;
7203
+ let method = "POST";
7204
+ let requestBody = body;
7205
+ let redirectsFollowed = 0;
7206
+ for (;; ) {
7207
+ const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
7208
+ throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
7209
+ });
7210
+ const response = options.fetchImpl ? await options.fetchImpl(target, {
7211
+ method,
7212
+ headers: requestHeaders,
7213
+ body: requestBody,
7214
+ signal: controller.signal,
7215
+ redirect: "manual"
7216
+ }) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
7217
+ const location = response.headers.get("location");
7218
+ if (isRedirectStatus(response.status) && location) {
7219
+ if (redirectsFollowed >= maxRedirects) {
7220
+ return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
7221
+ }
7222
+ redirectsFollowed += 1;
7223
+ const next = new URL(location, target);
7224
+ target = next;
7225
+ if (!redirectKeepsBody(response.status)) {
7226
+ method = "GET";
7227
+ requestBody = undefined;
7228
+ requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
7229
+ }
7230
+ continue;
7231
+ }
7232
+ const responseBody = truncate(await response.text());
7233
+ return {
7234
+ attempt: 1,
7235
+ status: response.ok ? "success" : "failed",
7236
+ startedAt,
7237
+ completedAt: now2(),
7238
+ responseStatus: response.status,
7239
+ responseBody,
7240
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
7241
+ };
7242
+ }
7243
+ } catch (error) {
7244
+ return {
7245
+ attempt: 1,
7246
+ status: "failed",
7247
+ startedAt,
7248
+ completedAt: now2(),
7249
+ error: error instanceof Error ? error.message : String(error)
7250
+ };
7251
+ } finally {
7252
+ clearTimeout(timeout);
7253
+ }
7254
+ }
6726
7255
  function failedAttempt(startedAt, error) {
6727
7256
  return {
6728
7257
  attempt: 1,
@@ -6737,7 +7266,7 @@ async function dispatchCommand(event, channel) {
6737
7266
  throw new Error(`Channel ${channel.id} has no command config`);
6738
7267
  const startedAt = now2();
6739
7268
  const eventJson = JSON.stringify(event);
6740
- const env = {
7269
+ const env2 = {
6741
7270
  ...process.env,
6742
7271
  ...channel.command.env,
6743
7272
  HASNA_CHANNEL_ID: channel.id,
@@ -6751,10 +7280,10 @@ async function dispatchCommand(event, channel) {
6751
7280
  HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
6752
7281
  HASNA_EVENT_JSON: eventJson
6753
7282
  };
6754
- return new Promise((resolve8) => {
7283
+ return new Promise((resolve22) => {
6755
7284
  const child = spawn(channel.command.command, channel.command.args ?? [], {
6756
7285
  cwd: channel.command.cwd,
6757
- env,
7286
+ env: env2,
6758
7287
  stdio: ["pipe", "pipe", "pipe"]
6759
7288
  });
6760
7289
  let stdout = "";
@@ -6769,7 +7298,7 @@ async function dispatchCommand(event, channel) {
6769
7298
  });
6770
7299
  child.on("error", (error) => {
6771
7300
  clearTimeout(timeout);
6772
- resolve8({
7301
+ resolve22({
6773
7302
  attempt: 1,
6774
7303
  status: "failed",
6775
7304
  startedAt,
@@ -6782,7 +7311,7 @@ async function dispatchCommand(event, channel) {
6782
7311
  child.on("close", (code, signal) => {
6783
7312
  clearTimeout(timeout);
6784
7313
  const success = code === 0;
6785
- resolve8({
7314
+ resolve22({
6786
7315
  attempt: 1,
6787
7316
  status: success ? "success" : "failed",
6788
7317
  startedAt,
@@ -6918,7 +7447,9 @@ class EventsClient {
6918
7447
  this.transportOptions = {
6919
7448
  fetchImpl: options.fetchImpl,
6920
7449
  secretResolver: options.secretResolver,
6921
- now: options.now
7450
+ now: options.now,
7451
+ tls: options.tls,
7452
+ webhookTargetPolicy: options.webhookTargetPolicy
6922
7453
  };
6923
7454
  this.catalog = options.catalog ?? defaultEventTypeCatalog;
6924
7455
  this.validateCatalogTypes = options.validateCatalogTypes ?? false;
@@ -7123,10 +7654,52 @@ function normalizeRetryPolicy(policy) {
7123
7654
  multiplier: Math.max(1, policy?.multiplier ?? 2)
7124
7655
  };
7125
7656
  }
7126
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
7127
- var init_dist2 = __esm(() => {
7128
- DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7129
- EventValidationError = class EventValidationError extends Error {
7657
+ var KIND_ENV, APP_SLUG_RE, HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", EVENTS_STORE_SENTINEL_FILE = "events.json", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, DEFAULT_MAX_REDIRECTS = 5, IPV4_PRIVATE_RANGES, IPV6_SPECIAL_PREFIXES, defaultTargetLookup = async (hostname) => {
7658
+ return dnsLookup(hostname, { all: true, verbatim: false });
7659
+ }, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
7660
+ var init_dist = __esm(() => {
7661
+ KIND_ENV = {
7662
+ config: "HASNA_CONFIG_HOME",
7663
+ data: "HASNA_DATA_HOME",
7664
+ state: "HASNA_STATE_HOME",
7665
+ cache: "HASNA_CACHE_HOME"
7666
+ };
7667
+ APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7668
+ DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
7669
+ IPV4_PRIVATE_RANGES = [
7670
+ [0, 16777215],
7671
+ [167772160, 184549375],
7672
+ [1681915904, 1686110207],
7673
+ [2130706432, 2147483647],
7674
+ [2851995648, 2852061183],
7675
+ [2886729728, 2887778303],
7676
+ [3221225472, 3221225727],
7677
+ [3221225984, 3221226239],
7678
+ [3227017984, 3227018239],
7679
+ [3232235520, 3232301055],
7680
+ [3323068416, 3323199487],
7681
+ [3325256704, 3325256959],
7682
+ [3405803776, 3405804031],
7683
+ [3758096384, 4294967295]
7684
+ ];
7685
+ IPV6_SPECIAL_PREFIXES = [
7686
+ { groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
7687
+ { groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
7688
+ { groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
7689
+ { groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
7690
+ { groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
7691
+ { groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
7692
+ { groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
7693
+ { groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
7694
+ { groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
7695
+ { groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
7696
+ { groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
7697
+ { groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
7698
+ { groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
7699
+ { groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
7700
+ { groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
7701
+ ];
7702
+ EventValidationError = class EventValidationError extends Error {
7130
7703
  eventType;
7131
7704
  issues;
7132
7705
  constructor(eventType, issues) {
@@ -7149,6 +7722,8 @@ function rowToTaskList(row) {
7149
7722
  };
7150
7723
  }
7151
7724
  function createTaskList(input, db) {
7725
+ if (input.status !== undefined)
7726
+ throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
7152
7727
  const d = db || getDatabase();
7153
7728
  return d.transaction(() => {
7154
7729
  const id = uuid();
@@ -7190,6 +7765,8 @@ function listTaskLists(projectId, db) {
7190
7765
  return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
7191
7766
  }
7192
7767
  function updateTaskList(id, input, db) {
7768
+ if (input.status !== undefined)
7769
+ throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
7193
7770
  const d = db || getDatabase();
7194
7771
  return d.transaction(() => {
7195
7772
  const existing = getTaskList(id, d);
@@ -7547,7 +8124,7 @@ function emitSharedTaskEventQuiet(input) {
7547
8124
  }
7548
8125
  var SOURCE = "todos";
7549
8126
  var init_shared_events = __esm(() => {
7550
- init_dist2();
8127
+ init_dist();
7551
8128
  init_database();
7552
8129
  init_projects();
7553
8130
  init_task_lists();
@@ -10132,7 +10709,9 @@ function listTasks(filter = {}, db) {
10132
10709
  params.push(decoded.p, decoded.p, decoded.c, decoded.p, decoded.c, decoded.i);
10133
10710
  } catch {}
10134
10711
  }
10135
- if (!filter.include_archived) {
10712
+ if (filter.archived_only) {
10713
+ conditions.push("archived_at IS NOT NULL");
10714
+ } else if (!filter.include_archived) {
10136
10715
  conditions.push("archived_at IS NULL");
10137
10716
  }
10138
10717
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -10145,7 +10724,8 @@ function listTasks(filter = {}, db) {
10145
10724
  params.push(filter.offset);
10146
10725
  }
10147
10726
  }
10148
- const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${PRIORITY_RANK}, created_at DESC, id ASC${limitClause}`).all(...params);
10727
+ const orderBy = filter.archived_only ? "archived_at DESC, id DESC" : `${PRIORITY_RANK}, created_at DESC, id ASC`;
10728
+ const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${orderBy}${limitClause}`).all(...params);
10149
10729
  return rows.map(rowToTask2);
10150
10730
  }
10151
10731
  function getTaskByFingerprint(fingerprint, db) {
@@ -10288,7 +10868,9 @@ function countTasks(filter = {}, db) {
10288
10868
  }
10289
10869
  }
10290
10870
  addMetadataConditions(filter.metadata, conditions, params);
10291
- if (!filter.include_archived) {
10871
+ if (filter.archived_only) {
10872
+ conditions.push("archived_at IS NOT NULL");
10873
+ } else if (!filter.include_archived) {
10292
10874
  conditions.push("archived_at IS NULL");
10293
10875
  }
10294
10876
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -10425,6 +11007,10 @@ function updateTaskStored(id, input, db) {
10425
11007
  sets.push("completed_at = ?");
10426
11008
  params.push(input.completed_at);
10427
11009
  }
11010
+ if (input.archived_at !== undefined) {
11011
+ sets.push("archived_at = ?");
11012
+ params.push(input.archived_at);
11013
+ }
10428
11014
  if (input.confidence !== undefined) {
10429
11015
  sets.push("confidence = ?");
10430
11016
  params.push(input.confidence);
@@ -10488,6 +11074,8 @@ function updateTaskStored(id, input, db) {
10488
11074
  logTaskChange(id, "update", "parent_id", task.parent_id, input.parent_id, agentId, d);
10489
11075
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
10490
11076
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
11077
+ if (input.archived_at !== undefined && input.archived_at !== task.archived_at)
11078
+ logTaskChange(id, "update", "archived_at", task.archived_at, input.archived_at, agentId, d);
10491
11079
  if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
10492
11080
  logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
10493
11081
  if (input.approved_by !== undefined)
@@ -11188,7 +11776,13 @@ function resolveCreateSlug(input, projectId, db) {
11188
11776
  }
11189
11777
  return nextPlanSlug(planSlugBase2(input.name), projectId, db);
11190
11778
  }
11779
+ function assertLegacyPlanInput(input) {
11780
+ if (input.start_date !== undefined || input.end_date !== undefined || input.status === "planning" || input.status === "cancelled") {
11781
+ throw new Error("Plan calendar dates and planning/cancelled statuses require the shared Todos API; explicit SQLite storage cannot retain them");
11782
+ }
11783
+ }
11191
11784
  function createPlan(input, db) {
11785
+ assertLegacyPlanInput(input);
11192
11786
  const d = db || getDatabase();
11193
11787
  const id = uuid();
11194
11788
  const timestamp2 = now();
@@ -11269,6 +11863,7 @@ function updatePlanStored(id, input, db) {
11269
11863
  return updated;
11270
11864
  }
11271
11865
  function updatePlan(id, input, db) {
11866
+ assertLegacyPlanInput(input);
11272
11867
  const d = db || getDatabase();
11273
11868
  return d.transaction(() => {
11274
11869
  guardPlanRowsSqlite([id], d);
@@ -11743,27 +12338,27 @@ var init_boards = __esm(() => {
11743
12338
  // src/lib/artifact-store.ts
11744
12339
  import { createHash as createHash3 } from "crypto";
11745
12340
  import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
11746
- import { basename, dirname as dirname4, join as join7, resolve as resolve8 } from "path";
12341
+ import { basename, dirname as dirname4, join as join6, resolve as resolve9 } from "path";
11747
12342
  import { tmpdir as tmpdir2 } from "os";
11748
12343
  function isInMemoryDb2(path) {
11749
12344
  return path === ":memory:" || path.startsWith("file::memory:");
11750
12345
  }
11751
12346
  function artifactStoreRoot() {
11752
12347
  if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
11753
- return resolve8(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
12348
+ return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
11754
12349
  if (process.env["TODOS_ARTIFACTS_DIR"])
11755
- return resolve8(process.env["TODOS_ARTIFACTS_DIR"]);
12350
+ return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
11756
12351
  const dbPath = getDatabasePath();
11757
12352
  if (isInMemoryDb2(dbPath))
11758
- return join7(tmpdir2(), "hasna-todos-artifacts");
11759
- return join7(dirname4(resolve8(dbPath)), "artifacts");
12353
+ return join6(tmpdir2(), "hasna-todos-artifacts");
12354
+ return join6(dirname4(resolve9(dbPath)), "artifacts");
11760
12355
  }
11761
12356
  function artifactStorePath(relativePath) {
11762
12357
  const normalized = relativePath.replace(/\\/g, "/");
11763
12358
  if (normalized.includes("..") || normalized.startsWith("/") || normalized.length === 0) {
11764
12359
  throw new Error("Invalid artifact store path");
11765
12360
  }
11766
- return join7(artifactStoreRoot(), normalized);
12361
+ return join6(artifactStoreRoot(), normalized);
11767
12362
  }
11768
12363
  function sha256(buffer) {
11769
12364
  return createHash3("sha256").update(buffer).digest("hex");
@@ -11803,7 +12398,7 @@ function mediaTypeFor(path, textLike) {
11803
12398
  return "application/octet-stream";
11804
12399
  }
11805
12400
  function storeArtifactContent(input) {
11806
- const sourcePath = resolve8(input.path);
12401
+ const sourcePath = resolve9(input.path);
11807
12402
  if (!existsSync8(sourcePath))
11808
12403
  return null;
11809
12404
  const sourceStat = statSync2(sourcePath);
@@ -11821,7 +12416,7 @@ function storeArtifactContent(input) {
11821
12416
  redactionStatus = "redacted";
11822
12417
  }
11823
12418
  const storedSha = sha256(storedBuffer);
11824
- const relativePath = join7("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
12419
+ const relativePath = join6("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
11825
12420
  const destination = artifactStorePath(relativePath);
11826
12421
  if (!existsSync8(destination)) {
11827
12422
  mkdirSync4(dirname4(destination), { recursive: true });
@@ -11958,19 +12553,19 @@ function importStoredArtifactContent(content) {
11958
12553
  }
11959
12554
  function getArtifactStoreRoot(dbPath) {
11960
12555
  if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
11961
- return resolve8(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
12556
+ return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
11962
12557
  if (process.env["TODOS_ARTIFACTS_DIR"])
11963
- return resolve8(process.env["TODOS_ARTIFACTS_DIR"]);
12558
+ return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
11964
12559
  const path = dbPath ?? getDatabasePath();
11965
12560
  if (isInMemoryDb2(path))
11966
- return join7(tmpdir2(), "hasna-todos-artifacts");
11967
- return join7(dirname4(resolve8(path)), "artifacts");
12561
+ return join6(tmpdir2(), "hasna-todos-artifacts");
12562
+ return join6(dirname4(resolve9(path)), "artifacts");
11968
12563
  }
11969
12564
  function computeContentHash(path) {
11970
- return sha256(readFileSync4(resolve8(path)));
12565
+ return sha256(readFileSync4(resolve9(path)));
11971
12566
  }
11972
12567
  function storeArtifactFile(input) {
11973
- const sourcePath = resolve8(input.sourcePath);
12568
+ const sourcePath = resolve9(input.sourcePath);
11974
12569
  if (!existsSync8(sourcePath)) {
11975
12570
  throw new Error(`Source file not found: ${input.sourcePath}`);
11976
12571
  }
@@ -11984,7 +12579,7 @@ function storeArtifactFile(input) {
11984
12579
  let localPath = sourcePath;
11985
12580
  if (storageMode === "copy") {
11986
12581
  const fileName = input.name && input.name.trim().length > 0 ? basename(input.name) : basename(sourcePath);
11987
- const destination = join7(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
12582
+ const destination = join6(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
11988
12583
  mkdirSync4(dirname4(destination), { recursive: true });
11989
12584
  writeFileSync2(destination, buffer);
11990
12585
  localPath = destination;
@@ -12019,7 +12614,7 @@ function buildArtifactExportManifest(artifacts, dbPath) {
12019
12614
  };
12020
12615
  }
12021
12616
  function writeArtifactExportManifest(manifest, outputPath) {
12022
- const destination = resolve8(outputPath);
12617
+ const destination = resolve9(outputPath);
12023
12618
  mkdirSync4(dirname4(destination), { recursive: true });
12024
12619
  writeFileSync2(destination, `${JSON.stringify(manifest, null, 2)}
12025
12620
  `);
@@ -12707,7 +13302,9 @@ function finishTaskRunTransaction(input, db) {
12707
13302
  const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
12708
13303
  const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
12709
13304
  if (!run) {
12710
- throw new Error(input.run_id ? `Run not found: ${input.run_id}` : "runs finish requires a run id or --key");
13305
+ if (input.run_id)
13306
+ throw new InputValidationError(`Run not found: ${input.run_id}`, "Pass an existing run id, or --key.");
13307
+ throw new InputValidationError("runs finish requires a run id or --key", "Pass run_id, or key with task_id.");
12711
13308
  }
12712
13309
  if (input.task_id && run.task_id !== input.task_id) {
12713
13310
  throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
@@ -13335,6 +13932,7 @@ function searchTasks(options, projectId, taskListId, db) {
13335
13932
 
13336
13933
  // src/storage/local-sqlite.ts
13337
13934
  init_tasks();
13935
+ init_task_graph();
13338
13936
  init_projects();
13339
13937
  init_plans();
13340
13938
 
@@ -13692,9 +14290,9 @@ function normalizeGeneratedAgentNames(db) {
13692
14290
 
13693
14291
  // src/db/agents.ts
13694
14292
  function getActiveWindowMs() {
13695
- const env = process.env["TODOS_AGENT_TIMEOUT_MS"];
13696
- if (env) {
13697
- const parsed = parseInt(env, 10);
14293
+ const env2 = process.env["TODOS_AGENT_TIMEOUT_MS"];
14294
+ if (env2) {
14295
+ const parsed = parseInt(env2, 10);
13698
14296
  if (!isNaN(parsed) && parsed > 0)
13699
14297
  return parsed;
13700
14298
  }
@@ -13898,6 +14496,19 @@ function listAgents(opts, db) {
13898
14496
  }
13899
14497
  return d.query("SELECT * FROM agents WHERE status = 'active' ORDER BY name").all().map(rowToAgent);
13900
14498
  }
14499
+ function listAgentsPage(options, db) {
14500
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 500) {
14501
+ throw new Error("Agent page limit must be an integer from 1 to 500");
14502
+ }
14503
+ if (!Number.isSafeInteger(options.offset) || options.offset < 0) {
14504
+ throw new Error("Agent page offset must be a non-negative integer");
14505
+ }
14506
+ const d = db || getDatabase();
14507
+ const where = options.include_archived ? "" : "WHERE status = 'active'";
14508
+ const total = d.query(`SELECT COUNT(*) AS total FROM agents ${where}`).get().total;
14509
+ const agents = d.query(`SELECT * FROM agents ${where} ORDER BY LOWER(name), id LIMIT ? OFFSET ?`).all(options.limit, options.offset).map(rowToAgent);
14510
+ return { agents, total };
14511
+ }
13901
14512
  function updateAgentActivity(id, db) {
13902
14513
  const d = db || getDatabase();
13903
14514
  d.run("UPDATE agents SET last_seen_at = ? WHERE id = ?", [now(), id]);
@@ -14540,6 +15151,171 @@ function rollbackPlanProjectLinkSqlite(input, db) {
14540
15151
  })();
14541
15152
  }
14542
15153
 
15154
+ // src/storage/machine-registry.ts
15155
+ import { randomUUID as randomUUID3 } from "crypto";
15156
+ import { isDeepStrictEqual } from "util";
15157
+ class MachineRegistryError extends Error {
15158
+ status;
15159
+ constructor(message, status = 409) {
15160
+ super(message);
15161
+ this.status = status;
15162
+ }
15163
+ }
15164
+ var fields = new Set(["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"]);
15165
+ function validateMachines(value) {
15166
+ if (!Array.isArray(value) || value.length > 1e4)
15167
+ throw new MachineRegistryError("machines must be an array of at most 10000 complete records", 400);
15168
+ for (const row of value) {
15169
+ if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some((key) => !fields.has(key)))
15170
+ throw new MachineRegistryError("Invalid machine record fields", 400);
15171
+ for (const key of ["id", "name", "last_seen_at", "created_at"])
15172
+ if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 1024)
15173
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15174
+ for (const key of ["hostname", "platform", "ssh_address", "archived_at"])
15175
+ if (row[key] !== null && typeof row[key] !== "string")
15176
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15177
+ for (const key of ["last_seen_at", "created_at", "archived_at"])
15178
+ if (row[key] !== null && !Number.isFinite(Date.parse(row[key])))
15179
+ throw new MachineRegistryError(`Invalid machine ${key}`, 400);
15180
+ if (typeof row.is_primary !== "boolean" || !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
15181
+ throw new MachineRegistryError("Invalid machine flags or metadata", 400);
15182
+ if (!isDeepStrictEqual(JSON.parse(JSON.stringify(row.metadata)), row.metadata))
15183
+ throw new MachineRegistryError("Machine metadata must preserve JSON values exactly", 400);
15184
+ if (JSON.stringify(row.metadata).length > 262144)
15185
+ throw new MachineRegistryError("Machine metadata is too large", 400);
15186
+ if (row.is_primary && row.archived_at !== null)
15187
+ throw new MachineRegistryError("An archived machine cannot be primary", 400);
15188
+ }
15189
+ return value;
15190
+ }
15191
+ function planMachineImport(existing, incoming) {
15192
+ const rows = validateMachines(incoming);
15193
+ const byId = new Map(existing.map((row) => [row.id, row]));
15194
+ const byName = new Map(existing.map((row) => [row.name, row.id]));
15195
+ const insert = [];
15196
+ let skipped = 0;
15197
+ for (const row of rows) {
15198
+ const prior = byId.get(row.id);
15199
+ if (prior && !isDeepStrictEqual(prior, row))
15200
+ throw new MachineRegistryError("Machine migration conflicts with an existing identity; reconcile before retrying");
15201
+ if (byName.has(row.name) && byName.get(row.name) !== row.id)
15202
+ throw new MachineRegistryError("Machine name belongs to a different identity; migration was not applied");
15203
+ if (prior)
15204
+ skipped++;
15205
+ else {
15206
+ insert.push(row);
15207
+ byId.set(row.id, row);
15208
+ byName.set(row.name, row.id);
15209
+ }
15210
+ }
15211
+ if ([...byId.values()].filter((row) => row.is_primary).length > 1)
15212
+ throw new MachineRegistryError("Machine migration would create multiple primary machines");
15213
+ return { rows: insert, skipped };
15214
+ }
15215
+ function createPostgresMachineRegistry(client, service, table, ensure) {
15216
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
15217
+ throw new Error("Invalid machine registry table");
15218
+ async function list(connection = client) {
15219
+ await ensure();
15220
+ const result = await connection.query(`SELECT payload FROM ${table} WHERE service=$1 AND object_type='machines' AND deleted_at IS NULL ORDER BY object_id`, [service]);
15221
+ return validateMachines(result.rows.map((row) => typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload));
15222
+ }
15223
+ return { list, async execute(input) {
15224
+ if (!input || typeof input !== "object" || Array.isArray(input) || !["register", "heartbeat", "set-primary", "archive", "unarchive", "delete", "import"].includes(input.action))
15225
+ throw new MachineRegistryError("Invalid machine operation", 400);
15226
+ if (Object.keys(input).some((key) => !["action", "name", "id", "options", "machines"].includes(key)))
15227
+ throw new MachineRegistryError("Unknown machine operation fields", 400);
15228
+ if (input.options !== undefined && (!input.options || typeof input.options !== "object" || Array.isArray(input.options)))
15229
+ throw new MachineRegistryError("Invalid machine options", 400);
15230
+ await ensure();
15231
+ if (!client.transaction)
15232
+ throw new MachineRegistryError("Machine registry requires a transactional server backend", 501);
15233
+ return client.transaction(async (tx) => {
15234
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${service}:machine-registry`]);
15235
+ const current = await list(tx);
15236
+ let changed = [];
15237
+ let skipped = 0;
15238
+ let machine;
15239
+ let deleted;
15240
+ const stamp = new Date().toISOString();
15241
+ if (input.action === "import") {
15242
+ const plan = planMachineImport(current, input.machines);
15243
+ const ids = plan.rows.map((row) => row.id);
15244
+ if (ids.length) {
15245
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id IN (SELECT jsonb_array_elements_text($2::text::jsonb)) AND deleted_at IS NOT NULL`, [service, JSON.stringify(ids)]);
15246
+ if (retired.rows.length)
15247
+ throw new MachineRegistryError("Machine migration contains a retired identity");
15248
+ }
15249
+ changed = plan.rows;
15250
+ skipped = plan.skipped;
15251
+ } else {
15252
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 1024)
15253
+ throw new MachineRegistryError("Machine name or ID is required", 400);
15254
+ const matches = current.filter((row) => row.name === input.name || row.id === input.name);
15255
+ if (matches.length > 1)
15256
+ throw new MachineRegistryError("Machine selector is ambiguous between a name and another identity; use an unambiguous selector");
15257
+ const prior = matches[0];
15258
+ if (input.action === "register" || input.action === "heartbeat") {
15259
+ const opts = input.options ?? {};
15260
+ const allowed = new Set(["hostname", "platform", "ssh_address", "primary", "tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"]);
15261
+ for (const [key, value] of Object.entries(opts)) {
15262
+ if (!allowed.has(key) || (key === "primary" ? typeof value !== "boolean" : typeof value !== "string" || value.length > 4096))
15263
+ throw new MachineRegistryError("Invalid machine registration options", 400);
15264
+ }
15265
+ if (input.id !== undefined && (typeof input.id !== "string" || !input.id.trim() || input.id.length > 1024))
15266
+ throw new MachineRegistryError("Invalid stable machine ID", 400);
15267
+ if (prior && input.id && input.id !== prior.id)
15268
+ throw new MachineRegistryError("Machine registration ID does not match existing identity");
15269
+ if (!prior && input.id && current.some((row) => row.id === input.id))
15270
+ throw new MachineRegistryError("Machine ID already belongs to another name");
15271
+ const metadata = { ...prior?.metadata };
15272
+ for (const key of ["tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"])
15273
+ if (opts[key] !== undefined)
15274
+ metadata[key] = opts[key];
15275
+ if (!prior && input.id) {
15276
+ const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id=$2 AND deleted_at IS NOT NULL`, [service, input.id]);
15277
+ if (retired.rows.length)
15278
+ throw new MachineRegistryError("Machine identity has been retired");
15279
+ }
15280
+ machine = { id: prior?.id ?? input.id ?? randomUUID3(), name: prior?.name ?? input.name, hostname: opts.hostname ?? prior?.hostname ?? null, platform: opts.platform ?? prior?.platform ?? null, ssh_address: opts.ssh_address ?? prior?.ssh_address ?? null, created_at: prior?.created_at ?? stamp, last_seen_at: stamp, archived_at: prior?.archived_at ?? null, is_primary: opts.primary === true || (prior?.is_primary ?? false), metadata };
15281
+ if (machine.archived_at && machine.is_primary)
15282
+ throw new MachineRegistryError("Cannot set archived machine as primary");
15283
+ changed = [machine];
15284
+ if (opts.primary === true)
15285
+ changed.push(...current.filter((row) => row.is_primary && row.id !== machine.id).map((row) => ({ ...row, is_primary: false })));
15286
+ } else {
15287
+ if (!prior)
15288
+ throw new MachineRegistryError("Machine not found", 404);
15289
+ machine = { ...prior };
15290
+ if (input.action === "set-primary") {
15291
+ if (prior.archived_at)
15292
+ throw new MachineRegistryError("Cannot set archived machine as primary");
15293
+ machine.is_primary = true;
15294
+ changed = current.filter((row) => row.is_primary && row.id !== prior.id).map((row) => ({ ...row, is_primary: false }));
15295
+ } else if (input.action === "archive" || input.action === "delete") {
15296
+ if (prior.is_primary)
15297
+ throw new MachineRegistryError("Cannot archive or delete the primary machine");
15298
+ const refs = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type<>'machines' AND deleted_at IS NULL AND payload->>'machine_id'=$2 AND ($3='delete' OR (object_type='tasks' AND payload->>'status' IN ('pending','in_progress'))) LIMIT 1`, [service, prior.id, input.action]);
15299
+ if (refs.rows.length)
15300
+ throw new MachineRegistryError("Machine still has referenced records or active tasks");
15301
+ machine.archived_at = stamp;
15302
+ deleted = input.action === "delete" ? true : undefined;
15303
+ } else if (input.action === "unarchive")
15304
+ machine.archived_at = null;
15305
+ else
15306
+ throw new MachineRegistryError("Unknown machine action", 400);
15307
+ changed.push(machine);
15308
+ }
15309
+ }
15310
+ for (const row of changed) {
15311
+ await tx.query(`INSERT INTO ${table} (service,object_type,object_id,payload,updated_at,deleted_at,version) VALUES ($1,'machines',$2,$3::text::jsonb,$4,$5,1) ON CONFLICT (service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=COALESCE(${table}.version,0)+1`, [service, row.id, JSON.stringify(row), stamp, deleted && row.id === machine?.id ? stamp : null]);
15312
+ }
15313
+ const machines = await list(tx);
15314
+ return { schema_version: 2, machines, ...machine ? { machine } : {}, inserted: changed.filter((row) => !current.some((old) => old.id === row.id)).length, skipped, ...deleted ? { deleted } : {} };
15315
+ });
15316
+ } };
15317
+ }
15318
+
14543
15319
  // src/storage/sqlite-snapshot.ts
14544
15320
  init_database();
14545
15321
  init_audit();
@@ -14753,6 +15529,7 @@ function exportSqliteTodosStorageSnapshot(db) {
14753
15529
  return {
14754
15530
  exportedAt: new Date().toISOString(),
14755
15531
  source: "sqlite",
15532
+ machines: exportSqliteMachines(d),
14756
15533
  tasks: listTasks({ include_archived: true }, d),
14757
15534
  projects: listProjects(d),
14758
15535
  projectMachinePaths: listProjectMachinePaths(d),
@@ -14766,7 +15543,6 @@ function exportSqliteTodosStorageSnapshot(db) {
14766
15543
  };
14767
15544
  }
14768
15545
  function importSqliteTodosStorageSnapshot(snapshot, db) {
14769
- const d = db ?? getDatabase();
14770
15546
  const result = {
14771
15547
  inserted: 0,
14772
15548
  updated: 0,
@@ -14774,6 +15550,22 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14774
15550
  skipped: 0,
14775
15551
  errors: []
14776
15552
  };
15553
+ if (snapshot.taskLists.some((row) => row.status !== undefined)) {
15554
+ result.errors.push("The explicit SQLite storage cannot retain task-list status; use the shared API");
15555
+ return result;
15556
+ }
15557
+ const d = db ?? getDatabase();
15558
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
15559
+ result.errors.push("Machine tombstones require explicit registry lifecycle operations");
15560
+ return result;
15561
+ }
15562
+ try {
15563
+ if (snapshot.machines !== undefined)
15564
+ validateMachines(snapshot.machines);
15565
+ } catch (e) {
15566
+ result.errors.push(e instanceof Error ? e.message : String(e));
15567
+ return result;
15568
+ }
14777
15569
  result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
14778
15570
  if (result.errors.length === 0) {
14779
15571
  const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
@@ -14784,6 +15576,20 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14784
15576
  result.errors.push(...auditImport.errors);
14785
15577
  if (result.errors.length > 0)
14786
15578
  return result;
15579
+ if (snapshot.machines?.length) {
15580
+ try {
15581
+ d.transaction(() => {
15582
+ const plan = planMachineImport(exportSqliteMachines(d), snapshot.machines);
15583
+ for (const row of plan.rows)
15584
+ d.run("INSERT INTO machines (id,name,hostname,platform,last_seen_at,metadata,created_at,ssh_address,is_primary,archived_at) VALUES (?,?,?,?,?,?,?,?,?,?)", [row.id, row.name, row.hostname, row.platform, row.last_seen_at, JSON.stringify(row.metadata), row.created_at, row.ssh_address, Number(row.is_primary), row.archived_at]);
15585
+ result.inserted += plan.rows.length;
15586
+ result.skipped += plan.skipped;
15587
+ })();
15588
+ } catch (error) {
15589
+ result.errors.push(error instanceof Error ? error.message : String(error));
15590
+ return result;
15591
+ }
15592
+ }
14787
15593
  result.skipped += auditImport.identicalReplayCount;
14788
15594
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
14789
15595
  for (const row of rows) {
@@ -15018,8 +15824,111 @@ function clockColumnsForTable(table) {
15018
15824
  return ["created_at"];
15019
15825
  return ["updated_at", "created_at"];
15020
15826
  }
15827
+ function exportSqliteMachines(db) {
15828
+ const columns = ["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"];
15829
+ const observed = db.query("PRAGMA table_info(machines)").all().map((row) => row.name);
15830
+ if (observed.length !== columns.length || observed.some((name) => !columns.includes(name)))
15831
+ throw new Error("Machine SQLite schema differs from the lossless migration contract; preserve the source and upgrade the migrator");
15832
+ return validateMachines(db.query("SELECT * FROM machines ORDER BY id").all().map((row) => {
15833
+ if (row.is_primary !== 0 && row.is_primary !== 1)
15834
+ throw new Error("Machine is_primary must be the raw SQLite integer 0 or 1; migration refused");
15835
+ return { ...row, is_primary: row.is_primary === 1, metadata: JSON.parse(String(row.metadata)) };
15836
+ }));
15837
+ }
15021
15838
 
15022
15839
  // src/storage/local-sqlite.ts
15840
+ function bulkCreateAtomicSqlite(db, inputs) {
15841
+ return db.transaction(() => {
15842
+ const tempIds = new Map;
15843
+ const created = [];
15844
+ const dependencies = [];
15845
+ for (const input of inputs) {
15846
+ const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
15847
+ const task = createTask(taskInput, db);
15848
+ if (temp_id)
15849
+ tempIds.set(temp_id, task.id);
15850
+ created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
15851
+ }
15852
+ for (let index = 0;index < inputs.length; index++) {
15853
+ const input = inputs[index];
15854
+ const taskId = created[index].id;
15855
+ const seenDependencies = new Set;
15856
+ for (const reference of input.depends_on ?? []) {
15857
+ const dependencyId = tempIds.get(reference) ?? resolveTaskRefLocal(db, reference)?.id;
15858
+ if (!dependencyId)
15859
+ throw new TaskNotFoundError(reference);
15860
+ if (seenDependencies.has(dependencyId)) {
15861
+ throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
15862
+ }
15863
+ seenDependencies.add(dependencyId);
15864
+ addDependency(taskId, dependencyId, db);
15865
+ dependencies.push({ task_id: taskId, depends_on: dependencyId });
15866
+ }
15867
+ }
15868
+ return { schema_version: 1, atomic: true, created, dependencies };
15869
+ })();
15870
+ }
15871
+ function deleteTaskHierarchySqlite(db, rootId) {
15872
+ const rows = db.query(`WITH RECURSIVE tree(id, depth) AS (
15873
+ SELECT id, 0 FROM tasks WHERE id = ?
15874
+ UNION ALL
15875
+ SELECT child.id, tree.depth + 1 FROM tasks child JOIN tree ON child.parent_id = tree.id
15876
+ ) SELECT id FROM tree ORDER BY depth DESC, id`).all(rootId);
15877
+ if (rows.length === 0)
15878
+ throw new TaskNotFoundError(rootId);
15879
+ for (const row of rows) {
15880
+ if (!deleteTask(row.id, db))
15881
+ throw new Error(`Atomic hierarchy delete lost task ${row.id}`);
15882
+ }
15883
+ }
15884
+ function bulkDeleteAtomicSqlite(db, ids, force) {
15885
+ return db.transaction(() => {
15886
+ const resolved = ids.map((reference) => ({ reference, task: resolveTaskRefLocal(db, reference) }));
15887
+ const seen = new Set;
15888
+ for (const item of resolved) {
15889
+ if (!item.task)
15890
+ continue;
15891
+ if (seen.has(item.task.id)) {
15892
+ throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
15893
+ }
15894
+ seen.add(item.task.id);
15895
+ }
15896
+ const childState = new Map;
15897
+ for (const item of resolved) {
15898
+ if (!item.task)
15899
+ continue;
15900
+ childState.set(item.task.id, Boolean(db.query("SELECT id FROM tasks WHERE parent_id = ? LIMIT 1").get(item.task.id)));
15901
+ }
15902
+ const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
15903
+ const plannedIds = new Set(planned.map((item) => item.task.id));
15904
+ const roots = planned.filter((item) => {
15905
+ if (!force)
15906
+ return true;
15907
+ let parentId = item.task.parent_id;
15908
+ while (parentId) {
15909
+ if (plannedIds.has(parentId))
15910
+ return false;
15911
+ parentId = getTask(parentId, db)?.parent_id ?? null;
15912
+ }
15913
+ return true;
15914
+ });
15915
+ for (const item of roots)
15916
+ deleteTaskHierarchySqlite(db, item.task.id);
15917
+ return {
15918
+ schema_version: 1,
15919
+ atomic: true,
15920
+ force,
15921
+ results: resolved.map((item) => {
15922
+ if (!item.task)
15923
+ return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
15924
+ if (!force && childState.get(item.task.id)) {
15925
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
15926
+ }
15927
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
15928
+ })
15929
+ };
15930
+ })();
15931
+ }
15023
15932
  var TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15024
15933
  function resolveTaskRefLocal(db, ref) {
15025
15934
  const raw = ref.trim().toLowerCase();
@@ -15116,6 +16025,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15116
16025
  },
15117
16026
  handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
15118
16027
  delete: (id) => deleteTask(id, database()),
16028
+ bulkCreateAtomic: (inputs, _context) => bulkCreateAtomicSqlite(database(), inputs),
16029
+ bulkDeleteAtomic: (ids, force, _context) => bulkDeleteAtomicSqlite(database(), ids, force),
15119
16030
  start: (id, agentId) => startTask(id, agentId, database()),
15120
16031
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
15121
16032
  fail: (id, agentId, reason, options2) => failTask(id, agentId, reason, options2, database()),
@@ -15124,6 +16035,30 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15124
16035
  getActiveWork: (filters) => getActiveWork(filters, database()),
15125
16036
  getChangedSince: (since, filters) => getTasksChangedSince(since, filters, database())
15126
16037
  },
16038
+ dependencies: {
16039
+ add: (taskId, dependsOn) => {
16040
+ addDependency(taskId, dependsOn, database());
16041
+ return { task_id: taskId, depends_on: dependsOn };
16042
+ },
16043
+ remove: (taskId, dependsOn) => removeDependency(taskId, dependsOn, database()),
16044
+ list: (taskId) => {
16045
+ const dependencies = getTaskDependencies(taskId, database());
16046
+ const blocks = getTaskDependents(taskId, database());
16047
+ return { dependencies, blocks, blocked_by: blocks };
16048
+ },
16049
+ listPage: ({ limit, offset }) => {
16050
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
16051
+ throw new Error("SQLite dependency page limit must be an integer from 1 to 500");
16052
+ }
16053
+ if (!Number.isSafeInteger(offset) || offset < 0) {
16054
+ throw new Error("SQLite dependency page offset must be a non-negative integer");
16055
+ }
16056
+ const totalRow = database().query("SELECT COUNT(*) AS total FROM task_dependencies").get();
16057
+ const dependencies = database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on LIMIT ? OFFSET ?").all(limit, offset);
16058
+ return { dependencies, total: totalRow.total };
16059
+ },
16060
+ listAll: () => database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on").all()
16061
+ },
15127
16062
  projects: {
15128
16063
  create: (input) => createProject(input, database()),
15129
16064
  get: (id) => getProject(id, database()),
@@ -15167,6 +16102,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15167
16102
  get: (id) => getAgent(id, database()),
15168
16103
  getByName: (name) => getAgentByName(name, database()),
15169
16104
  list: (options2) => listAgents(options2, database()),
16105
+ listPage: (options2) => listAgentsPage(options2, database()),
15170
16106
  update: (id, input) => updateAgent(id, input, database())
15171
16107
  },
15172
16108
  taskLists: {
@@ -15205,6 +16141,29 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15205
16141
  return comments;
15206
16142
  },
15207
16143
  getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
16144
+ getTaskHistoryPage: (taskId, options2) => {
16145
+ if (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 500) {
16146
+ throw new Error("Task history limit must be an integer from 1 to 500");
16147
+ }
16148
+ if (!Number.isSafeInteger(options2.offset) || options2.offset < 0) {
16149
+ throw new Error("Task history offset must be a non-negative integer");
16150
+ }
16151
+ const conditions = ["task_id = ?"];
16152
+ const values = [taskId];
16153
+ if (options2.since) {
16154
+ conditions.push("created_at >= ?");
16155
+ values.push(options2.since);
16156
+ }
16157
+ if (options2.until) {
16158
+ conditions.push("created_at <= ?");
16159
+ values.push(options2.until);
16160
+ }
16161
+ const where = conditions.join(" AND ");
16162
+ const total = database().query(`SELECT COUNT(*) AS total FROM task_history WHERE ${where}`).get(...values).total;
16163
+ const direction = options2.order === "asc" ? "ASC" : "DESC";
16164
+ const history = database().query(`SELECT * FROM task_history WHERE ${where} ORDER BY created_at ${direction}, id ${direction} LIMIT ? OFFSET ?`).all(...values, options2.limit, options2.offset);
16165
+ return { history, total };
16166
+ },
15208
16167
  getRecentActivity: (limit) => getRecentActivity(limit, database())
15209
16168
  },
15210
16169
  sync: {
@@ -15223,19 +16182,1168 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15223
16182
  return adapter;
15224
16183
  }
15225
16184
 
15226
- // src/storage/postgres-adapter.ts
16185
+ // src/lib/builtin-template-library.ts
16186
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
16187
+ import { join as join7 } from "path";
16188
+ var BUILTIN_TEMPLATE_LIBRARY_VERSION = "2026-05-21";
16189
+ var BUILTIN_TEMPLATE_LIBRARY_SOURCE = "bundled-local-template-library";
16190
+ var TEMPLATE_LIBRARY_SCHEMA = "todos.template_library.v1";
16191
+ var BUILTIN_TEMPLATES = [
16192
+ {
16193
+ name: "bug-fix",
16194
+ description: "Reproduce, diagnose, fix, test, and release a defect.",
16195
+ category: "bug-fix",
16196
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16197
+ variables: [
16198
+ { name: "bug", required: true, description: "Bug description" }
16199
+ ],
16200
+ tasks: [
16201
+ {
16202
+ position: 0,
16203
+ title_pattern: "Reproduce: {bug}",
16204
+ priority: "critical",
16205
+ tags: ["bug", "repro"]
16206
+ },
16207
+ {
16208
+ position: 1,
16209
+ title_pattern: "Diagnose root cause of {bug}",
16210
+ priority: "critical",
16211
+ tags: ["bug", "diagnosis"],
16212
+ depends_on_positions: [0]
16213
+ },
16214
+ {
16215
+ position: 2,
16216
+ title_pattern: "Write regression test for {bug}",
16217
+ priority: "high",
16218
+ tags: ["bug", "test"],
16219
+ depends_on_positions: [1]
16220
+ },
16221
+ {
16222
+ position: 3,
16223
+ title_pattern: "Implement fix for {bug}",
16224
+ priority: "critical",
16225
+ tags: ["bug", "implementation"],
16226
+ depends_on_positions: [2]
16227
+ },
16228
+ {
16229
+ position: 4,
16230
+ title_pattern: "Run full verification for {bug}",
16231
+ priority: "high",
16232
+ tags: ["bug", "verification"],
16233
+ depends_on_positions: [3]
16234
+ },
16235
+ {
16236
+ position: 5,
16237
+ title_pattern: "Publish and smoke test fix for {bug}",
16238
+ priority: "high",
16239
+ tags: ["bug", "release"],
16240
+ depends_on_positions: [4]
16241
+ }
16242
+ ]
16243
+ },
16244
+ {
16245
+ name: "feature-implementation",
16246
+ description: "Plan, build, test, document, and release a product feature.",
16247
+ category: "feature",
16248
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16249
+ variables: [
16250
+ { name: "feature", required: true, description: "Feature name" },
16251
+ {
16252
+ name: "scope",
16253
+ required: false,
16254
+ default: "medium",
16255
+ description: "Implementation size or risk"
16256
+ }
16257
+ ],
16258
+ tasks: [
16259
+ {
16260
+ position: 0,
16261
+ title_pattern: "Define acceptance criteria for {feature}",
16262
+ priority: "high",
16263
+ tags: ["feature", "spec"]
16264
+ },
16265
+ {
16266
+ position: 1,
16267
+ title_pattern: "Design {scope} implementation plan for {feature}",
16268
+ priority: "high",
16269
+ tags: ["feature", "design"],
16270
+ depends_on_positions: [0]
16271
+ },
16272
+ {
16273
+ position: 2,
16274
+ title_pattern: "Implement {feature}",
16275
+ priority: "critical",
16276
+ tags: ["feature", "implementation"],
16277
+ depends_on_positions: [1]
16278
+ },
16279
+ {
16280
+ position: 3,
16281
+ title_pattern: "Add tests for {feature}",
16282
+ priority: "high",
16283
+ tags: ["feature", "test"],
16284
+ depends_on_positions: [2]
16285
+ },
16286
+ {
16287
+ position: 4,
16288
+ title_pattern: "Update docs for {feature}",
16289
+ priority: "medium",
16290
+ tags: ["feature", "docs"],
16291
+ depends_on_positions: [2]
16292
+ },
16293
+ {
16294
+ position: 5,
16295
+ title_pattern: "Run release checks for {feature}",
16296
+ priority: "high",
16297
+ tags: ["feature", "verification"],
16298
+ depends_on_positions: [3, 4]
16299
+ }
16300
+ ]
16301
+ },
16302
+ {
16303
+ name: "security-review",
16304
+ description: "Threat model, test, remediate, and report on security posture.",
16305
+ category: "security",
16306
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16307
+ variables: [
16308
+ {
16309
+ name: "target",
16310
+ required: true,
16311
+ description: "System, package, or change under review"
16312
+ }
16313
+ ],
16314
+ tasks: [
16315
+ {
16316
+ position: 0,
16317
+ title_pattern: "Map trust boundaries for {target}",
16318
+ priority: "critical",
16319
+ tags: ["security", "threat-model"]
16320
+ },
16321
+ {
16322
+ position: 1,
16323
+ title_pattern: "Scan {target} for vulnerabilities and secret exposure",
16324
+ priority: "critical",
16325
+ tags: ["security", "scan"],
16326
+ depends_on_positions: [0]
16327
+ },
16328
+ {
16329
+ position: 2,
16330
+ title_pattern: "Review authz, data access, and dependency risks in {target}",
16331
+ priority: "critical",
16332
+ tags: ["security", "review"],
16333
+ depends_on_positions: [0]
16334
+ },
16335
+ {
16336
+ position: 3,
16337
+ title_pattern: "Fix critical security findings in {target}",
16338
+ priority: "critical",
16339
+ tags: ["security", "fix"],
16340
+ depends_on_positions: [1, 2]
16341
+ },
16342
+ {
16343
+ position: 4,
16344
+ title_pattern: "Retest {target} security fixes",
16345
+ priority: "high",
16346
+ tags: ["security", "verification"],
16347
+ depends_on_positions: [3]
16348
+ },
16349
+ {
16350
+ position: 5,
16351
+ title_pattern: "Write local security review report for {target}",
16352
+ priority: "medium",
16353
+ tags: ["security", "report"],
16354
+ depends_on_positions: [4]
16355
+ }
16356
+ ]
16357
+ },
16358
+ {
16359
+ name: "release",
16360
+ description: "Prepare, verify, publish, install, and smoke test a package release.",
16361
+ category: "release",
16362
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16363
+ variables: [
16364
+ { name: "package", required: true, description: "Package name" },
16365
+ {
16366
+ name: "version",
16367
+ required: false,
16368
+ default: "patch",
16369
+ description: "Release version or bump type"
16370
+ }
16371
+ ],
16372
+ tasks: [
16373
+ {
16374
+ position: 0,
16375
+ title_pattern: "Prepare {package} {version} release notes",
16376
+ priority: "medium",
16377
+ tags: ["release", "docs"]
16378
+ },
16379
+ {
16380
+ position: 1,
16381
+ title_pattern: "Run full tests for {package}",
16382
+ priority: "critical",
16383
+ tags: ["release", "test"]
16384
+ },
16385
+ {
16386
+ position: 2,
16387
+ title_pattern: "Run build and release verification for {package}",
16388
+ priority: "critical",
16389
+ tags: ["release", "verification"],
16390
+ depends_on_positions: [1]
16391
+ },
16392
+ {
16393
+ position: 3,
16394
+ title_pattern: "Scan {package} release diff for secrets",
16395
+ priority: "critical",
16396
+ tags: ["release", "security"],
16397
+ depends_on_positions: [2]
16398
+ },
16399
+ {
16400
+ position: 4,
16401
+ title_pattern: "Publish {package} {version}",
16402
+ priority: "high",
16403
+ tags: ["release", "publish"],
16404
+ depends_on_positions: [3]
16405
+ },
16406
+ {
16407
+ position: 5,
16408
+ title_pattern: "Install and smoke test published {package}",
16409
+ priority: "high",
16410
+ tags: ["release", "smoke"],
16411
+ depends_on_positions: [4]
16412
+ }
16413
+ ]
16414
+ },
16415
+ {
16416
+ name: "migration",
16417
+ description: "Plan, test, apply, and verify a schema or data migration.",
16418
+ category: "migration",
16419
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16420
+ variables: [
16421
+ {
16422
+ name: "migration",
16423
+ required: true,
16424
+ description: "Migration name or objective"
16425
+ }
16426
+ ],
16427
+ tasks: [
16428
+ {
16429
+ position: 0,
16430
+ title_pattern: "Design migration plan for {migration}",
16431
+ priority: "critical",
16432
+ tags: ["migration", "plan"]
16433
+ },
16434
+ {
16435
+ position: 1,
16436
+ title_pattern: "Write rollback plan for {migration}",
16437
+ priority: "critical",
16438
+ tags: ["migration", "rollback"],
16439
+ depends_on_positions: [0]
16440
+ },
16441
+ {
16442
+ position: 2,
16443
+ title_pattern: "Implement migration {migration}",
16444
+ priority: "critical",
16445
+ tags: ["migration", "implementation"],
16446
+ depends_on_positions: [0]
16447
+ },
16448
+ {
16449
+ position: 3,
16450
+ title_pattern: "Test migration {migration} on fixture data",
16451
+ priority: "high",
16452
+ tags: ["migration", "test"],
16453
+ depends_on_positions: [2]
16454
+ },
16455
+ {
16456
+ position: 4,
16457
+ title_pattern: "Run migration {migration} verification and drift checks",
16458
+ priority: "high",
16459
+ tags: ["migration", "verification"],
16460
+ depends_on_positions: [1, 3]
16461
+ },
16462
+ {
16463
+ position: 5,
16464
+ title_pattern: "Document migration {migration} evidence",
16465
+ priority: "medium",
16466
+ tags: ["migration", "docs"],
16467
+ depends_on_positions: [4]
16468
+ }
16469
+ ]
16470
+ },
16471
+ {
16472
+ name: "incident",
16473
+ description: "Triage, mitigate, repair, verify, and retrospect an incident.",
16474
+ category: "incident",
16475
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16476
+ variables: [
16477
+ { name: "incident", required: true, description: "Incident summary" }
16478
+ ],
16479
+ tasks: [
16480
+ {
16481
+ position: 0,
16482
+ title_pattern: "Triage incident: {incident}",
16483
+ priority: "critical",
16484
+ tags: ["incident", "triage"]
16485
+ },
16486
+ {
16487
+ position: 1,
16488
+ title_pattern: "Mitigate customer impact for {incident}",
16489
+ priority: "critical",
16490
+ tags: ["incident", "mitigation"],
16491
+ depends_on_positions: [0]
16492
+ },
16493
+ {
16494
+ position: 2,
16495
+ title_pattern: "Diagnose root cause for {incident}",
16496
+ priority: "critical",
16497
+ tags: ["incident", "diagnosis"],
16498
+ depends_on_positions: [0]
16499
+ },
16500
+ {
16501
+ position: 3,
16502
+ title_pattern: "Implement durable repair for {incident}",
16503
+ priority: "critical",
16504
+ tags: ["incident", "repair"],
16505
+ depends_on_positions: [2]
16506
+ },
16507
+ {
16508
+ position: 4,
16509
+ title_pattern: "Verify recovery from {incident}",
16510
+ priority: "high",
16511
+ tags: ["incident", "verification"],
16512
+ depends_on_positions: [1, 3]
16513
+ },
16514
+ {
16515
+ position: 5,
16516
+ title_pattern: "Write retrospective for {incident}",
16517
+ priority: "medium",
16518
+ tags: ["incident", "retro"],
16519
+ depends_on_positions: [4]
16520
+ }
16521
+ ]
16522
+ },
16523
+ {
16524
+ name: "docs-refresh",
16525
+ description: "Audit, update, validate, and publish documentation changes.",
16526
+ category: "docs",
16527
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16528
+ variables: [
16529
+ {
16530
+ name: "area",
16531
+ required: true,
16532
+ description: "Documentation area or product surface"
16533
+ }
16534
+ ],
16535
+ tasks: [
16536
+ {
16537
+ position: 0,
16538
+ title_pattern: "Audit current docs for {area}",
16539
+ priority: "medium",
16540
+ tags: ["docs", "audit"]
16541
+ },
16542
+ {
16543
+ position: 1,
16544
+ title_pattern: "Update examples and commands for {area}",
16545
+ priority: "medium",
16546
+ tags: ["docs", "examples"],
16547
+ depends_on_positions: [0]
16548
+ },
16549
+ {
16550
+ position: 2,
16551
+ title_pattern: "Validate docs snippets for {area}",
16552
+ priority: "high",
16553
+ tags: ["docs", "verification"],
16554
+ depends_on_positions: [1]
16555
+ },
16556
+ {
16557
+ position: 3,
16558
+ title_pattern: "Refresh screenshots or generated artifacts for {area}",
16559
+ priority: "medium",
16560
+ tags: ["docs", "assets"],
16561
+ depends_on_positions: [1]
16562
+ },
16563
+ {
16564
+ position: 4,
16565
+ title_pattern: "Publish docs refresh for {area}",
16566
+ priority: "medium",
16567
+ tags: ["docs", "release"],
16568
+ depends_on_positions: [2, 3]
16569
+ }
16570
+ ]
16571
+ },
16572
+ {
16573
+ name: "qa",
16574
+ description: "Build a focused QA plan, execute checks, file defects, and sign off.",
16575
+ category: "qa",
16576
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16577
+ variables: [
16578
+ {
16579
+ name: "target",
16580
+ required: true,
16581
+ description: "Feature, release, or workflow under QA"
16582
+ }
16583
+ ],
16584
+ tasks: [
16585
+ {
16586
+ position: 0,
16587
+ title_pattern: "Create QA matrix for {target}",
16588
+ priority: "high",
16589
+ tags: ["qa", "plan"]
16590
+ },
16591
+ {
16592
+ position: 1,
16593
+ title_pattern: "Run happy-path QA for {target}",
16594
+ priority: "high",
16595
+ tags: ["qa", "manual"],
16596
+ depends_on_positions: [0]
16597
+ },
16598
+ {
16599
+ position: 2,
16600
+ title_pattern: "Run edge-case QA for {target}",
16601
+ priority: "high",
16602
+ tags: ["qa", "edge-cases"],
16603
+ depends_on_positions: [0]
16604
+ },
16605
+ {
16606
+ position: 3,
16607
+ title_pattern: "File and dedupe QA defects for {target}",
16608
+ priority: "medium",
16609
+ tags: ["qa", "bugs"],
16610
+ depends_on_positions: [1, 2]
16611
+ },
16612
+ {
16613
+ position: 4,
16614
+ title_pattern: "Verify QA fixes for {target}",
16615
+ priority: "high",
16616
+ tags: ["qa", "verification"],
16617
+ depends_on_positions: [3]
16618
+ },
16619
+ {
16620
+ position: 5,
16621
+ title_pattern: "Record QA signoff for {target}",
16622
+ priority: "medium",
16623
+ tags: ["qa", "signoff"],
16624
+ depends_on_positions: [4]
16625
+ }
16626
+ ]
16627
+ },
16628
+ {
16629
+ name: "open-source-project",
16630
+ description: "Full open-source project bootstrap \u2014 scaffold to publish",
16631
+ category: "open-source",
16632
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16633
+ variables: [
16634
+ { name: "name", required: true, description: "Service name" },
16635
+ {
16636
+ name: "org",
16637
+ required: false,
16638
+ default: "hasna",
16639
+ description: "GitHub org"
16640
+ }
16641
+ ],
16642
+ tasks: [
16643
+ {
16644
+ position: 0,
16645
+ title_pattern: "Scaffold {name} package structure",
16646
+ priority: "critical"
16647
+ },
16648
+ {
16649
+ position: 1,
16650
+ title_pattern: "Create {name} SQLite database + migrations",
16651
+ priority: "critical",
16652
+ depends_on_positions: [0]
16653
+ },
16654
+ {
16655
+ position: 2,
16656
+ title_pattern: "Implement {name} CRUD operations",
16657
+ priority: "high",
16658
+ depends_on_positions: [1]
16659
+ },
16660
+ {
16661
+ position: 3,
16662
+ title_pattern: "Build {name} MCP server with standard tools",
16663
+ priority: "high",
16664
+ depends_on_positions: [2]
16665
+ },
16666
+ {
16667
+ position: 4,
16668
+ title_pattern: "Build {name} CLI with Commander.js",
16669
+ priority: "high",
16670
+ depends_on_positions: [2]
16671
+ },
16672
+ {
16673
+ position: 5,
16674
+ title_pattern: "Build {name} REST API",
16675
+ priority: "medium",
16676
+ depends_on_positions: [2]
16677
+ },
16678
+ {
16679
+ position: 6,
16680
+ title_pattern: "Write unit tests for {name}",
16681
+ priority: "high",
16682
+ depends_on_positions: [2, 3, 4]
16683
+ },
16684
+ {
16685
+ position: 7,
16686
+ title_pattern: "Add Apache 2.0 license and README",
16687
+ priority: "medium",
16688
+ depends_on_positions: [0]
16689
+ },
16690
+ {
16691
+ position: 8,
16692
+ title_pattern: "Create GitHub repo {org}/{name}",
16693
+ priority: "medium",
16694
+ depends_on_positions: [7]
16695
+ },
16696
+ {
16697
+ position: 9,
16698
+ title_pattern: "Add local backup and restore workflow for {name}",
16699
+ priority: "medium",
16700
+ depends_on_positions: [1]
16701
+ },
16702
+ {
16703
+ position: 10,
16704
+ title_pattern: "Add agent tools (register_agent, heartbeat, set_focus, list_agents)",
16705
+ priority: "medium",
16706
+ depends_on_positions: [3]
16707
+ },
16708
+ {
16709
+ position: 11,
16710
+ title_pattern: "Publish @hasna/{name} to npm",
16711
+ priority: "high",
16712
+ depends_on_positions: [6, 7, 8]
16713
+ },
16714
+ {
16715
+ position: 12,
16716
+ title_pattern: "Install @hasna/{name} globally and verify",
16717
+ priority: "medium",
16718
+ depends_on_positions: [11]
16719
+ }
16720
+ ]
16721
+ },
16722
+ {
16723
+ name: "release",
16724
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16725
+ category: "ops",
16726
+ description: "Version release workflow \u2014 test, changelog, publish, verify",
16727
+ variables: [
16728
+ {
16729
+ name: "version",
16730
+ required: true,
16731
+ description: "Release version (e.g. 1.2.0)"
16732
+ },
16733
+ {
16734
+ name: "package",
16735
+ required: false,
16736
+ default: "package",
16737
+ description: "Package name"
16738
+ }
16739
+ ],
16740
+ tasks: [
16741
+ {
16742
+ position: 0,
16743
+ title_pattern: "Run full test suite for {package} {version}",
16744
+ priority: "critical"
16745
+ },
16746
+ {
16747
+ position: 1,
16748
+ title_pattern: "Update changelog for {version}",
16749
+ priority: "high",
16750
+ depends_on_positions: [0]
16751
+ },
16752
+ {
16753
+ position: 2,
16754
+ title_pattern: "Bump version to {version}",
16755
+ priority: "high",
16756
+ depends_on_positions: [1]
16757
+ },
16758
+ {
16759
+ position: 3,
16760
+ title_pattern: "Publish {package} {version}",
16761
+ priority: "critical",
16762
+ depends_on_positions: [2]
16763
+ },
16764
+ {
16765
+ position: 4,
16766
+ title_pattern: "Verify {package} {version} install and CLI",
16767
+ priority: "high",
16768
+ depends_on_positions: [3]
16769
+ }
16770
+ ]
16771
+ },
16772
+ {
16773
+ name: "docs-refresh",
16774
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16775
+ category: "workflow",
16776
+ description: "Documentation refresh \u2014 audit, update, verify links",
16777
+ variables: [
16778
+ {
16779
+ name: "scope",
16780
+ required: true,
16781
+ description: "Docs scope (README, API, AGENTS.md)"
16782
+ }
16783
+ ],
16784
+ tasks: [
16785
+ {
16786
+ position: 0,
16787
+ title_pattern: "Audit outdated sections in {scope}",
16788
+ priority: "high"
16789
+ },
16790
+ {
16791
+ position: 1,
16792
+ title_pattern: "Update {scope} for current behavior",
16793
+ priority: "high",
16794
+ depends_on_positions: [0]
16795
+ },
16796
+ {
16797
+ position: 2,
16798
+ title_pattern: "Verify examples in {scope} still work",
16799
+ priority: "medium",
16800
+ depends_on_positions: [1]
16801
+ },
16802
+ {
16803
+ position: 3,
16804
+ title_pattern: "Fix broken links in {scope}",
16805
+ priority: "medium",
16806
+ depends_on_positions: [1]
16807
+ }
16808
+ ]
16809
+ },
16810
+ {
16811
+ name: "migration",
16812
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16813
+ category: "ops",
16814
+ description: "Schema/data migration workflow",
16815
+ variables: [
16816
+ {
16817
+ name: "migration",
16818
+ required: true,
16819
+ description: "Migration name or ID"
16820
+ },
16821
+ {
16822
+ name: "target",
16823
+ required: false,
16824
+ default: "sqlite",
16825
+ description: "Target store"
16826
+ }
16827
+ ],
16828
+ tasks: [
16829
+ {
16830
+ position: 0,
16831
+ title_pattern: "Write forward migration for {migration} ({target})",
16832
+ priority: "critical"
16833
+ },
16834
+ {
16835
+ position: 1,
16836
+ title_pattern: "Add migration tests for {migration}",
16837
+ priority: "high",
16838
+ depends_on_positions: [0]
16839
+ },
16840
+ {
16841
+ position: 2,
16842
+ title_pattern: "Run migration dry-run on copy",
16843
+ priority: "high",
16844
+ depends_on_positions: [1]
16845
+ },
16846
+ {
16847
+ position: 3,
16848
+ title_pattern: "Apply {migration} and verify integrity",
16849
+ priority: "critical",
16850
+ depends_on_positions: [2]
16851
+ },
16852
+ {
16853
+ position: 4,
16854
+ title_pattern: "Document rollback for {migration}",
16855
+ priority: "medium",
16856
+ depends_on_positions: [3]
16857
+ }
16858
+ ]
16859
+ },
16860
+ {
16861
+ name: "incident-response",
16862
+ version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
16863
+ category: "ops",
16864
+ description: "Incident triage, mitigation, postmortem",
16865
+ variables: [
16866
+ { name: "incident", required: true, description: "Incident summary" }
16867
+ ],
16868
+ tasks: [
16869
+ {
16870
+ position: 0,
16871
+ title_pattern: "Triage: {incident}",
16872
+ priority: "critical",
16873
+ tags: ["incident"]
16874
+ },
16875
+ {
16876
+ position: 1,
16877
+ title_pattern: "Mitigate impact of {incident}",
16878
+ priority: "critical",
16879
+ depends_on_positions: [0],
16880
+ tags: ["incident"]
16881
+ },
16882
+ {
16883
+ position: 2,
16884
+ title_pattern: "Identify root cause: {incident}",
16885
+ priority: "critical",
16886
+ depends_on_positions: [1]
16887
+ },
16888
+ {
16889
+ position: 3,
16890
+ title_pattern: "Permanent fix for {incident}",
16891
+ priority: "high",
16892
+ depends_on_positions: [2]
16893
+ },
16894
+ {
16895
+ position: 4,
16896
+ title_pattern: "Write postmortem for {incident}",
16897
+ priority: "medium",
16898
+ depends_on_positions: [3]
16899
+ },
16900
+ {
16901
+ position: 5,
16902
+ title_pattern: "Add monitoring/alert for {incident} class",
16903
+ priority: "medium",
16904
+ depends_on_positions: [4]
16905
+ }
16906
+ ]
16907
+ }
16908
+ ];
16909
+ function templateMetadata(template) {
16910
+ return {
16911
+ source: BUILTIN_TEMPLATE_LIBRARY_SOURCE,
16912
+ library_version: template.version,
16913
+ category: template.category,
16914
+ template_file: `${template.name}.json`,
16915
+ local_only: true,
16916
+ marketplace_free: true
16917
+ };
16918
+ }
16919
+ function listBuiltinTemplates() {
16920
+ return BUILTIN_TEMPLATES.map((template) => ({
16921
+ ...template,
16922
+ variables: template.variables.map((variable) => ({ ...variable })),
16923
+ tasks: template.tasks.map((task) => ({
16924
+ ...task,
16925
+ tags: task.tags ? [...task.tags] : undefined
16926
+ }))
16927
+ }));
16928
+ }
16929
+ function getBuiltinTemplate(name) {
16930
+ return listBuiltinTemplates().find((template) => template.name === name) ?? null;
16931
+ }
16932
+ function exportBuiltinTemplate(name) {
16933
+ const template = getBuiltinTemplate(name);
16934
+ if (!template)
16935
+ throw new Error(`Built-in template not found: ${name}`);
16936
+ return {
16937
+ name: template.name,
16938
+ title_pattern: `${template.name}: {${template.variables[0]?.name ?? "name"}}`,
16939
+ description: template.description,
16940
+ priority: "medium",
16941
+ tags: [template.category, "local-template"],
16942
+ variables: template.variables,
16943
+ project_id: null,
16944
+ plan_id: null,
16945
+ metadata: templateMetadata(template),
16946
+ tasks: template.tasks.map((task) => ({
16947
+ position: task.position,
16948
+ title_pattern: task.title_pattern,
16949
+ description: task.description ?? null,
16950
+ priority: task.priority ?? "medium",
16951
+ tags: task.tags ?? [template.category],
16952
+ task_type: task.task_type ?? null,
16953
+ condition: task.condition ?? null,
16954
+ include_template_id: task.include_template_id ?? null,
16955
+ depends_on_positions: task.depends_on_positions ?? task.depends_on ?? [],
16956
+ metadata: task.metadata ?? { category: template.category }
16957
+ }))
16958
+ };
16959
+ }
16960
+ function exportBuiltinTemplateFiles() {
16961
+ return BUILTIN_TEMPLATES.map((template) => ({
16962
+ filename: `${template.name}.json`,
16963
+ template: exportBuiltinTemplate(template.name)
16964
+ }));
16965
+ }
16966
+ function writeBuiltinTemplateFiles(directory) {
16967
+ mkdirSync5(directory, { recursive: true });
16968
+ const files = [];
16969
+ for (const entry of exportBuiltinTemplateFiles()) {
16970
+ const path = join7(directory, entry.filename);
16971
+ writeFileSync3(path, `${JSON.stringify(entry.template, null, 2)}
16972
+ `, "utf-8");
16973
+ files.push(path);
16974
+ }
16975
+ return { directory, written: files.length, files };
16976
+ }
16977
+ function builtinTemplateInputs() {
16978
+ return listBuiltinTemplates().map((bt) => ({
16979
+ name: bt.name,
16980
+ title_pattern: `${bt.name}: {${bt.variables[0]?.name || "name"}}`,
16981
+ description: bt.description,
16982
+ tags: [bt.category, "local-template"],
16983
+ variables: bt.variables,
16984
+ metadata: templateMetadata(bt),
16985
+ tasks: bt.tasks.map((task) => ({
16986
+ title_pattern: task.title_pattern,
16987
+ description: task.description,
16988
+ priority: task.priority,
16989
+ tags: task.tags,
16990
+ task_type: task.task_type,
16991
+ depends_on: task.depends_on_positions || task.depends_on,
16992
+ metadata: task.metadata
16993
+ }))
16994
+ }));
16995
+ }
16996
+
16997
+ // src/lib/plan-schedule.ts
16998
+ function validatePlanSchedule(value) {
16999
+ for (const key of ["start_date", "end_date"]) {
17000
+ const date = value[key];
17001
+ if (date === undefined || date === null)
17002
+ continue;
17003
+ if (typeof date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date)
17004
+ return `${key} must be a valid ISO calendar date (YYYY-MM-DD)`;
17005
+ }
17006
+ if (typeof value.start_date === "string" && typeof value.end_date === "string" && value.start_date > value.end_date)
17007
+ return "end_date must not precede start_date";
17008
+ return null;
17009
+ }
17010
+
17011
+ // src/storage/atomic-project-migration.ts
15227
17012
  init_types();
15228
- import { randomUUID as randomUUID3 } from "crypto";
15229
- init_creator_identity();
17013
+ import { createHash as createHash5 } from "crypto";
17014
+ var families = { projects: "projects", tasks: "tasks", plans: "plans", taskLists: "task_lists", auditHistory: "audit_history" };
15230
17015
 
15231
- // src/lib/instant-compare.ts
15232
- var SQLITE_STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/;
15233
- var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
15234
- function isLeapYear(year) {
15235
- return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
17016
+ class AtomicMigrationError extends Error {
17017
+ status;
17018
+ record;
17019
+ constructor(status, message, record) {
17020
+ super(message);
17021
+ this.status = status;
17022
+ this.record = record;
17023
+ this.name = "AtomicMigrationError";
17024
+ }
15236
17025
  }
15237
- function sqliteJulianDay(value) {
15238
- const m = SQLITE_STAMP.exec(value);
17026
+ function fail(message, status = 409, record) {
17027
+ throw new AtomicMigrationError(status, message, record && { object_type: record.object_type, object_id: record.object_id });
17028
+ }
17029
+ function plain(value) {
17030
+ return !!value && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
17031
+ }
17032
+ function canonical(value, depth = 0) {
17033
+ if (depth > 32)
17034
+ fail("Migration JSON depth exceeds 32", 400);
17035
+ if (value === null || typeof value === "boolean" || typeof value === "string")
17036
+ return JSON.stringify(value);
17037
+ if (typeof value === "number" && Number.isFinite(value))
17038
+ return JSON.stringify(value);
17039
+ if (Array.isArray(value))
17040
+ return "[" + value.map((v) => canonical(v, depth + 1)).join(",") + "]";
17041
+ if (plain(value))
17042
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonical(value[k], depth + 1)).join(",") + "}";
17043
+ return fail("Migration requires finite JSON values", 400);
17044
+ }
17045
+ function atomicMigrationHash(value) {
17046
+ return createHash5("sha256").update(canonical(value)).digest("hex");
17047
+ }
17048
+ function stamp(value) {
17049
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value)
17050
+ fail("Migration clocks require canonical millisecond UTC timestamps", 400);
17051
+ return value;
17052
+ }
17053
+ function version(value) {
17054
+ if (value == null)
17055
+ return 0;
17056
+ if (!Number.isSafeInteger(value) || Number(value) < 0)
17057
+ fail("Invalid migration version", 400);
17058
+ return value;
17059
+ }
17060
+ function key(row) {
17061
+ return row.object_type + "\x00" + row.object_id;
17062
+ }
17063
+ function normalized(row) {
17064
+ return { ...row, source_machine_id: row.source_machine_id ?? null, updated_at: new Date(row.updated_at).toISOString(), deleted_at: row.deleted_at ? new Date(row.deleted_at).toISOString() : null };
17065
+ }
17066
+ function digest(row) {
17067
+ return atomicMigrationHash(normalized(row));
17068
+ }
17069
+ function compare(a, b) {
17070
+ return Date.parse(String(a.updated_at)) - Date.parse(String(b.updated_at)) || version(a.version) - version(b.version);
17071
+ }
17072
+ function identity(value) {
17073
+ if (typeof value !== "string" || !value.trim() || value.length > 256 || value.includes("\x00"))
17074
+ fail("Invalid migration record identity", 400);
17075
+ return value;
17076
+ }
17077
+ function validate(request) {
17078
+ if (!plain(request) || request.schema_version !== 1 || typeof request.operation_id !== "string" || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.operation_id))
17079
+ fail("Invalid migration operation identity/version", 400);
17080
+ if (Object.keys(request).some((k) => !["schema_version", "operation_id", "expected_authority", "snapshot", "snapshot_hash"].includes(k)) || Buffer.byteLength(canonical(request)) > 2200000)
17081
+ fail("Unsupported or oversized migration envelope", 400);
17082
+ if (!plain(request.expected_authority))
17083
+ fail("Expected migration authority is required", 400);
17084
+ identity(request.expected_authority.tenant_id);
17085
+ identity(request.expected_authority.kid);
17086
+ identity(request.expected_authority.deployment_id);
17087
+ if (!plain(request.snapshot))
17088
+ fail("Migration snapshot must be an object", 400);
17089
+ const encoded = canonical(request.snapshot);
17090
+ if (Buffer.byteLength(encoded) > 2 * 1024 * 1024)
17091
+ fail("Migration snapshot exceeds 2 MiB", 413);
17092
+ if (atomicMigrationHash(request.snapshot) !== request.snapshot_hash)
17093
+ fail("Migration snapshot hash mismatch", 400);
17094
+ const allowed = new Set([...Object.keys(families), "tombstones", "exportedAt", "source"]);
17095
+ for (const [name, value] of Object.entries(request.snapshot))
17096
+ if (!allowed.has(name) && !(Array.isArray(value) && value.length === 0))
17097
+ fail(`Unsupported atomic migration family: ${name}`, 400);
17098
+ const rows = [];
17099
+ for (const [name, type] of Object.entries(families)) {
17100
+ const values = request.snapshot[name] ?? [];
17101
+ if (!Array.isArray(values))
17102
+ fail(`Invalid migration array: ${name}`, 400);
17103
+ for (const value of values) {
17104
+ if (!plain(value))
17105
+ fail("Invalid migration record", 400);
17106
+ const id = identity(value.id);
17107
+ const clock = stamp(value.updated_at ?? value.created_at);
17108
+ if (type === "tasks") {
17109
+ if (typeof value.title !== "string" || !TASK_STATUSES.includes(value.status) || !TASK_PRIORITIES.includes(value.priority))
17110
+ fail("Invalid task migration payload", 400);
17111
+ if (value.locked_by || value.runner_id)
17112
+ fail("Active task leases require separate migration reconciliation", 409);
17113
+ }
17114
+ if (type === "plans" && (typeof value.name !== "string" || !PLAN_STATUSES.includes(value.status)))
17115
+ fail("Invalid plan migration payload", 400);
17116
+ if (type === "projects" && typeof value.name !== "string")
17117
+ fail("Invalid project migration payload", 400);
17118
+ if (type === "audit_history" && (typeof value.task_id !== "string" || typeof value.action !== "string"))
17119
+ fail("Invalid history migration payload", 400);
17120
+ rows.push({ object_type: type, object_id: id, payload: value, updated_at: clock, deleted_at: null, version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
17121
+ }
17122
+ }
17123
+ const tombstones = request.snapshot.tombstones ?? [];
17124
+ if (!Array.isArray(tombstones))
17125
+ fail("Invalid tombstone array", 400);
17126
+ for (const value of tombstones) {
17127
+ if (!plain(value) || value.object_type !== "projects")
17128
+ fail("Atomic migration supports only project tombstones", 400);
17129
+ if (!plain(value.payload))
17130
+ fail("Project tombstone must preserve its original payload", 400);
17131
+ if (stamp(value.updated_at) < stamp(value.deleted_at))
17132
+ fail("Tombstone update clock precedes deletion", 400);
17133
+ const id = identity(value.object_id);
17134
+ if (value.payload.id !== id)
17135
+ fail("Tombstone payload identity mismatch", 400);
17136
+ rows.push({ object_type: "projects", object_id: id, payload: value.payload, updated_at: stamp(value.updated_at), deleted_at: stamp(value.deleted_at), version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
17137
+ }
17138
+ if (!rows.length || rows.length > 1000)
17139
+ fail("Atomic migration requires 1-1000 records", 400);
17140
+ const unique4 = new Map;
17141
+ for (const row of rows) {
17142
+ const previous = unique4.get(key(row));
17143
+ if (previous && digest(previous) !== digest(row))
17144
+ fail("Divergent duplicate migration identity", 409, row);
17145
+ unique4.set(key(row), row);
17146
+ }
17147
+ return [...unique4.values()];
17148
+ }
17149
+ function validateGraph(rows) {
17150
+ const live = [...rows.values()].filter((r) => !r.deleted_at);
17151
+ const get = (type, id) => typeof id === "string" ? rows.get(type + "\x00" + id) : undefined;
17152
+ for (const row of live) {
17153
+ if (row.object_type === "audit_history") {
17154
+ if (!get("tasks", row.payload.task_id))
17155
+ fail("Migration history references an unknown task", 409, row);
17156
+ continue;
17157
+ }
17158
+ if (!["projects", "tasks", "plans", "task_lists"].includes(row.object_type))
17159
+ continue;
17160
+ if (row.object_type === "tasks" && row.payload.plan_id) {
17161
+ const plan = get("plans", row.payload.plan_id);
17162
+ if (plan && !plan.deleted_at && (plan.payload.project_id ?? null) !== (row.payload.project_id ?? null))
17163
+ fail("Migration task project conflicts with its plan", 409, row);
17164
+ }
17165
+ if (["tasks", "plans"].includes(row.object_type) && row.payload.task_list_id != null) {
17166
+ const selector = row.payload.task_list_id;
17167
+ if (typeof selector !== "string" || !selector)
17168
+ fail("Invalid migration task-list selector", 409, row);
17169
+ const candidates = live.filter((candidate) => candidate.object_type === "task_lists" && (candidate.payload.project_id ?? null) === (row.payload.project_id ?? null) && (candidate.object_id === selector || candidate.payload.slug === selector));
17170
+ const project = get("projects", row.payload.project_id);
17171
+ const canonicalDefault = project && !project.deleted_at && project.payload.task_list_id === selector;
17172
+ if (candidates.length > 1 || !candidates.length && !canonicalDefault)
17173
+ fail("Migration task-list reference is missing or ambiguous; explicit reconciliation required", 409, row);
17174
+ }
17175
+ const refs = [];
17176
+ if (row.object_type === "projects")
17177
+ refs.push(["projects", row.payload.parent_id]);
17178
+ else
17179
+ refs.push(["projects", row.payload.project_id]);
17180
+ if (row.object_type === "tasks")
17181
+ refs.push(["tasks", row.payload.parent_id], ["plans", row.payload.plan_id]);
17182
+ for (const [type, id] of refs) {
17183
+ if (id == null)
17184
+ continue;
17185
+ if (typeof id !== "string" || !id || !get(type, id) || get(type, id).deleted_at)
17186
+ fail("Migration reference is missing or deleted", 409, row);
17187
+ }
17188
+ if (row.object_type === "tasks" && row.payload.parent_id) {
17189
+ const parent = get("tasks", row.payload.parent_id);
17190
+ if ((parent.payload.project_id ?? null) !== (row.payload.project_id ?? null))
17191
+ fail("Migration task parent crosses project scope", 409, row);
17192
+ }
17193
+ }
17194
+ for (const type of ["projects", "tasks"])
17195
+ for (const row of live.filter((r) => r.object_type === type)) {
17196
+ const seen = new Set;
17197
+ let cursor = row;
17198
+ while (cursor) {
17199
+ if (seen.has(cursor.object_id))
17200
+ fail("Migration hierarchy contains a cycle", 409, row);
17201
+ seen.add(cursor.object_id);
17202
+ cursor = get(type, cursor.payload.parent_id);
17203
+ }
17204
+ }
17205
+ const projects = live.filter((r) => r.object_type === "projects").map((r) => r.payload);
17206
+ const lists = live.filter((r) => r.object_type === "task_lists").map((r) => r.payload);
17207
+ const errors = validateSnapshotRoutingRecords(projects, lists);
17208
+ if (errors.length)
17209
+ fail("Migration project/list routing conflict");
17210
+ }
17211
+ function createAtomicProjectMigration(options) {
17212
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.table))
17213
+ throw new Error("Unsafe migration table");
17214
+ return { async apply(request) {
17215
+ const incoming = validate(request);
17216
+ if (!options.client.transaction)
17217
+ fail("Atomic migration requires a transaction-capable backend", 501);
17218
+ await options.ensureSchema();
17219
+ return options.client.transaction(async (client) => {
17220
+ const deadline = Date.now() + 20000;
17221
+ await client.query("SET LOCAL lock_timeout = '5s'");
17222
+ await client.query("SET LOCAL statement_timeout = '15s'");
17223
+ await client.query("SET LOCAL synchronous_commit = 'on'");
17224
+ const durability = await client.query("SELECT current_setting('fsync') AS fsync,current_setting('full_page_writes') AS full_page_writes,current_setting('synchronous_commit') AS synchronous_commit");
17225
+ const durable = durability.rows[0];
17226
+ if (durable?.fsync !== "on" || durable.full_page_writes !== "on" || durable.synchronous_commit !== "on")
17227
+ fail("Migration database durability settings are not ready", 503);
17228
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity',0))", [options.service]);
17229
+ await client.query(`LOCK TABLE ${options.table} IN SHARE ROW EXCLUSIVE MODE`);
17230
+ const journalId = atomicMigrationHash({ operation_id: request.operation_id });
17231
+ const journal = await client.query(`SELECT payload->>'request_hash' AS request_hash,payload->'receipt' AS receipt FROM ${options.table} WHERE service=$1 AND object_type='atomic_project_migrations' AND object_id=$2`, [options.service, journalId]);
17232
+ const requestHash = atomicMigrationHash(request);
17233
+ if (journal.rows[0]) {
17234
+ if (journal.rows[0].request_hash !== requestHash)
17235
+ fail("Migration operation identity was already used for another request");
17236
+ return journal.rows[0].receipt;
17237
+ }
17238
+ const bounds = await client.query(`SELECT count(*)::text AS records,COALESCE(sum(octet_length(payload::text)),0)::text AS bytes,COALESCE(max(octet_length(payload::text)),0)::text AS largest FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history')`, [options.service]);
17239
+ const size = bounds.rows[0];
17240
+ if (!size || Number(size.records) > 1e4 || Number(size.bytes) > 16 * 1024 * 1024 || Number(size.largest) > 2 * 1024 * 1024)
17241
+ fail("Atomic migration destination exceeds its bounded row/byte scope", 413);
17242
+ const selected = await client.query(`SELECT object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history') LIMIT 10001 FOR UPDATE`, [options.service]);
17243
+ if (selected.rows.length > 1e4)
17244
+ fail("Atomic migration destination exceeds bounded 10000-record scope", 413);
17245
+ for (const row of selected.rows) {
17246
+ canonical(row.payload);
17247
+ if (Date.now() > deadline)
17248
+ fail("Migration destination validation exceeded time budget", 503);
17249
+ }
17250
+ const original = new Map(selected.rows.map((r) => [key(r), normalized(r)]));
17251
+ const current = new Map(original);
17252
+ const receipts = [];
17253
+ const receipt = (source, before, after, outcome) => receipts.push({ object_type: source.object_type, object_id: source.object_id, outcome, before_hash: before ? digest(before) : null, after_hash: digest(after), source_hash: digest(source) });
17254
+ for (const row of incoming.filter((r) => !r.deleted_at)) {
17255
+ const before = current.get(key(row));
17256
+ if (before) {
17257
+ if (digest(before) === digest(row)) {
17258
+ receipt(row, before, before, "identical");
17259
+ continue;
17260
+ }
17261
+ if (before.object_type === "tasks" && (before.payload.locked_by || before.payload.runner_id))
17262
+ fail("Active destination task lease requires reconciliation", 409, before);
17263
+ if (row.object_type === "audit_history")
17264
+ fail("Immutable history identity conflict", 409, row);
17265
+ const clock = compare(row, before);
17266
+ if (clock === 0)
17267
+ fail("Ambiguous equal-clock migration conflict", 409, row);
17268
+ if (clock < 0) {
17269
+ receipt(row, before, before, "superseded");
17270
+ continue;
17271
+ }
17272
+ if (before.deleted_at)
17273
+ fail("Migration cannot implicitly resurrect a deleted record", 409, row);
17274
+ }
17275
+ current.set(key(row), row);
17276
+ receipt(row, before, row, before ? "updated" : "inserted");
17277
+ }
17278
+ for (const row of incoming.filter((r) => r.deleted_at)) {
17279
+ const before = current.get(key(row));
17280
+ if (before) {
17281
+ if (digest(before) === digest(row)) {
17282
+ receipt(row, before, before, "identical");
17283
+ continue;
17284
+ }
17285
+ const clock = compare(row, before);
17286
+ if (clock === 0)
17287
+ fail("Ambiguous equal-clock lifecycle conflict", 409, row);
17288
+ if (clock < 0) {
17289
+ receipt(row, before, before, "superseded");
17290
+ continue;
17291
+ }
17292
+ }
17293
+ for (const [id, linked] of current) {
17294
+ if (linked.deleted_at)
17295
+ continue;
17296
+ const field = linked.object_type === "projects" ? "parent_id" : "project_id";
17297
+ if (!["projects", "tasks", "plans", "task_lists"].includes(linked.object_type) || linked.payload[field] !== row.object_id)
17298
+ continue;
17299
+ if (compare(linked, row) > 0)
17300
+ fail("A newer linked record conflicts with the historical project deletion", 409, linked);
17301
+ if (linked.object_type === "tasks" && (linked.payload.locked_by || linked.payload.runner_id))
17302
+ fail("Active linked task lease requires reconciliation", 409, linked);
17303
+ const detached = { ...linked, payload: { ...linked.payload, [field]: null, updated_at: row.updated_at }, updated_at: row.updated_at };
17304
+ if (linked.object_type === "tasks") {
17305
+ detached.version = version(linked.version) + 1;
17306
+ detached.payload.version = detached.version;
17307
+ }
17308
+ current.set(id, detached);
17309
+ receipt(linked, linked, detached, "detached");
17310
+ }
17311
+ current.set(key(row), row);
17312
+ receipt(row, before, row, "deleted");
17313
+ }
17314
+ validateGraph(current);
17315
+ for (const [id, row] of current) {
17316
+ if (Date.now() > deadline)
17317
+ fail("Atomic migration exceeded its transaction time budget", 503);
17318
+ const before = original.get(id);
17319
+ if (before && digest(before) === digest(row))
17320
+ continue;
17321
+ await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id) VALUES($1,$2,$3,$4::jsonb,$5::timestamptz,$6::timestamptz,$7,$8) ON CONFLICT(service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=EXCLUDED.version,source_machine_id=EXCLUDED.source_machine_id`, [options.service, row.object_type, row.object_id, row.payload, row.updated_at, row.deleted_at, row.version, row.source_machine_id ?? null]);
17322
+ }
17323
+ const result = { schema_version: 1, operation_id: request.operation_id, snapshot_hash: request.snapshot_hash, authority: request.expected_authority, status: "complete", records: receipts };
17324
+ const changedKeys = new Set(receipts.map((row) => key(row)));
17325
+ const evidence = { request_hash: requestHash, request, receipt: result, before: [...original.values()].filter((row) => changedKeys.has(key(row))) };
17326
+ if (Date.now() > deadline)
17327
+ fail("Atomic migration exceeded its transaction time budget", 503);
17328
+ await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at) VALUES($1,'atomic_project_migrations',$2,$3::jsonb,now())`, [options.service, journalId, evidence]);
17329
+ return result;
17330
+ });
17331
+ } };
17332
+ }
17333
+
17334
+ // src/storage/postgres-adapter.ts
17335
+ init_types();
17336
+ import { randomUUID as randomUUID4 } from "crypto";
17337
+ init_creator_identity();
17338
+
17339
+ // src/lib/instant-compare.ts
17340
+ var SQLITE_STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/;
17341
+ var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
17342
+ function isLeapYear(year) {
17343
+ return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
17344
+ }
17345
+ function sqliteJulianDay(value) {
17346
+ const m = SQLITE_STAMP.exec(value);
15239
17347
  if (!m)
15240
17348
  return null;
15241
17349
  const year = Number(m[1]);
@@ -15269,8 +17377,8 @@ function sqliteJulianDay(value) {
15269
17377
  const ms = Date.UTC(year, month - 1, day, hour, minute, second);
15270
17378
  return (ms + micros / 1000) / 86400000 + 2440587.5 - offsetMinutes / 1440;
15271
17379
  }
15272
- function changedSinceStampNewer(stamp, since) {
15273
- const stampJd = sqliteJulianDay(stamp);
17380
+ function changedSinceStampNewer(stamp2, since) {
17381
+ const stampJd = sqliteJulianDay(stamp2);
15274
17382
  if (stampJd === null)
15275
17383
  return true;
15276
17384
  const sinceJd = sqliteJulianDay(since);
@@ -15504,6 +17612,10 @@ class PostgresTodosSyncStore {
15504
17612
  }
15505
17613
  }
15506
17614
  async pushSnapshot(snapshot, context = {}) {
17615
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines"))
17616
+ throw new Error("Machine tombstones require explicit registry lifecycle operations");
17617
+ if (snapshot.machines !== undefined)
17618
+ validateMachines(snapshot.machines);
15507
17619
  const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
15508
17620
  if (routingErrors.length > 0) {
15509
17621
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
@@ -15526,6 +17638,14 @@ class PostgresTodosSyncStore {
15526
17638
  throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
15527
17639
  }
15528
17640
  const result = { records: 0, objectTypes: {} };
17641
+ if (snapshot.machines?.length) {
17642
+ if (!this.client.transaction)
17643
+ throw new Error("Machine snapshot migration requires transaction(callback)");
17644
+ const registry = createPostgresMachineRegistry({ query: client.query.bind(client), transaction: (fn) => fn(client) }, this.service, this.tableName, async () => {});
17645
+ const receipt = await registry.execute({ action: "import", machines: snapshot.machines });
17646
+ result.records += receipt.inserted + receipt.skipped;
17647
+ result.objectTypes.machines = receipt.inserted + receipt.skipped;
17648
+ }
15529
17649
  const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
15530
17650
  for (const entry of snapshotEntries(snapshot)) {
15531
17651
  if (entry.deletedAt === null)
@@ -15689,6 +17809,7 @@ function rowsToSnapshot(rows) {
15689
17809
  tasks: [],
15690
17810
  projects: [],
15691
17811
  projectMachinePaths: [],
17812
+ machines: [],
15692
17813
  plans: [],
15693
17814
  agents: [],
15694
17815
  taskLists: [],
@@ -15700,6 +17821,12 @@ function rowsToSnapshot(rows) {
15700
17821
  for (const row of rows) {
15701
17822
  const payload = payloadRecord(row.payload);
15702
17823
  const deletedAt = stringValue(row.deleted_at);
17824
+ if (row.object_type === "machines") {
17825
+ if (deletedAt)
17826
+ throw new Error("Snapshot contains retired machine identities; use the authoritative registry instead of silently discarding tombstones");
17827
+ snapshot.machines.push(payload);
17828
+ continue;
17829
+ }
15703
17830
  if (deletedAt) {
15704
17831
  snapshot.tombstones ??= [];
15705
17832
  snapshot.tombstones.push({
@@ -15760,12 +17887,12 @@ init_redaction();
15760
17887
  init_task_parent_integrity();
15761
17888
 
15762
17889
  // src/task-manifest/canonical.ts
15763
- import { createHash as createHash5 } from "crypto";
17890
+ import { createHash as createHash6 } from "crypto";
15764
17891
  function canonicalize2(value) {
15765
17892
  if (Array.isArray(value))
15766
17893
  return value.map(canonicalize2);
15767
17894
  if (value !== null && typeof value === "object") {
15768
- return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry2]) => [key, canonicalize2(entry2)]));
17895
+ return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key2, entry2]) => [key2, canonicalize2(entry2)]));
15769
17896
  }
15770
17897
  return value;
15771
17898
  }
@@ -15773,10 +17900,10 @@ function canonicalJson(value) {
15773
17900
  return JSON.stringify(canonicalize2(value));
15774
17901
  }
15775
17902
  function canonicalDigest(value) {
15776
- return createHash5("sha256").update(canonicalJson(value)).digest("hex");
17903
+ return createHash6("sha256").update(canonicalJson(value)).digest("hex");
15777
17904
  }
15778
17905
  function deterministicUuid(namespace, ...parts) {
15779
- const bytes = createHash5("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
17906
+ const bytes = createHash6("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
15780
17907
  bytes[6] = bytes[6] & 15 | 80;
15781
17908
  bytes[8] = bytes[8] & 63 | 128;
15782
17909
  const hex = bytes.toString("hex");
@@ -15803,6 +17930,8 @@ function createPostgresTodosStorageAdapter(options) {
15803
17930
  count: (filter = {}) => store.countTasks(filter),
15804
17931
  update: (id, input, context) => updateTask2(id, input, store, context),
15805
17932
  delete: (id, context) => store.deleteTaskHierarchy(id, context),
17933
+ bulkCreateAtomic: (inputs, context) => bulkCreateTasksAtomic(inputs, store, context),
17934
+ bulkDeleteAtomic: (ids, force, context) => bulkDeleteTasksAtomic(ids, force, store, context),
15806
17935
  start: (id, agentId) => startTask2(id, agentId, store),
15807
17936
  complete: (id, agentId, options2) => completeTask2(id, agentId, options2, store),
15808
17937
  fail: (id, agentId, reason, options2) => failTask2(id, agentId, reason, options2, store),
@@ -15819,6 +17948,7 @@ function createPostgresTodosStorageAdapter(options) {
15819
17948
  add: (taskId, dependsOn, context) => addDependency2(taskId, dependsOn, store, context),
15820
17949
  remove: (taskId, dependsOn) => removeDependency2(taskId, dependsOn, store),
15821
17950
  list: (taskId) => listDependencies(taskId, store),
17951
+ listPage: (page) => store.listDependencyPage(page),
15822
17952
  listAll: () => store.list("dependencies")
15823
17953
  },
15824
17954
  verifications: {
@@ -15836,13 +17966,14 @@ function createPostgresTodosStorageAdapter(options) {
15836
17966
  find: (ref) => findGitRefs(ref, store)
15837
17967
  },
15838
17968
  projects: {
15839
- create: (input, context) => createProject2(input, store, context),
17969
+ create: (input, context) => store.withDependencyGraphTransaction((scoped) => createProject2(input, scoped, context)),
15840
17970
  get: (id) => store.get("projects", id),
15841
17971
  getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
15842
17972
  list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
15843
- update: (id, input) => updateProject2(id, input, store),
17973
+ update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateProject2(id, input, scoped)),
15844
17974
  rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
15845
- delete: (id, context) => store.delete("projects", id, context)
17975
+ delete: async (id, context) => (await store.deleteProjectPreserving(id, false, context)).deleted,
17976
+ deletePreserving: (id, force, context, requireCompletedTasks) => store.deleteProjectPreserving(id, force, context, requireCompletedTasks)
15846
17977
  },
15847
17978
  plans: {
15848
17979
  create: (input, context) => createPlan2(input, store, context),
@@ -15851,6 +17982,7 @@ function createPostgresTodosStorageAdapter(options) {
15851
17982
  update: (id, input) => updatePlan2(id, input, store),
15852
17983
  completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
15853
17984
  delete: (id, context) => store.deletePlan(id, context),
17985
+ deletePreserving: (id, force, context) => store.deletePlanPreserving(id, force, context),
15854
17986
  addComment: (input, context) => addPlanComment2(input, store, context),
15855
17987
  getComments: async (planId) => {
15856
17988
  const pages = [];
@@ -15875,30 +18007,41 @@ function createPostgresTodosStorageAdapter(options) {
15875
18007
  apply: (input, context) => store.applyPlanProjectLink(input, context),
15876
18008
  rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
15877
18009
  getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
15878
- getReceiptByIdempotencyKey: (key) => store.getPlanProjectLinkReceiptByIdempotencyKey(key)
18010
+ getReceiptByIdempotencyKey: (key2) => store.getPlanProjectLinkReceiptByIdempotencyKey(key2)
15879
18011
  },
15880
18012
  agents: {
15881
18013
  register: (input, context) => registerAgent2(input, store, context),
15882
18014
  get: (id) => store.get("agents", id),
15883
18015
  getByName: async (name) => matchAgentByName(await store.list("agents"), name),
15884
18016
  list: async (options2) => (await store.list("agents")).filter((agent) => options2?.include_archived || agent.status !== "archived").sort((a, b) => a.name.localeCompare(b.name)),
18017
+ listPage: (options2) => store.listAgentPage(options2),
15885
18018
  update: (id, input) => updateAgent2(id, input, store),
15886
18019
  heartbeat: (idOrName, context) => heartbeatAgent(idOrName, store, context),
15887
18020
  release: (idOrName, sessionId, context) => releaseAgent2(idOrName, sessionId, store, context)
15888
18021
  },
15889
18022
  taskLists: {
15890
18023
  create: (input, context) => createTaskList2(input, store, context),
15891
- get: (id) => store.get("task_lists", id),
15892
- getBySlug: async (slug, projectId) => (await store.list("task_lists")).find((list) => list.slug === slug && (projectId === undefined || list.project_id === projectId)) ?? null,
15893
- list: async (projectId) => (await store.list("task_lists")).filter((list) => projectId === undefined || list.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
15894
- update: (id, input) => updateTaskList2(id, input, store),
15895
- delete: (id, context) => store.delete("task_lists", id, context)
18024
+ get: async (id) => {
18025
+ const list = await store.get("task_lists", id);
18026
+ return list ? withTaskListStatus(list) : null;
18027
+ },
18028
+ getBySlug: async (slug, projectId) => {
18029
+ const list = (await store.list("task_lists")).find((list2) => list2.slug === slug && (projectId === undefined || list2.project_id === projectId));
18030
+ return list ? withTaskListStatus(list) : null;
18031
+ },
18032
+ list: async (projectId) => (await store.list("task_lists")).map(withTaskListStatus).filter((list) => projectId === undefined || list.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
18033
+ update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateTaskList2(id, input, scoped)).then(withTaskListStatus),
18034
+ delete: async (id, context) => (await store.deleteTaskListPreserving(id, false, context)).deleted,
18035
+ deletePreserving: (id, force, context) => store.deleteTaskListPreserving(id, force, context)
15896
18036
  },
15897
18037
  templates: {
18038
+ updateWithHistory: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
18039
+ history: (id) => store.withTemplateTransaction((scoped) => readTemplateHistory(id, scoped)),
18040
+ initialize: (context) => store.withTemplateTransaction((scoped) => initializeTemplates(scoped, context)),
15898
18041
  create: (input, context) => createTemplate2(input, store, context),
15899
18042
  get: (id) => store.get("templates", id),
15900
18043
  list: async () => (await store.list("templates")).sort((a, b) => a.name.localeCompare(b.name)),
15901
- update: (id, input) => updateTemplate2(id, input, store),
18044
+ update: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
15902
18045
  delete: (id, context) => deleteTemplate2(id, store, context),
15903
18046
  getWithTasks: async (id) => {
15904
18047
  const template = await store.get("templates", id);
@@ -15929,13 +18072,16 @@ function createPostgresTodosStorageAdapter(options) {
15929
18072
  getCommentsPage: async (taskId, options2) => {
15930
18073
  return (await store.listComments(taskId, options2)).map(redactComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
15931
18074
  },
15932
- getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
15933
- getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
18075
+ getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)),
18076
+ getTaskHistoryPage: (taskId, options2) => store.listTaskHistoryPage(taskId, options2),
18077
+ getRecentActivity: (limit = 20) => store.listRecentActivity(limit)
15934
18078
  },
18079
+ machines: createPostgresMachineRegistry(options.client, options.service ?? "todos", options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, () => store.ensureSchema()),
18080
+ atomicProjectMigration: createAtomicProjectMigration({ client: options.client, table: options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, service: options.service ?? "todos", ensureSchema: () => store.ensureSchema() }),
15935
18081
  sync: {
15936
18082
  getTasksChangedSince: (since, filters) => getChangedSince(since, filters, store),
15937
18083
  exportSnapshot: () => exportSnapshot(store),
15938
- importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
18084
+ importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context, adapter.machines)
15939
18085
  },
15940
18086
  integrity: {
15941
18087
  report: () => store.integrityReport()
@@ -15952,6 +18098,7 @@ class PostgresJsonRecordStore {
15952
18098
  tableName;
15953
18099
  cursorTableName;
15954
18100
  schemaReady = null;
18101
+ projectIntegrityLocked = false;
15955
18102
  constructor(options) {
15956
18103
  this.options = options;
15957
18104
  this.service = options.service ?? "todos";
@@ -15962,6 +18109,45 @@ class PostgresJsonRecordStore {
15962
18109
  machineId(context) {
15963
18110
  return context?.requestId ?? this.sourceMachineId ?? null;
15964
18111
  }
18112
+ async withDependencyGraphTransaction(fn) {
18113
+ await this.ensureSchema();
18114
+ if (this.projectIntegrityLocked)
18115
+ return fn(this);
18116
+ return this.withTaskParentIntegrityTransaction(async (client) => {
18117
+ const scoped = new PostgresJsonRecordStore({ ...this.options, client });
18118
+ scoped.schemaReady = Promise.resolve();
18119
+ scoped.projectIntegrityLocked = true;
18120
+ return fn(scoped);
18121
+ });
18122
+ }
18123
+ async withTemplateTransaction(fn) {
18124
+ await this.ensureSchema();
18125
+ const transaction = this.options.client.transaction;
18126
+ if (!transaction)
18127
+ throw new Error("TEMPLATE_ATOMICITY_UNAVAILABLE: upgrade the Todos storage backend");
18128
+ return transaction(async (client) => {
18129
+ await client.query("/* todos:template-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':template-integrity', 0))", [this.service]);
18130
+ const scoped = new PostgresJsonRecordStore({ ...this.options, client });
18131
+ scoped.schemaReady = Promise.resolve();
18132
+ return fn(scoped);
18133
+ });
18134
+ }
18135
+ async lockTemplate(id) {
18136
+ await this.options.client.query(`/* todos:template-row-lock */ SELECT object_id FROM ${this.tableName} WHERE service = $1 AND object_type = 'templates' AND object_id = $2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
18137
+ }
18138
+ async persistTemplateRevision(template, previousVersion) {
18139
+ const result = await this.options.client.query(`/* todos:template-version-cas */ UPDATE ${this.tableName}
18140
+ SET payload = $3::jsonb,
18141
+ updated_at = GREATEST(clock_timestamp(), COALESCE(updated_at, 'epoch'::timestamptz) + interval '1 microsecond'),
18142
+ version = $5
18143
+ WHERE service = $1 AND object_type = 'templates' AND object_id = $2
18144
+ AND deleted_at IS NULL AND (payload->>'version')::integer = $4
18145
+ AND COALESCE(version, 1) = $4
18146
+ RETURNING payload`, [this.service, template.id, jsonbParam(template), previousVersion, template.version]);
18147
+ if (result.rows.length !== 1)
18148
+ throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template revision write was refused; no history was committed");
18149
+ return payloadRecord2(result.rows[0].payload);
18150
+ }
15965
18151
  async ensureSchema() {
15966
18152
  if (!this.schemaReady) {
15967
18153
  this.schemaReady = (async () => {
@@ -16073,6 +18259,128 @@ class PostgresJsonRecordStore {
16073
18259
  async list(type) {
16074
18260
  return (await this.listRecords(type)).map((record) => record.payload);
16075
18261
  }
18262
+ async listAgentPage(options) {
18263
+ const { limit, offset } = options;
18264
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18265
+ throw new Error("Postgres agent page limit must be an integer from 1 to 500");
18266
+ }
18267
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18268
+ throw new Error("Postgres agent page offset must be a non-negative integer");
18269
+ }
18270
+ await this.ensureSchema();
18271
+ const result = await this.options.client.query(`/* todos:list-agent-page */ WITH filtered AS (
18272
+ SELECT object_id, payload
18273
+ FROM ${this.tableName}
18274
+ WHERE service = $1 AND object_type = 'agents' AND deleted_at IS NULL
18275
+ AND ($2::boolean OR COALESCE(payload->>'status', 'active') <> 'archived')
18276
+ ), page AS (
18277
+ SELECT object_id, payload FROM filtered
18278
+ ORDER BY LOWER(payload->>'name'), object_id
18279
+ LIMIT $3 OFFSET $4
18280
+ )
18281
+ SELECT (SELECT count(*) FROM filtered) AS total,
18282
+ COALESCE((SELECT jsonb_agg(payload ORDER BY LOWER(payload->>'name'), object_id) FROM page), '[]'::jsonb) AS agents`, [this.service, options.include_archived === true, limit, offset]);
18283
+ const row = result.rows[0];
18284
+ const total = Number(row?.total);
18285
+ if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.agents)) {
18286
+ throw new Error("Postgres agent page returned invalid count or agents");
18287
+ }
18288
+ if (row.agents.length > limit || row.agents.length > 0 && offset + row.agents.length > total) {
18289
+ throw new Error("Postgres agent page exceeded its requested bounds");
18290
+ }
18291
+ return { agents: row.agents.map((value) => payloadRecord2(value)), total };
18292
+ }
18293
+ async listRecentActivity(limit) {
18294
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
18295
+ throw new Error("Postgres recent activity limit must be an integer from 1 to 10000");
18296
+ }
18297
+ await this.ensureSchema();
18298
+ const result = await this.options.client.query(`/* todos:list-recent-activity */ SELECT payload
18299
+ FROM ${this.tableName}
18300
+ WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
18301
+ ORDER BY payload->>'created_at' DESC, object_id DESC
18302
+ LIMIT $2`, [this.service, limit]);
18303
+ return result.rows.map((row) => payloadRecord2(row.payload));
18304
+ }
18305
+ async listTaskHistoryPage(taskId, options) {
18306
+ const { limit, offset } = options;
18307
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18308
+ throw new Error("Postgres task history limit must be an integer from 1 to 500");
18309
+ }
18310
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18311
+ throw new Error("Postgres task history offset must be a non-negative integer");
18312
+ }
18313
+ await this.ensureSchema();
18314
+ const direction = options.order === "asc" ? "ASC" : "DESC";
18315
+ const result = await this.options.client.query(`/* todos:list-task-history-page */ WITH filtered AS (
18316
+ SELECT object_id, payload, payload->>'created_at' AS created_at
18317
+ FROM ${this.tableName}
18318
+ WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
18319
+ AND payload->>'task_id' = $2
18320
+ AND ($3::text IS NULL OR payload->>'created_at' >= $3)
18321
+ AND ($4::text IS NULL OR payload->>'created_at' <= $4)
18322
+ ), page AS (
18323
+ SELECT object_id, payload, created_at FROM filtered
18324
+ ORDER BY created_at ${direction}, object_id ${direction}
18325
+ LIMIT $5 OFFSET $6
18326
+ )
18327
+ SELECT (SELECT count(*) FROM filtered) AS total,
18328
+ COALESCE((SELECT jsonb_agg(payload ORDER BY created_at ${direction}, object_id ${direction}) FROM page), '[]'::jsonb) AS history`, [this.service, taskId, options.since ?? null, options.until ?? null, limit, offset]);
18329
+ const row = result.rows[0];
18330
+ const total = Number(row?.total);
18331
+ if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.history)) {
18332
+ throw new Error("Postgres task history page returned invalid count or history");
18333
+ }
18334
+ if (row.history.length > limit || row.history.length > 0 && offset + row.history.length > total) {
18335
+ throw new Error("Postgres task history page exceeded its requested bounds");
18336
+ }
18337
+ return { history: row.history.map((value) => payloadRecord2(value)), total };
18338
+ }
18339
+ async listDependencyPage(options) {
18340
+ const { limit, offset } = options;
18341
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
18342
+ throw new Error("Postgres dependency page limit must be an integer from 1 to 500");
18343
+ }
18344
+ if (!Number.isSafeInteger(offset) || offset < 0) {
18345
+ throw new Error("Postgres dependency page offset must be a non-negative integer");
18346
+ }
18347
+ await this.ensureSchema();
18348
+ const result = await this.options.client.query(`/* todos:list-dependencies-page */ WITH page AS (
18349
+ SELECT payload, updated_at, object_id
18350
+ FROM ${this.tableName}
18351
+ WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL
18352
+ ORDER BY updated_at ASC, object_id ASC
18353
+ LIMIT $2 OFFSET $3
18354
+ )
18355
+ SELECT
18356
+ (SELECT COUNT(*)::text FROM ${this.tableName}
18357
+ WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL) AS total,
18358
+ COALESCE(
18359
+ jsonb_agg(page.payload ORDER BY page.updated_at ASC, page.object_id ASC)
18360
+ FILTER (WHERE page.payload IS NOT NULL),
18361
+ '[]'::jsonb
18362
+ ) AS dependencies
18363
+ FROM page`, [this.service, limit, offset]);
18364
+ const row = result.rows[0];
18365
+ const totalText = typeof row?.total === "string" ? row.total : typeof row?.total === "number" && Number.isSafeInteger(row.total) ? String(row.total) : null;
18366
+ if (totalText === null || !/^\d+$/.test(totalText)) {
18367
+ throw new Error("Postgres dependency page returned an invalid total");
18368
+ }
18369
+ const total = Number(totalText);
18370
+ if (!Number.isSafeInteger(total)) {
18371
+ throw new Error("Postgres dependency page total exceeds the safe integer range");
18372
+ }
18373
+ if (!Array.isArray(row?.dependencies)) {
18374
+ throw new Error("Postgres dependency page returned an invalid dependencies array");
18375
+ }
18376
+ if (row.dependencies.length > limit || offset + row.dependencies.length > total) {
18377
+ throw new Error("Postgres dependency page returned rows outside the requested bound");
18378
+ }
18379
+ return {
18380
+ dependencies: row.dependencies.map((value) => payloadRecord2(value)),
18381
+ total
18382
+ };
18383
+ }
16076
18384
  async listComments(taskId, options = {}) {
16077
18385
  await this.ensureSchema();
16078
18386
  const limit = options.limit ?? 100;
@@ -16148,6 +18456,10 @@ class PostgresJsonRecordStore {
16148
18456
  return "1=0";
16149
18457
  return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
16150
18458
  };
18459
+ if (filter.archived_only === true)
18460
+ conds.push(`(payload->>'archived_at' IS NOT NULL)`);
18461
+ else if (filter.include_archived === false)
18462
+ conds.push(`(payload->>'archived_at' IS NULL)`);
16151
18463
  if (filter.ids)
16152
18464
  conds.push(inClause("payload->>'id'", filter.ids));
16153
18465
  if (filter.project_id !== undefined)
@@ -16203,7 +18515,7 @@ class PostgresJsonRecordStore {
16203
18515
  async listTasks(filter) {
16204
18516
  await this.ensureSchema();
16205
18517
  const { where, params, queryRef } = await this.buildTaskFilterSql(filter);
16206
- const orderBy = queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
18518
+ const orderBy = filter.archived_only ? `ORDER BY payload->>'archived_at' DESC, object_id DESC` : queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
16207
18519
  let sql = `/* todos:list-tasks */ SELECT payload FROM ${this.tableName} WHERE ${where} ${orderBy}`;
16208
18520
  if (filter.limit !== undefined) {
16209
18521
  params.push(filter.limit);
@@ -16316,6 +18628,32 @@ class PostgresJsonRecordStore {
16316
18628
  throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
16317
18629
  }
16318
18630
  }
18631
+ const projectTypes = new Set(["projects", "tasks", "plans", "task_lists"]);
18632
+ const reference = type === "projects" ? value.parent_id : value.project_id;
18633
+ const planReference = value.plan_id;
18634
+ const needsPlanFence = type === "tasks" && typeof planReference === "string" && planReference.length > 0;
18635
+ const listReference = value.task_list_id;
18636
+ const needsListFence = (type === "tasks" || type === "plans") && typeof listReference === "string" && listReference.length > 0;
18637
+ const needsProjectFence = needsListFence || needsPlanFence || type === "projects" || projectTypes.has(type) && typeof reference === "string" && reference.length > 0;
18638
+ if (needsProjectFence && !this.projectIntegrityLocked) {
18639
+ return this.withDependencyGraphTransaction((scoped) => scoped.upsert(type, value, context));
18640
+ }
18641
+ if (needsListFence)
18642
+ await this.assertTaskListMembership(type, value.id, listReference);
18643
+ if (needsPlanFence) {
18644
+ const plan = await this.options.client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, planReference]);
18645
+ if (!plan.rows.length)
18646
+ throw new PlanNotFoundError(planReference);
18647
+ }
18648
+ if (projectTypes.has(type)) {
18649
+ if (typeof reference === "string" && reference)
18650
+ await this.assertLiveProject(reference);
18651
+ if (type === "projects") {
18652
+ const prior = await this.options.client.query(`SELECT deleted_at FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 FOR UPDATE`, [this.service, value.id]);
18653
+ if (prior.rows[0]?.deleted_at)
18654
+ throw new ProjectNotFoundError(value.id);
18655
+ }
18656
+ }
16319
18657
  await this.ensureSchema();
16320
18658
  const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
16321
18659
  let result;
@@ -16397,10 +18735,15 @@ class PostgresJsonRecordStore {
16397
18735
  if (planIds.length === 0 && !parentGuard)
16398
18736
  return this.upsert("tasks", value, context);
16399
18737
  await this.ensureSchema();
16400
- if (parentGuard && !queryClient) {
18738
+ if (!queryClient && this.projectIntegrityLocked)
18739
+ queryClient = this.options.client;
18740
+ if (!queryClient) {
16401
18741
  return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
16402
18742
  }
16403
18743
  const client = queryClient ?? this.options.client;
18744
+ if (value.project_id)
18745
+ await this.assertLiveProject(value.project_id, client);
18746
+ await this.assertTaskListMembership("tasks", value.id, value.task_list_id, client);
16404
18747
  const updatedAt = value.updated_at;
16405
18748
  const targetPlanId = value.plan_id;
16406
18749
  const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
@@ -16535,8 +18878,11 @@ class PostgresJsonRecordStore {
16535
18878
  }
16536
18879
  return payloadRecord2(row.payload);
16537
18880
  }
16538
- async updatePlanWithProjectLinkGuard(value, context = {}) {
18881
+ async updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context = {}) {
18882
+ if (!this.projectIntegrityLocked)
18883
+ return this.withDependencyGraphTransaction((scoped) => scoped.updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context));
16539
18884
  await this.ensureSchema();
18885
+ await this.assertTaskListMembership("plans", value.id, value.task_list_id);
16540
18886
  const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
16541
18887
  SELECT payload FROM ${this.tableName}
16542
18888
  WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
@@ -16555,19 +18901,24 @@ class PostgresJsonRecordStore {
16555
18901
  version = COALESCE(r.version, 0) + 1
16556
18902
  FROM locked_plan
16557
18903
  WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
18904
+ AND locked_plan.payload->>'updated_at' = $6
16558
18905
  RETURNING r.payload
16559
18906
  )
16560
18907
  SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
18908
+ (SELECT payload->>'updated_at' FROM locked_plan) AS current_revision,
16561
18909
  (SELECT payload FROM stored) AS payload`, [
16562
18910
  this.service,
16563
18911
  value.id,
16564
18912
  jsonbParam(value),
16565
18913
  value.updated_at,
16566
- context.requestId ?? this.sourceMachineId ?? null
18914
+ context.requestId ?? this.sourceMachineId ?? null,
18915
+ expectedUpdatedAt
16567
18916
  ]);
16568
18917
  const row = result.rows[0];
16569
- if (!row?.plan_found || !row.payload)
18918
+ if (!row?.plan_found)
16570
18919
  throw new PlanNotFoundError(value.id);
18920
+ if (!row.payload)
18921
+ throw new PlanRevisionConflictError(value.id, expectedUpdatedAt, row.current_revision);
16571
18922
  return payloadRecord2(row.payload);
16572
18923
  }
16573
18924
  async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
@@ -16841,6 +19192,71 @@ class PostgresJsonRecordStore {
16841
19192
  return null;
16842
19193
  return Number(row.counter);
16843
19194
  }
19195
+ async assertTaskListMembership(type, id, target, client = this.options.client) {
19196
+ if (target === undefined || target === null || target === "")
19197
+ return;
19198
+ const prior = await client.query(`SELECT payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type=$2 AND object_id=$3 AND deleted_at IS NULL`, [this.service, type, id]);
19199
+ if (prior.rows[0]?.task_list_id === target)
19200
+ return;
19201
+ const list = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, target]);
19202
+ if (!list.rows.length)
19203
+ throw new TaskListNotFoundError(String(target));
19204
+ }
19205
+ async deleteTaskListPreserving(id, force, context = {}) {
19206
+ await this.ensureSchema();
19207
+ return this.withTaskParentIntegrityTransaction(async (client) => {
19208
+ const receipt = { schema_version: 1, task_list_id: id, deleted: false, detached_task_ids: [], detached_plan_ids: [], detached_tasks: 0, detached_plans: 0 };
19209
+ const target = await client.query(`SELECT object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
19210
+ if (!target.rows.length)
19211
+ return receipt;
19212
+ const related = await client.query(`SELECT object_type,object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2 ORDER BY object_type,object_id FOR UPDATE`, [this.service, id]);
19213
+ if (related.rows.length && !force)
19214
+ throw new ResourceConflictError("TASK_LIST_NOT_EMPTY", "Task list has linked records; force confirms detaching them without deleting content or history");
19215
+ const timestamp2 = new Date([...target.rows, ...related.rows].reduce((latest, row) => Math.max(latest, Date.parse(String(row.updated_at)) + 1), Date.now())).toISOString();
19216
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{task_list_id}','null'::jsonb)||jsonb_build_object('updated_at',$3::text)||CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
19217
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
19218
+ receipt.deleted = true;
19219
+ receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
19220
+ receipt.detached_plan_ids = related.rows.filter((row) => row.object_type === "plans").map((row) => row.object_id);
19221
+ receipt.detached_tasks = receipt.detached_task_ids.length;
19222
+ receipt.detached_plans = receipt.detached_plan_ids.length;
19223
+ return receipt;
19224
+ });
19225
+ }
19226
+ async assertLiveProject(id, client = this.options.client) {
19227
+ const result = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, id]);
19228
+ if (!result.rows.length)
19229
+ throw new ProjectNotFoundError(id);
19230
+ }
19231
+ async deleteProjectPreserving(id, force, context = {}, requireCompletedTasks = false) {
19232
+ await this.ensureSchema();
19233
+ return this.withTaskParentIntegrityTransaction(async (client) => {
19234
+ const project = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
19235
+ const receipt = { schema_version: 1, project_id: id, deleted: false, preserved_tasks: 0, preserved_plans: 0, detached_task_lists: 0, detached_child_projects: 0 };
19236
+ if (!project.rows.length)
19237
+ return receipt;
19238
+ const related = await client.query(`SELECT object_type,object_id,payload->>'status' AS status FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2)) FOR UPDATE`, [this.service, id]);
19239
+ if (requireCompletedTasks && related.rows.some((row) => row.object_type === "tasks" && row.status !== "completed" && row.status !== "cancelled"))
19240
+ throw new ResourceConflictError("PROJECT_INCOMPLETE", "Project has incomplete tasks; deregistration refused");
19241
+ if (related.rows.length && !force)
19242
+ throw new ResourceConflictError("PROJECT_NOT_EMPTY", "Project has linked records; force confirms detaching them without deleting their content");
19243
+ const timestamp2 = new Date().toISOString();
19244
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(jsonb_set(payload,CASE WHEN object_type='projects' THEN '{parent_id}'::text[] ELSE '{project_id}'::text[] END,'null'::jsonb),'{updated_at}',to_jsonb($3::text)) || CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END, updated_at=$3::timestamptz, version=CASE WHEN object_type='tasks' THEN COALESCE(version,0)+1 ELSE version END,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2))`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
19245
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,source_machine_id=$4 WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
19246
+ receipt.deleted = true;
19247
+ for (const row of related.rows) {
19248
+ if (row.object_type === "tasks")
19249
+ receipt.preserved_tasks++;
19250
+ else if (row.object_type === "plans")
19251
+ receipt.preserved_plans++;
19252
+ else if (row.object_type === "task_lists")
19253
+ receipt.detached_task_lists++;
19254
+ else
19255
+ receipt.detached_child_projects++;
19256
+ }
19257
+ return receipt;
19258
+ });
19259
+ }
16844
19260
  async delete(type, id, context = {}) {
16845
19261
  await this.ensureSchema();
16846
19262
  const existing = await this.get(type, id);
@@ -16858,9 +19274,11 @@ class PostgresJsonRecordStore {
16858
19274
  }
16859
19275
  async deleteTaskHierarchy(id, context = {}) {
16860
19276
  await this.ensureSchema();
16861
- return this.withTaskParentIntegrityTransaction(async (client) => {
16862
- const timestamp2 = new Date().toISOString();
16863
- const result = await client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
19277
+ if (!this.projectIntegrityLocked) {
19278
+ return this.withDependencyGraphTransaction((scoped) => scoped.deleteTaskHierarchy(id, context));
19279
+ }
19280
+ const timestamp2 = new Date().toISOString();
19281
+ const result = await this.options.client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
16864
19282
  task_tree(object_id, path, cycle) AS (
16865
19283
  SELECT task.object_id, ARRAY[task.object_id], false
16866
19284
  FROM ${this.tableName} AS task
@@ -16922,13 +19340,12 @@ class PostgresJsonRecordStore {
16922
19340
  SELECT EXISTS (SELECT 1 FROM task_tree WHERE object_id = $2) AS found,
16923
19341
  (SELECT count(*) FROM tombstoned) AS deleted_count,
16924
19342
  (SELECT count(*) FROM tombstoned_related) AS related_deleted_count`, [
16925
- this.service,
16926
- id,
16927
- timestamp2,
16928
- context.requestId ?? this.sourceMachineId ?? null
16929
- ]);
16930
- return Boolean(result.rows[0]?.found);
16931
- });
19343
+ this.service,
19344
+ id,
19345
+ timestamp2,
19346
+ context.requestId ?? this.sourceMachineId ?? null
19347
+ ]);
19348
+ return Boolean(result.rows[0]?.found);
16932
19349
  }
16933
19350
  async getPlanProjectLinkReceipt(receiptId) {
16934
19351
  const value = await this.get("plan_project_link_receipts", receiptId);
@@ -16945,7 +19362,7 @@ class PostgresJsonRecordStore {
16945
19362
  }
16946
19363
  return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
16947
19364
  }
16948
- async currentPlanProjectLinkResult(receipt, action) {
19365
+ async currentPlanProjectLinkResult(receipt, action, previouslyCommitted = false) {
16949
19366
  const [plan, project, tasks] = await Promise.all([
16950
19367
  this.get("plans", receipt.plan_id),
16951
19368
  this.get("projects", receipt.project_id),
@@ -16953,11 +19370,14 @@ class PostgresJsonRecordStore {
16953
19370
  ]);
16954
19371
  const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
16955
19372
  if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
16956
- throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
19373
+ throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", previouslyCommitted ? "The plan-project link was already committed; current state has drifted from its immutable receipt" : "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id, ...previouslyCommitted ? { operation_committed: true, current_state_matches_receipt: false, receipt } : {} });
16957
19374
  }
16958
19375
  return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
16959
19376
  }
16960
19377
  async applyPlanProjectLink(input, context = {}) {
19378
+ if (!this.projectIntegrityLocked) {
19379
+ return this.withDependencyGraphTransaction((scoped) => scoped.applyPlanProjectLink(input, context));
19380
+ }
16961
19381
  await this.ensureSchema();
16962
19382
  const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
16963
19383
  if (existing) {
@@ -16968,7 +19388,7 @@ class PostgresJsonRecordStore {
16968
19388
  if (rolledBack) {
16969
19389
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
16970
19390
  }
16971
- return this.currentPlanProjectLinkResult(existing, "already_linked");
19391
+ return this.currentPlanProjectLinkResult(existing, "already_linked", true);
16972
19392
  }
16973
19393
  const [plan, project, tasks, scopedPlans] = await Promise.all([
16974
19394
  this.get("plans", input.plan_id),
@@ -17110,7 +19530,7 @@ class PostgresJsonRecordStore {
17110
19530
  if (isPostgresUniqueViolation(error)) {
17111
19531
  const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
17112
19532
  if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
17113
- return this.currentPlanProjectLinkResult(raced, "already_linked");
19533
+ return this.currentPlanProjectLinkResult(raced, "already_linked", true);
17114
19534
  }
17115
19535
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
17116
19536
  }
@@ -17133,7 +19553,7 @@ class PostgresJsonRecordStore {
17133
19553
  if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
17134
19554
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
17135
19555
  }
17136
- return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
19556
+ return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked", row.existing_receipt != null);
17137
19557
  }
17138
19558
  async rollbackPlanProjectLink(input, context = {}) {
17139
19559
  await this.ensureSchema();
@@ -17256,6 +19676,28 @@ class PostgresJsonRecordStore {
17256
19676
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
17257
19677
  return accepted;
17258
19678
  }
19679
+ async deletePlanPreserving(id, force, context = {}) {
19680
+ await this.ensureSchema();
19681
+ return this.withTaskParentIntegrityTransaction(async (client) => {
19682
+ const receipt = { schema_version: 1, plan_id: id, deleted: false, detached_task_ids: [], detached_task_list_ids: [], detached_tasks: 0, detached_task_lists: 0 };
19683
+ const target = await client.query(`SELECT object_id,payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
19684
+ if (!target.rows.length)
19685
+ return receipt;
19686
+ const related = await client.query(`SELECT object_type,object_id FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type='tasks' AND payload->>'plan_id'=$2) OR (object_type='task_lists' AND object_id=$3)) ORDER BY object_type,object_id FOR UPDATE`, [this.service, id, target.rows[0].task_list_id]);
19687
+ const notes = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plan_comments' AND payload->>'plan_id'=$2 AND deleted_at IS NULL LIMIT 1`, [this.service, id]);
19688
+ if ((related.rows.length || notes.rows.length) && !force)
19689
+ throw new ResourceConflictError("PLAN_NOT_EMPTY", "Plan has linked records; force confirms detaching them while preserving content and history");
19690
+ const timestamp2 = new Date().toISOString();
19691
+ await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{plan_id}','null'::jsonb) || jsonb_build_object('updated_at',$3::text) || jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1),updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type='tasks' AND payload->>'plan_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
19692
+ await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
19693
+ receipt.deleted = true;
19694
+ receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
19695
+ receipt.detached_task_list_ids = related.rows.filter((row) => row.object_type === "task_lists").map((row) => row.object_id);
19696
+ receipt.detached_tasks = receipt.detached_task_ids.length;
19697
+ receipt.detached_task_lists = receipt.detached_task_list_ids.length;
19698
+ return receipt;
19699
+ });
19700
+ }
17259
19701
  async deletePlan(id, context = {}) {
17260
19702
  await this.ensureSchema();
17261
19703
  const timestamp2 = new Date().toISOString();
@@ -17302,6 +19744,8 @@ class PostgresJsonRecordStore {
17302
19744
  }
17303
19745
  async tombstone(tombstone, context = {}) {
17304
19746
  await this.ensureSchema();
19747
+ if (tombstone.object_type === "atomic_project_migrations")
19748
+ throw new Error("Migration evidence is immutable");
17305
19749
  const deletedAt = stringValue2(tombstone.deleted_at) ?? new Date().toISOString();
17306
19750
  const updatedAt = stringValue2(tombstone.updated_at) ?? deletedAt;
17307
19751
  const existing = await this.clock(tombstone.object_type, tombstone.object_id);
@@ -17357,9 +19801,105 @@ class PostgresJsonRecordStore {
17357
19801
  updated_at = EXCLUDED.updated_at`, [this.service, name, value]);
17358
19802
  }
17359
19803
  }
19804
+ async function bulkCreateTasksAtomic(inputs, store, context) {
19805
+ return store.withDependencyGraphTransaction(async (scoped) => {
19806
+ const tempIds = new Map;
19807
+ const created = [];
19808
+ const dependencies = [];
19809
+ for (const input of inputs) {
19810
+ const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
19811
+ const task = await createTask2(taskInput, scoped, context);
19812
+ if (temp_id)
19813
+ tempIds.set(temp_id, task.id);
19814
+ created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
19815
+ }
19816
+ for (let index = 0;index < inputs.length; index++) {
19817
+ const input = inputs[index];
19818
+ const taskId = created[index].id;
19819
+ const seenDependencies = new Set;
19820
+ for (const reference of input.depends_on ?? []) {
19821
+ const exact = tempIds.get(reference) ?? (await scoped.get("tasks", reference))?.id;
19822
+ const dependencyId = exact ?? (await scoped.resolveTaskRef(reference))?.id;
19823
+ if (!dependencyId)
19824
+ throw new TaskNotFoundError(reference);
19825
+ if (seenDependencies.has(dependencyId)) {
19826
+ throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
19827
+ }
19828
+ seenDependencies.add(dependencyId);
19829
+ dependencies.push(await addDependency2(taskId, dependencyId, scoped, context));
19830
+ }
19831
+ }
19832
+ return { schema_version: 1, atomic: true, created, dependencies };
19833
+ });
19834
+ }
19835
+ async function bulkDeleteTasksAtomic(ids, force, store, context) {
19836
+ return store.withDependencyGraphTransaction(async (scoped) => {
19837
+ const resolved = [];
19838
+ for (const reference of ids) {
19839
+ resolved.push({
19840
+ reference,
19841
+ task: await scoped.get("tasks", reference) ?? await scoped.resolveTaskRef(reference)
19842
+ });
19843
+ }
19844
+ const seen = new Set;
19845
+ for (const item of resolved) {
19846
+ if (!item.task)
19847
+ continue;
19848
+ if (seen.has(item.task.id)) {
19849
+ throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
19850
+ }
19851
+ seen.add(item.task.id);
19852
+ }
19853
+ const childState = new Map;
19854
+ for (const item of resolved) {
19855
+ if (!item.task)
19856
+ continue;
19857
+ const children = await scoped.listTasks({ parent_id: item.task.id, include_subtasks: true, limit: 1 });
19858
+ childState.set(item.task.id, children.length > 0);
19859
+ }
19860
+ const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
19861
+ const plannedIds = new Set(planned.map((item) => item.task.id));
19862
+ const roots = [];
19863
+ for (const item of planned) {
19864
+ if (!force) {
19865
+ roots.push(item);
19866
+ continue;
19867
+ }
19868
+ let parentId = item.task.parent_id;
19869
+ let covered = false;
19870
+ while (parentId) {
19871
+ if (plannedIds.has(parentId)) {
19872
+ covered = true;
19873
+ break;
19874
+ }
19875
+ parentId = (await scoped.get("tasks", parentId))?.parent_id ?? null;
19876
+ }
19877
+ if (!covered)
19878
+ roots.push(item);
19879
+ }
19880
+ for (const item of roots) {
19881
+ if (!await scoped.deleteTaskHierarchy(item.task.id, context)) {
19882
+ throw new Error(`Atomic bulk delete lost task ${item.task.id}`);
19883
+ }
19884
+ }
19885
+ return {
19886
+ schema_version: 1,
19887
+ atomic: true,
19888
+ force,
19889
+ results: resolved.map((item) => {
19890
+ if (!item.task)
19891
+ return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
19892
+ if (!force && childState.get(item.task.id)) {
19893
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
19894
+ }
19895
+ return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
19896
+ })
19897
+ };
19898
+ });
19899
+ }
17360
19900
  async function createTask2(input, store, context) {
17361
19901
  const timestamp2 = new Date().toISOString();
17362
- const taskId = randomUUID3();
19902
+ const taskId = randomUUID4();
17363
19903
  await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
17364
19904
  const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
17365
19905
  const requestedProjectId = input.project_id ?? context?.projectId ?? null;
@@ -17447,12 +19987,16 @@ async function updateTask2(id, input, store, context) {
17447
19987
  }
17448
19988
  }
17449
19989
  }
19990
+ const patchTouchesPlacement = input.project_id !== undefined || input.task_list_id !== undefined;
19991
+ const effectiveProjectIdForShortId = input.project_id !== undefined ? input.project_id : existing.project_id;
19992
+ const backfilledShortId = existing.short_id === null && patchTouchesPlacement && effectiveProjectIdForShortId ? await nextTaskShortId2(effectiveProjectIdForShortId, store, context) : null;
17450
19993
  const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
17451
19994
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
17452
19995
  const task = {
17453
19996
  ...existing,
17454
19997
  ...definedPatch(input),
17455
19998
  ...terminalNow ? { locked_by: null, locked_at: null } : {},
19999
+ short_id: backfilledShortId ?? existing.short_id,
17456
20000
  version: existing.version + 1,
17457
20001
  updated_at: new Date().toISOString(),
17458
20002
  tags: input.tags ?? existing.tags,
@@ -17581,42 +20125,46 @@ function dependencyId(taskId, dependsOn) {
17581
20125
  return `${taskId}::${dependsOn}`;
17582
20126
  }
17583
20127
  async function addDependency2(taskId, dependsOn, store, context) {
17584
- if (taskId === dependsOn)
17585
- throw new Error("A task cannot depend on itself");
17586
- if (!await store.get("tasks", taskId))
17587
- throw new Error(`Task not found: ${taskId}`);
17588
- if (!await store.get("tasks", dependsOn))
17589
- throw new Error(`Task not found: ${dependsOn}`);
17590
- const edges = await store.list("dependencies");
17591
- const adjacency = new Map;
17592
- for (const edge of edges) {
17593
- if (!adjacency.has(edge.task_id))
17594
- adjacency.set(edge.task_id, []);
17595
- adjacency.get(edge.task_id).push(edge.depends_on);
17596
- }
17597
- const queue = [dependsOn];
17598
- const seen = new Set;
17599
- while (queue.length) {
17600
- const node = queue.shift();
17601
- if (node === taskId)
17602
- throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
17603
- if (seen.has(node))
17604
- continue;
17605
- seen.add(node);
17606
- for (const next of adjacency.get(node) ?? [])
17607
- queue.push(next);
17608
- }
17609
- const timestamp2 = new Date().toISOString();
17610
- const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
17611
- await store.upsert("dependencies", record, context);
17612
- return { task_id: taskId, depends_on: dependsOn };
20128
+ return store.withDependencyGraphTransaction(async (store2) => {
20129
+ if (taskId === dependsOn)
20130
+ throw new Error("A task cannot depend on itself");
20131
+ if (!await store2.get("tasks", taskId))
20132
+ throw new Error(`Task not found: ${taskId}`);
20133
+ if (!await store2.get("tasks", dependsOn))
20134
+ throw new Error(`Task not found: ${dependsOn}`);
20135
+ const edges = await store2.list("dependencies");
20136
+ const adjacency = new Map;
20137
+ for (const edge of edges) {
20138
+ if (!adjacency.has(edge.task_id))
20139
+ adjacency.set(edge.task_id, []);
20140
+ adjacency.get(edge.task_id).push(edge.depends_on);
20141
+ }
20142
+ const queue = [dependsOn];
20143
+ const seen = new Set;
20144
+ while (queue.length) {
20145
+ const node = queue.shift();
20146
+ if (node === taskId)
20147
+ throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
20148
+ if (seen.has(node))
20149
+ continue;
20150
+ seen.add(node);
20151
+ for (const next of adjacency.get(node) ?? [])
20152
+ queue.push(next);
20153
+ }
20154
+ const timestamp2 = new Date().toISOString();
20155
+ const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
20156
+ await store2.upsert("dependencies", record, context);
20157
+ return { task_id: taskId, depends_on: dependsOn };
20158
+ });
17613
20159
  }
17614
20160
  async function removeDependency2(taskId, dependsOn, store) {
17615
- const existing = await store.get("dependencies", dependencyId(taskId, dependsOn));
17616
- if (!existing)
17617
- return false;
17618
- await store.delete("dependencies", dependencyId(taskId, dependsOn));
17619
- return true;
20161
+ return store.withDependencyGraphTransaction(async (store2) => {
20162
+ const existing = await store2.get("dependencies", dependencyId(taskId, dependsOn));
20163
+ if (!existing)
20164
+ return false;
20165
+ await store2.delete("dependencies", dependencyId(taskId, dependsOn));
20166
+ return true;
20167
+ });
17620
20168
  }
17621
20169
  async function listDependencies(taskId, store) {
17622
20170
  const edges = await store.list("dependencies");
@@ -17632,7 +20180,7 @@ async function addVerification(input, store, context) {
17632
20180
  throw new Error(`Task not found: ${input.task_id}`);
17633
20181
  const timestamp2 = new Date().toISOString();
17634
20182
  const verification = {
17635
- id: randomUUID3(),
20183
+ id: randomUUID4(),
17636
20184
  task_id: input.task_id,
17637
20185
  command: input.command,
17638
20186
  status: input.status ?? "unknown",
@@ -17653,7 +20201,7 @@ async function addCommit(input, store, context) {
17653
20201
  throw new Error(`Task not found: ${input.task_id}`);
17654
20202
  const timestamp2 = new Date().toISOString();
17655
20203
  const commit = {
17656
- id: randomUUID3(),
20204
+ id: randomUUID4(),
17657
20205
  task_id: input.task_id,
17658
20206
  sha: input.sha,
17659
20207
  message: input.message ?? null,
@@ -17749,7 +20297,7 @@ async function createProject2(input, store, context) {
17749
20297
  if (!derivedSlug || !taskListId)
17750
20298
  throw new Error("Project name and task-list slug must be non-empty");
17751
20299
  const parentId = input.parent_id ?? null;
17752
- const id = randomUUID3();
20300
+ const id = randomUUID4();
17753
20301
  if (parentId !== null) {
17754
20302
  const parent = await store.get("projects", parentId);
17755
20303
  if (!parent)
@@ -17760,6 +20308,9 @@ async function createProject2(input, store, context) {
17760
20308
  id,
17761
20309
  name: input.name,
17762
20310
  path: input.path,
20311
+ status: input.status ?? "active",
20312
+ short_id: input.short_id ?? null,
20313
+ metadata: input.metadata ?? {},
17763
20314
  description: input.description ?? null,
17764
20315
  task_list_id: taskListId,
17765
20316
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
@@ -17807,6 +20358,9 @@ async function updateProject2(id, input, store) {
17807
20358
  return store.upsert("projects", updated);
17808
20359
  }
17809
20360
  async function createPlan2(input, store, context) {
20361
+ const scheduleError = validatePlanSchedule(input);
20362
+ if (scheduleError)
20363
+ throw new Error(scheduleError);
17810
20364
  const timestamp2 = new Date().toISOString();
17811
20365
  const projectId = input.project_id ?? context?.projectId ?? null;
17812
20366
  const slug = await resolvePostgresPlanSlug({
@@ -17816,7 +20370,7 @@ async function createPlan2(input, store, context) {
17816
20370
  store
17817
20371
  });
17818
20372
  return store.upsert("plans", {
17819
- id: randomUUID3(),
20373
+ id: randomUUID4(),
17820
20374
  slug,
17821
20375
  project_id: projectId,
17822
20376
  task_list_id: input.task_list_id ?? context?.taskListId ?? null,
@@ -17824,6 +20378,8 @@ async function createPlan2(input, store, context) {
17824
20378
  name: input.name,
17825
20379
  description: input.description ?? null,
17826
20380
  status: input.status ?? "active",
20381
+ ...input.start_date !== undefined ? { start_date: input.start_date } : {},
20382
+ ...input.end_date !== undefined ? { end_date: input.end_date } : {},
17827
20383
  created_at: timestamp2,
17828
20384
  updated_at: timestamp2,
17829
20385
  machine_id: store.machineId(context),
@@ -17832,6 +20388,9 @@ async function createPlan2(input, store, context) {
17832
20388
  }
17833
20389
  async function updatePlan2(id, input, store) {
17834
20390
  const plan = await requireRecord("plans", id, store);
20391
+ const scheduleError = validatePlanSchedule({ ...plan, ...input });
20392
+ if (scheduleError)
20393
+ throw new Error(scheduleError);
17835
20394
  const patch = definedPatch(input);
17836
20395
  if (input.slug !== undefined) {
17837
20396
  patch.slug = await resolvePostgresPlanSlug({
@@ -17846,8 +20405,8 @@ async function updatePlan2(id, input, store) {
17846
20405
  ...plan,
17847
20406
  ...patch,
17848
20407
  project_id: plan.project_id,
17849
- updated_at: new Date().toISOString()
17850
- });
20408
+ updated_at: new Date(Math.max(Date.now(), Date.parse(plan.updated_at) + 1)).toISOString()
20409
+ }, plan.updated_at);
17851
20410
  }
17852
20411
  function matchAgentByName(agents, name, options) {
17853
20412
  const target = normalizeAgentNameInput(name);
@@ -17868,7 +20427,7 @@ async function registerAgent2(input, store, context) {
17868
20427
  }
17869
20428
  const timestamp2 = new Date().toISOString();
17870
20429
  const agent = {
17871
- id: existing?.id ?? randomUUID3().slice(0, 8),
20430
+ id: existing?.id ?? randomUUID4().slice(0, 8),
17872
20431
  name: canonicalName,
17873
20432
  description: input.description ?? existing?.description ?? null,
17874
20433
  role: input.role ?? existing?.role ?? null,
@@ -17935,18 +20494,24 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
17935
20494
  const updated = await store.upsert("agents", { ...agent, session_id: null, last_seen_at: new Date().toISOString() }, context);
17936
20495
  return { agent: updated, released: true };
17937
20496
  }
20497
+ function withTaskListStatus(list) {
20498
+ return { ...list, status: list.status ?? "active" };
20499
+ }
17938
20500
  async function createTaskList2(input, store, context) {
20501
+ if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
20502
+ throw new Error("Invalid task-list status");
17939
20503
  const timestamp2 = new Date().toISOString();
17940
20504
  const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
17941
20505
  if (!slug)
17942
20506
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
17943
20507
  return store.upsert("task_lists", {
17944
- id: randomUUID3(),
20508
+ id: randomUUID4(),
17945
20509
  project_id: input.project_id ?? context?.projectId ?? null,
17946
20510
  slug,
17947
20511
  name: input.name,
17948
20512
  description: input.description ?? null,
17949
20513
  metadata: input.metadata ?? {},
20514
+ status: input.status ?? "active",
17950
20515
  created_at: timestamp2,
17951
20516
  updated_at: timestamp2,
17952
20517
  machine_id: store.machineId(context),
@@ -17954,6 +20519,8 @@ async function createTaskList2(input, store, context) {
17954
20519
  }, context);
17955
20520
  }
17956
20521
  async function updateTaskList2(id, input, store) {
20522
+ if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
20523
+ throw new Error("Invalid task-list status");
17957
20524
  const list = await requireRecord("task_lists", id, store);
17958
20525
  const patch = definedPatch(input);
17959
20526
  const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
@@ -17976,13 +20543,13 @@ async function updateTaskList2(id, input, store) {
17976
20543
  ...list,
17977
20544
  ...patch,
17978
20545
  metadata: input.metadata ?? list.metadata,
17979
- updated_at: new Date().toISOString()
20546
+ updated_at: new Date(Math.max(Date.now(), Date.parse(list.updated_at) + 1)).toISOString()
17980
20547
  });
17981
20548
  }
17982
20549
  async function createTemplate2(input, store, context) {
17983
20550
  const timestamp2 = new Date().toISOString();
17984
20551
  const template = {
17985
- id: randomUUID3(),
20552
+ id: randomUUID4(),
17986
20553
  name: input.name,
17987
20554
  title_pattern: input.title_pattern,
17988
20555
  description: input.description ?? null,
@@ -18003,7 +20570,7 @@ async function createTemplate2(input, store, context) {
18003
20570
  }
18004
20571
  function buildTemplateTasks(templateId, inputs, timestamp2) {
18005
20572
  return inputs.map((input, position) => ({
18006
- id: randomUUID3(),
20573
+ id: randomUUID4(),
18007
20574
  template_id: templateId,
18008
20575
  position,
18009
20576
  title_pattern: input.title_pattern,
@@ -18021,22 +20588,107 @@ function buildTemplateTasks(templateId, inputs, timestamp2) {
18021
20588
  async function deleteTemplate2(id, store, context) {
18022
20589
  return store.deleteTemplateWithTasks(id, context);
18023
20590
  }
18024
- async function updateTemplate2(id, input, store) {
20591
+ async function templateWithTasks(id, store) {
18025
20592
  const template = await store.get("templates", id);
18026
20593
  if (!template)
18027
20594
  return null;
18028
- return store.upsert("templates", {
18029
- ...template,
18030
- ...definedPatch(input),
18031
- tags: input.tags ?? template.tags,
18032
- variables: input.variables ?? template.variables,
18033
- metadata: input.metadata ?? template.metadata,
18034
- version: template.version + 1
20595
+ const tasks = (await store.list("template_tasks")).filter((row) => row.template_id === id).sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
20596
+ return { ...template, tasks };
20597
+ }
20598
+ async function updateTemplate2(id, input, store) {
20599
+ await store.lockTemplate(id);
20600
+ const current = await templateWithTasks(id, store);
20601
+ if (!current)
20602
+ return null;
20603
+ if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version >= 1e4)
20604
+ throw new Error("Template revision is invalid or exceeds the supported bound");
20605
+ if (input.expected_version !== undefined && input.expected_version !== current.version) {
20606
+ throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template changed; fetch its current version before updating");
20607
+ }
20608
+ const {
20609
+ id: _id,
20610
+ version: _version,
20611
+ created_at: _created,
20612
+ machine_id: _machine,
20613
+ synced_at: _synced,
20614
+ ...snapshot
20615
+ } = current;
20616
+ await store.upsert("template_versions", {
20617
+ id: randomUUID4(),
20618
+ template_id: id,
20619
+ version: current.version,
20620
+ snapshot: JSON.stringify(snapshot),
20621
+ created_at: new Date().toISOString()
18035
20622
  });
20623
+ const { tasks, ...template } = current;
20624
+ const { expected_version: _expected, ...patch } = input;
20625
+ const updated = await store.persistTemplateRevision({
20626
+ ...template,
20627
+ ...definedPatch(patch),
20628
+ version: current.version + 1
20629
+ }, current.version);
20630
+ return { ...updated, tasks };
20631
+ }
20632
+ async function readTemplateHistory(id, store) {
20633
+ await store.lockTemplate(id);
20634
+ const current = await store.get("templates", id);
20635
+ if (!current)
20636
+ return null;
20637
+ if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version > 1e4)
20638
+ throw new Error("Template history exceeds the supported version bound");
20639
+ const versions = (await store.list("template_versions")).filter((row) => row.template_id === id).sort((a, b) => b.version - a.version);
20640
+ const seen = new Set;
20641
+ for (const row of versions) {
20642
+ if (!Number.isSafeInteger(row.version) || row.version < 1 || row.version >= current.version || seen.has(row.version))
20643
+ throw new Error("Template history has conflicting versions");
20644
+ seen.add(row.version);
20645
+ }
20646
+ const missing_versions = Array.from({ length: current.version - 1 }, (_, index) => index + 1).filter((version2) => !seen.has(version2));
20647
+ return {
20648
+ current_version: current.version,
20649
+ versions,
20650
+ selection: {
20651
+ schema_version: 1,
20652
+ template_id: id,
20653
+ complete: missing_versions.length === 0,
20654
+ missing_versions
20655
+ }
20656
+ };
20657
+ }
20658
+ async function initializeTemplates(store, context) {
20659
+ const existing = await store.list("templates");
20660
+ const records = [];
20661
+ for (const [definition_index, input] of builtinTemplateInputs().entries()) {
20662
+ const matches = existing.filter((row) => row.name === input.name);
20663
+ if (matches.length) {
20664
+ records.push({
20665
+ definition_index,
20666
+ ids: matches.map((row) => row.id).sort(),
20667
+ name: input.name,
20668
+ status: "skipped"
20669
+ });
20670
+ } else {
20671
+ const template = await createTemplate2(input, store, context);
20672
+ records.push({
20673
+ definition_index,
20674
+ ids: [template.id],
20675
+ name: template.name,
20676
+ status: "created"
20677
+ });
20678
+ }
20679
+ }
20680
+ const names = records.filter((row) => row.status === "created").map((row) => row.name);
20681
+ return {
20682
+ schema_version: 1,
20683
+ created: names.length,
20684
+ skipped: records.length - names.length,
20685
+ names,
20686
+ records
20687
+ };
18036
20688
  }
18037
20689
  async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
18038
20690
  const entry2 = {
18039
- id: randomUUID3(),
20691
+ id: randomUUID4(),
18040
20692
  task_id: taskId,
18041
20693
  action,
18042
20694
  field: field ?? null,
@@ -18050,7 +20702,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
18050
20702
  }
18051
20703
  async function addComment2(input, store, context) {
18052
20704
  const comment = {
18053
- id: randomUUID3(),
20705
+ id: randomUUID4(),
18054
20706
  task_id: input.task_id,
18055
20707
  agent_id: input.agent_id ?? context?.agentId ?? null,
18056
20708
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -18066,7 +20718,7 @@ function redactComment(comment) {
18066
20718
  }
18067
20719
  async function addPlanComment2(input, store, context) {
18068
20720
  const comment = {
18069
- id: randomUUID3(),
20721
+ id: randomUUID4(),
18070
20722
  plan_id: input.plan_id,
18071
20723
  agent_id: input.agent_id ?? context?.agentId ?? null,
18072
20724
  session_id: input.session_id ?? context?.sessionId ?? null,
@@ -18084,6 +20736,7 @@ async function exportSnapshot(store) {
18084
20736
  return {
18085
20737
  exportedAt: new Date().toISOString(),
18086
20738
  source: "postgres",
20739
+ machines: await store.list("machines"),
18087
20740
  tasks: await store.list("tasks"),
18088
20741
  projects: await store.list("projects"),
18089
20742
  projectMachinePaths: await store.list("project_machine_paths"),
@@ -18096,8 +20749,27 @@ async function exportSnapshot(store) {
18096
20749
  tombstones: await store.listTombstones()
18097
20750
  };
18098
20751
  }
18099
- async function importSnapshot(snapshot, store, context) {
20752
+ async function importSnapshot(snapshot, store, context, machines) {
18100
20753
  const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
20754
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "atomic_project_migrations")) {
20755
+ result.errors.push("Migration evidence is immutable");
20756
+ return result;
20757
+ }
20758
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
20759
+ result.errors.push("Machine tombstones require explicit registry lifecycle operations");
20760
+ return result;
20761
+ }
20762
+ if ((snapshot.tombstones ?? []).some((row) => row.object_type === "projects")) {
20763
+ result.errors.push("Project tombstones require explicit reference-preserving project deletion");
20764
+ return result;
20765
+ }
20766
+ try {
20767
+ if (snapshot.machines !== undefined)
20768
+ validateMachines(snapshot.machines);
20769
+ } catch (e) {
20770
+ result.errors.push(e instanceof Error ? e.message : String(e));
20771
+ return result;
20772
+ }
18101
20773
  result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
18102
20774
  if (result.errors.length > 0)
18103
20775
  return result;
@@ -18108,18 +20780,47 @@ async function importSnapshot(snapshot, store, context) {
18108
20780
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
18109
20781
  if (result.errors.length > 0)
18110
20782
  return result;
20783
+ if (new Set(snapshot.projects.map((project) => project.id)).size !== snapshot.projects.length) {
20784
+ result.errors.push("Snapshot contains duplicate project identities");
20785
+ return result;
20786
+ }
20787
+ const orderedProjects = [];
20788
+ const pendingProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
20789
+ while (pendingProjects.size > 0) {
20790
+ const ready = [...pendingProjects.values()].filter((project) => !project.parent_id || !pendingProjects.has(project.parent_id));
20791
+ if (ready.length === 0) {
20792
+ result.errors.push("Snapshot project hierarchy contains a cycle");
20793
+ return result;
20794
+ }
20795
+ for (const project of ready) {
20796
+ orderedProjects.push(project);
20797
+ pendingProjects.delete(project.id);
20798
+ }
20799
+ }
18111
20800
  const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
18112
20801
  result.errors.push(...auditHistory.errors);
18113
20802
  if (result.errors.length > 0)
18114
20803
  return result;
20804
+ if (snapshot.machines?.length) {
20805
+ try {
20806
+ if (!machines)
20807
+ throw new Error("Machine registry import is unavailable");
20808
+ const receipt = await machines.execute({ action: "import", machines: snapshot.machines });
20809
+ result.inserted += receipt.inserted;
20810
+ result.skipped += receipt.skipped;
20811
+ } catch (error) {
20812
+ result.errors.push(error instanceof Error ? error.message : String(error));
20813
+ return result;
20814
+ }
20815
+ }
18115
20816
  result.skipped += auditHistory.identical;
18116
20817
  const entries = [
18117
- ...snapshot.tasks.map((row) => ["tasks", row]),
18118
- ...snapshot.projects.map((row) => ["projects", row]),
20818
+ ...orderedProjects.map((row) => ["projects", row]),
18119
20819
  ...(snapshot.projectMachinePaths ?? []).map((row) => ["project_machine_paths", row]),
18120
- ...snapshot.plans.map((row) => ["plans", row]),
18121
20820
  ...snapshot.agents.map((row) => ["agents", row]),
18122
20821
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
20822
+ ...snapshot.plans.map((row) => ["plans", row]),
20823
+ ...snapshot.tasks.map((row) => ["tasks", row]),
18123
20824
  ...snapshot.templates.map((row) => ["templates", row]),
18124
20825
  ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
18125
20826
  ];
@@ -18306,7 +21007,7 @@ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
18306
21007
  lastError = error;
18307
21008
  if (!isTransientPostgresError(error) || attempt === attempts)
18308
21009
  throw error;
18309
- await new Promise((resolve9) => setTimeout(resolve9, delayMs * attempt));
21010
+ await new Promise((resolve10) => setTimeout(resolve10, delayMs * attempt));
18310
21011
  }
18311
21012
  }
18312
21013
  throw lastError;
@@ -18373,7 +21074,7 @@ class TodosShadowMirror {
18373
21074
  lastError: this.metrics.lastError
18374
21075
  };
18375
21076
  }
18376
- enqueueUpsert(key, record, context) {
21077
+ enqueueUpsert(key2, record, context) {
18377
21078
  if (!record || typeof record !== "object")
18378
21079
  return;
18379
21080
  const payload = record;
@@ -18382,7 +21083,7 @@ class TodosShadowMirror {
18382
21083
  this.metrics.enqueued += 1;
18383
21084
  this.queue.push({
18384
21085
  kind: "upsert",
18385
- key,
21086
+ key: key2,
18386
21087
  record: payload,
18387
21088
  ...context ? { context } : {},
18388
21089
  enqueuedAt: Date.now(),
@@ -18407,8 +21108,8 @@ class TodosShadowMirror {
18407
21108
  async flush() {
18408
21109
  if (this.idle())
18409
21110
  return;
18410
- await new Promise((resolve9) => {
18411
- this.idleResolvers.push(resolve9);
21111
+ await new Promise((resolve10) => {
21112
+ this.idleResolvers.push(resolve10);
18412
21113
  this.pump();
18413
21114
  });
18414
21115
  }
@@ -18420,8 +21121,8 @@ class TodosShadowMirror {
18420
21121
  return;
18421
21122
  const resolvers = this.idleResolvers;
18422
21123
  this.idleResolvers = [];
18423
- for (const resolve9 of resolvers)
18424
- resolve9();
21124
+ for (const resolve10 of resolvers)
21125
+ resolve10();
18425
21126
  }
18426
21127
  pump() {
18427
21128
  if (this.pumping)
@@ -18759,8 +21460,8 @@ function createTodosCloudQueryClient(url, options = {}) {
18759
21460
  };
18760
21461
  return client;
18761
21462
  }
18762
- function createTodosCloudQueryClientFromEnv(env = process.env, options = {}) {
18763
- const url = getTodosStorageDatabaseUrl(env);
21463
+ function createTodosCloudQueryClientFromEnv(env2 = process.env, options = {}) {
21464
+ const url = getTodosStorageDatabaseUrl(env2);
18764
21465
  if (!url)
18765
21466
  return null;
18766
21467
  return createTodosCloudQueryClient(url, options);
@@ -18900,7 +21601,7 @@ class TodosShadowOutbox {
18900
21601
  const remaining = deadline - Date.now();
18901
21602
  if (remaining <= 0)
18902
21603
  break;
18903
- await new Promise((resolve9) => setTimeout(resolve9, Math.min(200, remaining)));
21604
+ await new Promise((resolve10) => setTimeout(resolve10, Math.min(200, remaining)));
18904
21605
  }
18905
21606
  }
18906
21607
  return this.getStats();
@@ -19078,8 +21779,8 @@ var _capturedDb = null;
19078
21779
  var _outbox = null;
19079
21780
  var _cloud = null;
19080
21781
  var _exitRegistered = false;
19081
- function maybeInstallShadowCapture2(db, env = process.env) {
19082
- if (!isTodosShadowEnabled(env))
21782
+ function maybeInstallShadowCapture2(db, env2 = process.env) {
21783
+ if (!isTodosShadowEnabled(env2))
19083
21784
  return false;
19084
21785
  if (_capturedDb === db)
19085
21786
  return true;
@@ -19087,15 +21788,15 @@ function maybeInstallShadowCapture2(db, env = process.env) {
19087
21788
  _capturedDb = db;
19088
21789
  return true;
19089
21790
  }
19090
- function getRuntimeShadowOutbox(db, env = process.env) {
19091
- if (!isTodosShadowEnabled(env)) {
19092
- throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env)}=1 to enable it`);
21791
+ function getRuntimeShadowOutbox(db, env2 = process.env) {
21792
+ if (!isTodosShadowEnabled(env2)) {
21793
+ throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env2)}=1 to enable it`);
19093
21794
  }
19094
21795
  if (_outbox && _capturedDb === db)
19095
21796
  return _outbox;
19096
- const cloud = createTodosCloudQueryClientFromEnv(env);
21797
+ const cloud = createTodosCloudQueryClientFromEnv(env2);
19097
21798
  if (!cloud) {
19098
- throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env)}`);
21799
+ throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env2)}`);
19099
21800
  }
19100
21801
  _cloud = cloud;
19101
21802
  installShadowOutboxSchema(db);
@@ -19103,28 +21804,28 @@ function getRuntimeShadowOutbox(db, env = process.env) {
19103
21804
  _outbox = new TodosShadowOutbox({ db, postgresClient: cloud });
19104
21805
  return _outbox;
19105
21806
  }
19106
- function startRuntimeShadowDrain(db, env = process.env) {
19107
- if (!isTodosShadowEnabled(env))
21807
+ function startRuntimeShadowDrain(db, env2 = process.env) {
21808
+ if (!isTodosShadowEnabled(env2))
19108
21809
  return;
19109
- maybeInstallShadowCapture2(db, env);
21810
+ maybeInstallShadowCapture2(db, env2);
19110
21811
  try {
19111
- const outbox = getRuntimeShadowOutbox(db, env);
21812
+ const outbox = getRuntimeShadowOutbox(db, env2);
19112
21813
  outbox.startLoop();
19113
- registerShadowExitFlush(db, env);
21814
+ registerShadowExitFlush(db, env2);
19114
21815
  } catch (error) {
19115
21816
  const message = error instanceof Error ? error.message : String(error);
19116
21817
  console.error(`[todos] shadow drain not started: ${message}`);
19117
21818
  }
19118
21819
  }
19119
- function registerShadowExitFlush(db, env = process.env) {
21820
+ function registerShadowExitFlush(db, env2 = process.env) {
19120
21821
  if (_exitRegistered)
19121
21822
  return;
19122
- if (!isTodosShadowEnabled(env))
21823
+ if (!isTodosShadowEnabled(env2))
19123
21824
  return;
19124
21825
  _exitRegistered = true;
19125
21826
  const flush = async () => {
19126
21827
  try {
19127
- const outbox = getRuntimeShadowOutbox(db, env);
21828
+ const outbox = getRuntimeShadowOutbox(db, env2);
19128
21829
  await outbox.flush(5000);
19129
21830
  } catch {} finally {
19130
21831
  await closeRuntimeShadowCloud();
@@ -19282,7 +21983,7 @@ function isCommentRedactionBackfillComplete(result) {
19282
21983
  return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
19283
21984
  }
19284
21985
  // src/storage/s3-artifacts.ts
19285
- import { createHash as createHash6, createHmac as createHmac2 } from "crypto";
21986
+ import { createHash as createHash7, createHmac as createHmac2 } from "crypto";
19286
21987
  function createTodosS3ArtifactStore(options) {
19287
21988
  const requestFetch = options.fetch ?? fetch;
19288
21989
  const now3 = options.now ?? (() => new Date);
@@ -19290,8 +21991,8 @@ function createTodosS3ArtifactStore(options) {
19290
21991
  objectKey: (relativePath) => buildS3ObjectKey(options.config, relativePath),
19291
21992
  objectUrl: (relativePath) => buildS3ObjectUrl(options.config, buildS3ObjectKey(options.config, relativePath)),
19292
21993
  async putObject(input) {
19293
- const key = buildS3ObjectKey(options.config, input.key);
19294
- const url = buildS3ObjectUrl(options.config, key);
21994
+ const key2 = buildS3ObjectKey(options.config, input.key);
21995
+ const url = buildS3ObjectUrl(options.config, key2);
19295
21996
  const body = normalizeBody(input.body);
19296
21997
  const headers = {
19297
21998
  "content-type": input.contentType ?? "application/octet-stream"
@@ -19314,7 +22015,7 @@ function createTodosS3ArtifactStore(options) {
19314
22015
  throw new Error(`S3 put failed with HTTP ${response.status}`);
19315
22016
  return {
19316
22017
  bucket: options.config.bucket,
19317
- key,
22018
+ key: key2,
19318
22019
  url: url.toString(),
19319
22020
  etag: response.headers.get("etag") ?? undefined
19320
22021
  };
@@ -19354,11 +22055,11 @@ function createTodosS3ArtifactStore(options) {
19354
22055
  }
19355
22056
  function buildS3ObjectKey(config, relativePath) {
19356
22057
  const prefix = normalizeS3Prefix(config.prefix);
19357
- const key = normalizeS3Key(relativePath);
19358
- return `${prefix}${key}`;
22058
+ const key2 = normalizeS3Key(relativePath);
22059
+ return `${prefix}${key2}`;
19359
22060
  }
19360
- function buildS3ObjectUrl(config, key) {
19361
- const encodedKey = encodeS3Path(key);
22061
+ function buildS3ObjectUrl(config, key2) {
22062
+ const encodedKey = encodeS3Path(key2);
19362
22063
  if (config.endpoint || config.forcePathStyle) {
19363
22064
  const base = new URL(config.endpoint ?? `https://s3.${config.region ?? "us-east-1"}.amazonaws.com`);
19364
22065
  base.pathname = `${trimTrailingSlash(base.pathname)}/${encodeURIComponent(config.bucket)}/${encodedKey}`;
@@ -19422,25 +22123,25 @@ function normalizeBody(body) {
19422
22123
  throw new Error("S3 body must be a string, Uint8Array, Buffer, or ArrayBuffer");
19423
22124
  }
19424
22125
  function normalizeS3Prefix(value) {
19425
- const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
19426
- if (!normalized || normalized.includes(".."))
22126
+ const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
22127
+ if (!normalized2 || normalized2.includes(".."))
19427
22128
  throw new Error("Invalid S3 object key");
19428
- return normalized.endsWith("/") ? normalized : `${normalized}/`;
22129
+ return normalized2.endsWith("/") ? normalized2 : `${normalized2}/`;
19429
22130
  }
19430
22131
  function normalizeS3Key(value) {
19431
- const normalized = value.replace(/\\/g, "/").replace(/^\/+/, "");
19432
- if (!normalized || normalized.includes("..") || normalized.endsWith("/"))
22132
+ const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
22133
+ if (!normalized2 || normalized2.includes("..") || normalized2.endsWith("/"))
19433
22134
  throw new Error("Invalid S3 object key");
19434
- return normalized;
22135
+ return normalized2;
19435
22136
  }
19436
- function encodeS3Path(key) {
19437
- return key.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
22137
+ function encodeS3Path(key2) {
22138
+ return key2.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
19438
22139
  }
19439
22140
  function trimTrailingSlash(value) {
19440
22141
  return value.replace(/\/+$/, "");
19441
22142
  }
19442
22143
  function canonicalQuery(url) {
19443
- return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
22144
+ return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key2, value]) => `${encodeURIComponent(key2)}=${encodeURIComponent(value)}`).join("&");
19444
22145
  }
19445
22146
  function normalizeHeaders(headers) {
19446
22147
  const entries = Object.entries(headers).map(([name, value]) => [
@@ -19454,13 +22155,13 @@ function toAmzDate(date) {
19454
22155
  return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
19455
22156
  }
19456
22157
  function sha256Hex(value) {
19457
- return createHash6("sha256").update(value).digest("hex");
22158
+ return createHash7("sha256").update(value).digest("hex");
19458
22159
  }
19459
- function hmac(key, value) {
19460
- return createHmac2("sha256", key).update(value).digest();
22160
+ function hmac(key2, value) {
22161
+ return createHmac2("sha256", key2).update(value).digest();
19461
22162
  }
19462
- function hmacHex(key, value) {
19463
- return createHmac2("sha256", key).update(value).digest("hex");
22163
+ function hmacHex(key2, value) {
22164
+ return createHmac2("sha256", key2).update(value).digest("hex");
19464
22165
  }
19465
22166
  function getSigningKey(secretAccessKey, dateStamp, region, service) {
19466
22167
  const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp);
@@ -19494,7 +22195,7 @@ async function uploadRunArtifactsToS3(options) {
19494
22195
  continue;
19495
22196
  }
19496
22197
  const ref = await options.store.putObject({
19497
- key: content.relative_path,
22198
+ key: remoteArtifactKey(artifact.task_id, content.sha256),
19498
22199
  body: Buffer.from(content.base64, "base64"),
19499
22200
  contentType: mediaType(metadata) ?? "application/octet-stream",
19500
22201
  metadata: {
@@ -19508,7 +22209,8 @@ async function uploadRunArtifactsToS3(options) {
19508
22209
  provider: "s3",
19509
22210
  bucket: ref.bucket,
19510
22211
  key: ref.key,
19511
- relative_path: content.relative_path,
22212
+ relative_path: remoteArtifactKey(artifact.task_id, content.sha256),
22213
+ local_path: content.relative_path,
19512
22214
  url: ref.url,
19513
22215
  sha256: content.sha256,
19514
22216
  size_bytes: content.size_bytes,
@@ -19612,7 +22314,7 @@ async function downloadRunArtifactsFromS3(options) {
19612
22314
  const bytes = Buffer.from(await response.arrayBuffer());
19613
22315
  const report = importStoredArtifactContent({
19614
22316
  artifact_id: artifact.id,
19615
- relative_path: remote.relative_path,
22317
+ relative_path: remote.local_path ?? remote.relative_path,
19616
22318
  sha256: remote.sha256,
19617
22319
  size_bytes: remote.size_bytes,
19618
22320
  base64: bytes.toString("base64")
@@ -19644,6 +22346,12 @@ async function downloadRunArtifactsFromS3(options) {
19644
22346
  function emptyResult() {
19645
22347
  return { uploaded: 0, downloaded: 0, skipped: 0, errors: [], artifacts: [] };
19646
22348
  }
22349
+ function remoteArtifactKey(taskId, sha2562) {
22350
+ if (!/^[0-9a-f]{64}$/.test(sha2562)) {
22351
+ throw new Error("run artifact digest must be a lowercase hex sha-256; refusing to build an object key from it");
22352
+ }
22353
+ return `artifacts/${taskId}/${sha2562}`;
22354
+ }
19647
22355
  function planArtifact(artifact, status, remote) {
19648
22356
  return {
19649
22357
  id: artifact.id,
@@ -19707,10 +22415,106 @@ function mediaType(metadata) {
19707
22415
  const value = store["media_type"];
19708
22416
  return typeof value === "string" ? value : null;
19709
22417
  }
22418
+ // src/storage/run-artifact-creation-upload.ts
22419
+ init_database();
22420
+ init_artifact_store();
22421
+ init_config();
22422
+ async function uploadRunArtifactAtCreation(input) {
22423
+ const db = input.db ?? getDatabase();
22424
+ const now3 = input.now ?? (() => new Date);
22425
+ try {
22426
+ const artifact = getRunArtifactById(db, input.artifactId);
22427
+ if (!artifact)
22428
+ return { uploaded: false, reason: "artifact_not_found" };
22429
+ const metadata = artifact.metadata;
22430
+ const existing = remoteRef(metadata);
22431
+ if (existing)
22432
+ return { uploaded: true, reason: "already_remote", ref: existing };
22433
+ if (!input.store) {
22434
+ const config = loadTodosStorageConfig(input.env ?? process.env);
22435
+ if (!config.objectStorage)
22436
+ return { uploaded: false, reason: "s3_not_configured" };
22437
+ const credentials = creationCredentialsFromEnv(input.env ?? process.env);
22438
+ if (!credentials)
22439
+ return { uploaded: false, reason: "s3_credentials_missing" };
22440
+ input.store = createTodosS3ArtifactStore({ config: config.objectStorage, credentials });
22441
+ }
22442
+ const store = input.store;
22443
+ const content = exportStoredArtifactContent({
22444
+ id: artifact.id,
22445
+ path: artifact.path,
22446
+ size_bytes: artifact.size_bytes,
22447
+ sha256: artifact.sha256,
22448
+ metadata
22449
+ });
22450
+ if (!content)
22451
+ return { uploaded: false, reason: "no_local_content" };
22452
+ const relativePath = remoteArtifactKey(artifact.task_id, content.sha256);
22453
+ const ref = await store.putObject({
22454
+ key: relativePath,
22455
+ body: Buffer.from(content.base64, "base64"),
22456
+ contentType: mediaType2(metadata) ?? "application/octet-stream",
22457
+ metadata: {
22458
+ artifact_id: artifact.id,
22459
+ run_id: artifact.run_id,
22460
+ task_id: artifact.task_id,
22461
+ sha256: content.sha256
22462
+ }
22463
+ });
22464
+ const remote = {
22465
+ provider: "s3",
22466
+ bucket: ref.bucket,
22467
+ key: ref.key,
22468
+ relative_path: relativePath,
22469
+ local_path: content.relative_path,
22470
+ url: ref.url,
22471
+ sha256: content.sha256,
22472
+ size_bytes: content.size_bytes,
22473
+ uploaded_at: now3().toISOString()
22474
+ };
22475
+ updateArtifactMetadata(db, artifact.id, {
22476
+ ...metadata,
22477
+ remote_artifact_store: remote
22478
+ });
22479
+ return { uploaded: true, reason: "uploaded", ref: remote };
22480
+ } catch (error) {
22481
+ return {
22482
+ uploaded: false,
22483
+ reason: "upload_error",
22484
+ error: error instanceof Error ? error.message : String(error)
22485
+ };
22486
+ }
22487
+ }
22488
+ function creationCredentialsFromEnv(env2) {
22489
+ const accessKeyId = readCredentialEnv(env2, "s3AccessKeyId");
22490
+ const secretAccessKey = readCredentialEnv(env2, "s3SecretAccessKey");
22491
+ if (!accessKeyId || !secretAccessKey)
22492
+ return null;
22493
+ const sessionToken = readCredentialEnv(env2, "s3SessionToken");
22494
+ return sessionToken ? { accessKeyId, secretAccessKey, sessionToken } : { accessKeyId, secretAccessKey };
22495
+ }
22496
+ function readCredentialEnv(env2, key2) {
22497
+ const value = env2[TODOS_STORAGE_ENV[key2]] ?? env2[TODOS_STORAGE_FALLBACK_ENV[key2]];
22498
+ const trimmed = value?.trim();
22499
+ return trimmed ? trimmed : undefined;
22500
+ }
22501
+ function getRunArtifactById(db, id) {
22502
+ const row = db.query("SELECT * FROM task_run_artifacts WHERE id = ?").get(id);
22503
+ return row ? { ...row, metadata: parseMetadata2(row.metadata) } : null;
22504
+ }
22505
+ function mediaType2(metadata) {
22506
+ const store = metadata["artifact_store"];
22507
+ if (!store || typeof store !== "object" || Array.isArray(store))
22508
+ return null;
22509
+ const value = store["media_type"];
22510
+ return typeof value === "string" ? value : null;
22511
+ }
19710
22512
  export {
19711
22513
  uploadRunArtifactsToS3,
22514
+ uploadRunArtifactAtCreation,
19712
22515
  startRuntimeShadowDrain,
19713
22516
  signAwsV4Request,
22517
+ remoteArtifactKey,
19714
22518
  registerShadowExitFlush,
19715
22519
  postgresTodosSyncSchemaSql,
19716
22520
  postgresTodosScopedSlugUniqueIndexSql,