@hasna/todos 0.13.1 → 0.13.3

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 (76) hide show
  1. package/README.md +80 -15
  2. package/dist/cli/cloud-router.d.ts +78 -12
  3. package/dist/cli/cloud-router.d.ts.map +1 -1
  4. package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  6. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  7. package/dist/cli/doctor-integrity.d.ts +106 -0
  8. package/dist/cli/doctor-integrity.d.ts.map +1 -0
  9. package/dist/cli/helpers.d.ts +2 -2
  10. package/dist/cli/index.js +1748 -1012
  11. package/dist/cli/stage-a.d.ts.map +1 -1
  12. package/dist/contracts.js +137 -39
  13. package/dist/db/database.d.ts.map +1 -1
  14. package/dist/db/findings.d.ts +1 -0
  15. package/dist/db/findings.d.ts.map +1 -1
  16. package/dist/db/integrity.d.ts +20 -0
  17. package/dist/db/integrity.d.ts.map +1 -0
  18. package/dist/db/task-crud.d.ts.map +1 -1
  19. package/dist/index.d.ts +5 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +592 -147
  22. package/dist/lib/context-packs.d.ts +1 -0
  23. package/dist/lib/context-packs.d.ts.map +1 -1
  24. package/dist/lib/dependency-graph.d.ts +19 -11
  25. package/dist/lib/dependency-graph.d.ts.map +1 -1
  26. package/dist/lib/doctor.d.ts +20 -0
  27. package/dist/lib/doctor.d.ts.map +1 -1
  28. package/dist/lib/integrity.d.ts +215 -0
  29. package/dist/lib/integrity.d.ts.map +1 -0
  30. package/dist/lib/native-storage-status.d.ts +2 -2
  31. package/dist/lib/native-storage-status.d.ts.map +1 -1
  32. package/dist/lib/redaction.d.ts.map +1 -1
  33. package/dist/lib/release-compatibility.d.ts.map +1 -1
  34. package/dist/lib/task-dedupe.d.ts +15 -0
  35. package/dist/lib/task-dedupe.d.ts.map +1 -1
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +544 -97
  38. package/dist/mcp.js +8 -4
  39. package/dist/registry.d.ts +1 -1
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js +144 -39
  42. package/dist/release-provenance.json +5 -5
  43. package/dist/sdk/types.d.ts +42 -0
  44. package/dist/sdk/types.d.ts.map +1 -1
  45. package/dist/sdk/v1.generated.d.ts +1 -0
  46. package/dist/sdk/v1.generated.d.ts.map +1 -1
  47. package/dist/server/cloud.d.ts +8 -1
  48. package/dist/server/cloud.d.ts.map +1 -1
  49. package/dist/server/index.js +585 -118
  50. package/dist/server/openapi.d.ts +12 -0
  51. package/dist/server/openapi.d.ts.map +1 -1
  52. package/dist/server/port.d.ts +52 -0
  53. package/dist/server/port.d.ts.map +1 -0
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage/config.d.ts +23 -5
  57. package/dist/storage/config.d.ts.map +1 -1
  58. package/dist/storage/factory.d.ts +9 -3
  59. package/dist/storage/factory.d.ts.map +1 -1
  60. package/dist/storage/hybrid.d.ts +7 -0
  61. package/dist/storage/hybrid.d.ts.map +1 -1
  62. package/dist/storage/index.d.ts +3 -3
  63. package/dist/storage/index.d.ts.map +1 -1
  64. package/dist/storage/interfaces.d.ts +43 -8
  65. package/dist/storage/interfaces.d.ts.map +1 -1
  66. package/dist/storage/local-sqlite.d.ts.map +1 -1
  67. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  68. package/dist/storage.d.ts +3 -3
  69. package/dist/storage.d.ts.map +1 -1
  70. package/dist/storage.js +619 -256
  71. package/dist/testing.d.ts +79 -0
  72. package/dist/testing.d.ts.map +1 -0
  73. package/dist/testing.js +107 -0
  74. package/dist/types/index.d.ts +27 -1
  75. package/dist/types/index.d.ts.map +1 -1
  76. package/package.json +8 -4
package/dist/mcp/index.js CHANGED
@@ -41,7 +41,10 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
41
41
  var __require = import.meta.require;
42
42
 
43
43
  // src/types/index.ts
44
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
44
+ function isBlockingDependencyStatus(status) {
45
+ return status !== "completed" && status !== "cancelled";
46
+ }
47
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
45
48
  var init_types = __esm(() => {
46
49
  TASK_STATUSES = [
47
50
  "pending",
@@ -74,6 +77,22 @@ var init_types = __esm(() => {
74
77
  this.name = "TaskNotFoundError";
75
78
  }
76
79
  };
80
+ TaskReferenceAmbiguousError = class TaskReferenceAmbiguousError extends Error {
81
+ reference;
82
+ static code = "TASK_REFERENCE_AMBIGUOUS";
83
+ candidateTaskIds;
84
+ candidateProjectIds;
85
+ constructor(reference, candidates) {
86
+ const candidateTaskIds = [...new Set(candidates.map((candidate) => candidate.task_id))].sort();
87
+ const candidateProjectIds = [...new Set(candidates.map((candidate) => candidate.project_id).filter((projectId) => typeof projectId === "string" && projectId.length > 0))].sort();
88
+ const projectList = candidateProjectIds.length > 0 ? candidateProjectIds.join(", ") : "(unscoped)";
89
+ super(`Task reference is ambiguous: "${reference}". Candidate project IDs: ${projectList}. ` + "Use a full task UUID.");
90
+ this.reference = reference;
91
+ this.name = "TaskReferenceAmbiguousError";
92
+ this.candidateTaskIds = candidateTaskIds;
93
+ this.candidateProjectIds = candidateProjectIds;
94
+ }
95
+ };
77
96
  ProjectNotFoundError = class ProjectNotFoundError extends Error {
78
97
  projectId;
79
98
  static code = "PROJECT_NOT_FOUND";
@@ -3763,15 +3782,18 @@ var init_identity_mapping = __esm(() => {
3763
3782
  var exports_config = {};
3764
3783
  __export(exports_config, {
3765
3784
  parseStorageMode: () => parseStorageMode,
3785
+ parseStorageBackend: () => parseStorageBackend,
3766
3786
  loadTodosStorageConfig: () => loadTodosStorageConfig,
3767
3787
  loadStorageConfig: () => loadStorageConfig,
3768
3788
  isTodosShadowEnabled: () => isTodosShadowEnabled,
3769
3789
  isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
3790
+ isTodosPostgresBackend: () => isTodosPostgresBackend,
3770
3791
  getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
3771
3792
  getTodosStorageMode: () => getTodosStorageMode,
3772
3793
  getTodosStorageEnvName: () => getTodosStorageEnvName,
3773
3794
  getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
3774
3795
  getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
3796
+ getTodosStorageBackend: () => getTodosStorageBackend,
3775
3797
  getStorageMode: () => getStorageMode,
3776
3798
  getStorageDatabaseUrl: () => getStorageDatabaseUrl,
3777
3799
  getStorageDatabaseEnv: () => getStorageDatabaseEnv,
@@ -3796,7 +3818,7 @@ function getCanonicalTodosRdsConfig(env = process.env) {
3796
3818
  };
3797
3819
  }
3798
3820
  function loadTodosStorageConfig(env = process.env) {
3799
- const mode = getTodosStorageMode(env);
3821
+ const mode = getTodosStorageBackend(env);
3800
3822
  const databaseUrl = getTodosStorageDatabaseUrl(env);
3801
3823
  const bucket = readStorageEnv(env, "s3Bucket").value;
3802
3824
  const prefix = readStorageEnv(env, "s3Prefix").value ?? "todos/";
@@ -3833,29 +3855,41 @@ function loadTodosStorageConfig(env = process.env) {
3833
3855
  function loadStorageConfig(env = process.env) {
3834
3856
  return loadTodosStorageConfig(env);
3835
3857
  }
3858
+ function isTodosPostgresBackend(config) {
3859
+ return config.mode === "postgres";
3860
+ }
3836
3861
  function isTodosRemoteStorageEnabled(config) {
3837
- return config.mode === "remote" || config.mode === "hybrid";
3862
+ return isTodosPostgresBackend(config);
3838
3863
  }
3839
3864
  function assertTodosRemoteStorageConfig(config) {
3840
- if (!isTodosRemoteStorageEnabled(config))
3865
+ if (!isTodosPostgresBackend(config))
3841
3866
  return;
3842
3867
  if (!config.database?.url) {
3843
3868
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
3844
3869
  }
3845
3870
  }
3846
- function parseStorageMode(value) {
3871
+ function parseStorageBackend(value) {
3847
3872
  const normalized = clean(value)?.toLowerCase();
3848
3873
  if (!normalized)
3849
- return "local";
3850
- if (normalized === "local" || normalized === "remote" || normalized === "hybrid")
3874
+ return "sqlite";
3875
+ if (normalized === "sqlite" || normalized === "postgres")
3851
3876
  return normalized;
3852
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be local, remote, or hybrid`);
3877
+ const legacy = LEGACY_BACKEND_TOKENS[normalized];
3878
+ if (legacy)
3879
+ return legacy;
3880
+ throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
3881
+ }
3882
+ function parseStorageMode(value) {
3883
+ return parseStorageBackend(value);
3884
+ }
3885
+ function getTodosStorageBackend(env = process.env) {
3886
+ return parseStorageBackend(readStorageEnv(env, "mode").value);
3853
3887
  }
3854
3888
  function getTodosStorageMode(env = process.env) {
3855
- return parseStorageMode(readStorageEnv(env, "mode").value);
3889
+ return getTodosStorageBackend(env);
3856
3890
  }
3857
3891
  function getStorageMode(env = process.env) {
3858
- return getTodosStorageMode(env);
3892
+ return getTodosStorageBackend(env);
3859
3893
  }
3860
3894
  function isTodosShadowEnabled(env = process.env) {
3861
3895
  return parseBoolean(readStorageEnv(env, "shadow").value, false);
@@ -3866,8 +3900,8 @@ function getTodosStorageShadowEnvName(env = process.env) {
3866
3900
  function assertTodosShadowConfig(config, env = process.env) {
3867
3901
  if (!isTodosShadowEnabled(env))
3868
3902
  return;
3869
- if (config.mode !== "local") {
3870
- throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires ${TODOS_STORAGE_ENV.mode}=local (got ${config.mode})`);
3903
+ if (config.mode !== "sqlite") {
3904
+ throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires the sqlite backend (got ${config.mode})`);
3871
3905
  }
3872
3906
  if (!config.database?.url) {
3873
3907
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${readStorageEnv(env, "shadow").name} is enabled`);
@@ -3923,7 +3957,7 @@ function parsePositiveInteger(value, fallback) {
3923
3957
  }
3924
3958
  return parsed;
3925
3959
  }
3926
- var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos";
3960
+ var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos", LEGACY_BACKEND_TOKENS;
3927
3961
  var init_config = __esm(() => {
3928
3962
  TODOS_STORAGE_TABLES = [
3929
3963
  "todos_sync_records",
@@ -3964,6 +3998,14 @@ var init_config = __esm(() => {
3964
3998
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
3965
3999
  syncDryRun: "TODOS_SYNC_DRY_RUN"
3966
4000
  };
4001
+ LEGACY_BACKEND_TOKENS = {
4002
+ local: "sqlite",
4003
+ remote: "postgres",
4004
+ postgresql: "postgres",
4005
+ hybrid: "postgres",
4006
+ self_hosted: "postgres",
4007
+ cloud: "postgres"
4008
+ };
3967
4009
  });
3968
4010
 
3969
4011
  // src/storage/shadow-outbox-schema.ts
@@ -4097,6 +4139,30 @@ function findGitRoot(startDir) {
4097
4139
  }
4098
4140
  return null;
4099
4141
  }
4142
+ function getGlobalDbPath() {
4143
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4144
+ return join(home, ".hasna", "todos", "todos.db");
4145
+ }
4146
+ function hasExplicitProjectArg(args = process.argv.slice(2)) {
4147
+ return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
4148
+ }
4149
+ function getCliCommand(args = process.argv.slice(2)) {
4150
+ const globalOptionsWithValues = new Set(["--project", "--agent", "--session"]);
4151
+ for (let index = 0;index < args.length; index += 1) {
4152
+ const arg = args[index];
4153
+ if (globalOptionsWithValues.has(arg)) {
4154
+ index += 1;
4155
+ continue;
4156
+ }
4157
+ if (arg.startsWith("-"))
4158
+ continue;
4159
+ return arg;
4160
+ }
4161
+ return null;
4162
+ }
4163
+ function canCreateScopedProjectDb(args = process.argv.slice(2)) {
4164
+ return getCliCommand(args) === "project-bootstrap" && !args.some((arg) => arg === "--dry-run" || arg.startsWith("--dry-run="));
4165
+ }
4100
4166
  function getDbPath() {
4101
4167
  if (process.env["HASNA_TODOS_DB_PATH"]) {
4102
4168
  return process.env["HASNA_TODOS_DB_PATH"];
@@ -4104,18 +4170,19 @@ function getDbPath() {
4104
4170
  if (process.env["TODOS_DB_PATH"]) {
4105
4171
  return process.env["TODOS_DB_PATH"];
4106
4172
  }
4173
+ if (hasExplicitProjectArg())
4174
+ return getGlobalDbPath();
4107
4175
  const cwd = process.cwd();
4108
4176
  const nearest = findNearestProjectDb(cwd);
4109
4177
  if (nearest)
4110
4178
  return nearest;
4111
4179
  if (process.env["TODOS_DB_SCOPE"] === "project") {
4112
4180
  const gitRoot = findGitRoot(cwd);
4113
- if (gitRoot) {
4181
+ if (gitRoot && canCreateScopedProjectDb()) {
4114
4182
  return join(gitRoot, ".hasna", "todos", "todos.db");
4115
4183
  }
4116
4184
  }
4117
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4118
- return join(home, ".hasna", "todos", "todos.db");
4185
+ return getGlobalDbPath();
4119
4186
  }
4120
4187
  function getDatabasePath() {
4121
4188
  return getDbPath();
@@ -4131,8 +4198,8 @@ function ensureDir(filePath) {
4131
4198
  function openDatabase(path) {
4132
4199
  ensureDir(path);
4133
4200
  const db = new Database(path);
4134
- db.run("PRAGMA journal_mode = WAL");
4135
4201
  db.run("PRAGMA busy_timeout = 5000");
4202
+ db.run("PRAGMA journal_mode = WAL");
4136
4203
  db.run("PRAGMA foreign_keys = ON");
4137
4204
  runMigrations(db);
4138
4205
  ensureAgentIdentitySchema(db);
@@ -4218,10 +4285,13 @@ function resolvePartialId(db, table, partialId) {
4218
4285
  return null;
4219
4286
  }
4220
4287
  if (table === "tasks") {
4221
- const shortIdRows = db.query("SELECT id FROM tasks WHERE short_id = ?").all(partialId);
4288
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = LOWER(?) ORDER BY project_id, id").all(partialId);
4222
4289
  if (shortIdRows.length === 1) {
4223
4290
  return shortIdRows[0].id;
4224
4291
  }
4292
+ if (shortIdRows.length > 1) {
4293
+ throw new TaskReferenceAmbiguousError(partialId, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
4294
+ }
4225
4295
  }
4226
4296
  if (table === "task_lists") {
4227
4297
  const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
@@ -9107,11 +9177,15 @@ function secretPatterns() {
9107
9177
  return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
9108
9178
  }
9109
9179
  function isRedactionPlaceholderMatch(match) {
9110
- const placeholder = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
9111
9180
  const trimmed = match.trim();
9112
- return new RegExp(`^${placeholder}$`).test(trimmed) || new RegExp(`=\\s*['"]?${placeholder}['"]?$`).test(trimmed);
9181
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(trimmed) || new RegExp(`=\\s*['"]?${REDACTION_PLACEHOLDER}['"]?$`).test(trimmed);
9182
+ }
9183
+ function isRedactionPlaceholderKey(key) {
9184
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(key.trim());
9113
9185
  }
9114
9186
  function isSecretKey(key) {
9187
+ if (isRedactionPlaceholderKey(key))
9188
+ return false;
9115
9189
  if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
9116
9190
  return false;
9117
9191
  if (DEFAULT_SECRET_KEY_PATTERN.test(key))
@@ -9169,7 +9243,7 @@ function upsertSecretSafetyConfig(input) {
9169
9243
  saveConfig({ ...config, secret_safety: next });
9170
9244
  return next;
9171
9245
  }
9172
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS;
9246
+ var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
9173
9247
  var init_redaction = __esm(() => {
9174
9248
  init_config2();
9175
9249
  DEFAULT_SECRET_PATTERNS = [
@@ -9193,6 +9267,7 @@ var init_redaction = __esm(() => {
9193
9267
  "completion_tokens",
9194
9268
  "cost_tokens"
9195
9269
  ]);
9270
+ REDACTION_PLACEHOLDER = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
9196
9271
  });
9197
9272
 
9198
9273
  // src/lib/secret-redaction.ts
@@ -9228,7 +9303,7 @@ function scanTextForSecrets(text, options = {}) {
9228
9303
  };
9229
9304
  }
9230
9305
  function redactText(text, options = {}) {
9231
- const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER;
9306
+ const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER2;
9232
9307
  let out = text;
9233
9308
  for (const { pattern, allowlist_ok } of DEFAULT_PATTERNS) {
9234
9309
  out = out.replace(new RegExp(pattern.source, pattern.flags), (match) => {
@@ -9255,7 +9330,7 @@ function redactExportRecord(record) {
9255
9330
  }
9256
9331
  return base;
9257
9332
  }
9258
- var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
9333
+ var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER2 = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
9259
9334
  var init_secret_redaction = __esm(() => {
9260
9335
  init_redaction();
9261
9336
  SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
@@ -13420,10 +13495,11 @@ function getTaskWithRelations(id, db) {
13420
13495
  JOIN task_dependencies td ON td.depends_on = t.id
13421
13496
  WHERE td.task_id = ?`).all(id);
13422
13497
  const dependencies = depRows.map(rowToTask);
13423
- const blockedByRows = d.query(`SELECT t.* FROM tasks t
13498
+ const blocked_by = dependencies.filter((dep) => isBlockingDependencyStatus(dep.status));
13499
+ const blocksRows = d.query(`SELECT t.* FROM tasks t
13424
13500
  JOIN task_dependencies td ON td.task_id = t.id
13425
13501
  WHERE td.depends_on = ?`).all(id);
13426
- const blocked_by = blockedByRows.map(rowToTask);
13502
+ const blocks = blocksRows.map(rowToTask);
13427
13503
  const comments = d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(id);
13428
13504
  const parent = task.parent_id ? getTask(task.parent_id, d) : null;
13429
13505
  const checklist = getChecklist(id, d);
@@ -13432,6 +13508,7 @@ function getTaskWithRelations(id, db) {
13432
13508
  subtasks,
13433
13509
  dependencies,
13434
13510
  blocked_by,
13511
+ blocks,
13435
13512
  comments,
13436
13513
  parent,
13437
13514
  checklist
@@ -20012,9 +20089,6 @@ function cleanMode(value) {
20012
20089
  const normalized = value?.trim().toLowerCase();
20013
20090
  return normalized || null;
20014
20091
  }
20015
- function modeRole(mode) {
20016
- return mode === "local" ? "local" : "remote";
20017
- }
20018
20092
  function resolveTodosCliStorageMode(env = process.env) {
20019
20093
  for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
20020
20094
  if (env[source] !== undefined && env[source].trim() === "") {
@@ -20027,22 +20101,25 @@ function resolveTodosCliStorageMode(env = process.env) {
20027
20101
  ["HASNA_TODOS_STORAGE_MODE", canonical],
20028
20102
  ["TODOS_STORAGE_MODE", fallback]
20029
20103
  ]) {
20030
- if (value && !VALID_STORAGE_MODES.has(value)) {
20031
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source}=${value} must be local, remote, self_hosted, cloud, or hybrid; ` + "local SQLite fallback is disabled for invalid routing state");
20104
+ if (value && !(value in TRANSPORT_TOKENS)) {
20105
+ throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source}=${value} must be sqlite (local file) or http (hosted /v1 authority); ` + "legacy values local and remote are accepted; " + "local SQLite fallback is disabled for invalid routing state");
20032
20106
  }
20033
20107
  }
20034
- if (canonical && fallback && modeRole(canonical) !== modeRole(fallback)) {
20108
+ const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
20109
+ const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
20110
+ if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
20035
20111
  throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
20036
20112
  }
20037
- const mode = canonical ?? fallback ?? "local";
20113
+ const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
20038
20114
  return {
20039
- mode,
20040
- selected: CLOUD_MODES.has(mode),
20115
+ mode: transport,
20116
+ transport,
20117
+ selected: transport === "http",
20041
20118
  source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
20042
20119
  };
20043
20120
  }
20044
- function requestedStorageMode(env) {
20045
- return resolveTodosCliStorageMode(env).mode;
20121
+ function requestedTransport(env) {
20122
+ return resolveTodosCliStorageMode(env).transport;
20046
20123
  }
20047
20124
  function normalizeRemoteAuthorityUrl(value) {
20048
20125
  const trimmed = value?.trim();
@@ -20095,7 +20172,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
20095
20172
  return {
20096
20173
  selected: false,
20097
20174
  ok: true,
20098
- mode: mode || "local",
20175
+ mode,
20099
20176
  api_url_configured: false,
20100
20177
  api_key_configured: false,
20101
20178
  v1_base_url: null,
@@ -20207,8 +20284,7 @@ async function requiredRemoteRoute(client, route, request) {
20207
20284
  }
20208
20285
  }
20209
20286
  function getTodosCloudClient(env = process.env) {
20210
- const mode = requestedStorageMode(env);
20211
- if (!CLOUD_MODES.has(mode))
20287
+ if (requestedTransport(env) !== "http")
20212
20288
  return null;
20213
20289
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
20214
20290
  fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
@@ -20243,13 +20319,38 @@ function toListQuery(filter = {}) {
20243
20319
  query["assigned_to"] = filter.assigned_to;
20244
20320
  if (filter.agent_id)
20245
20321
  query["agent_id"] = filter.agent_id;
20322
+ if (filter.tags?.length)
20323
+ query["tags"] = filter.tags.join(",");
20246
20324
  if (typeof filter.limit === "number")
20247
20325
  query["limit"] = filter.limit;
20248
20326
  if (typeof filter.offset === "number")
20249
20327
  query["offset"] = filter.offset;
20250
20328
  return query;
20251
20329
  }
20330
+ async function fetchListTagsCapability(client) {
20331
+ const document = await requiredRemoteRoute(client, "/v1/openapi.json", () => client.transport.get("/openapi.json"));
20332
+ if (!document || typeof document !== "object" || Array.isArray(document))
20333
+ return false;
20334
+ const paths = document["paths"];
20335
+ const tasksPath = paths && typeof paths === "object" && !Array.isArray(paths) ? paths["/v1/tasks"] : undefined;
20336
+ const get = tasksPath && typeof tasksPath === "object" && !Array.isArray(tasksPath) ? tasksPath["get"] : undefined;
20337
+ const parameters = get && typeof get === "object" && !Array.isArray(get) ? get["parameters"] : undefined;
20338
+ return Array.isArray(parameters) && parameters.some((parameter) => parameter && typeof parameter === "object" && parameter["name"] === "tags");
20339
+ }
20340
+ async function requireTagsFilterCapability(client) {
20341
+ const authority = remoteAuthorityBase(client);
20342
+ let capability = listTagsCapabilityCache.get(authority);
20343
+ if (!capability) {
20344
+ capability = fetchListTagsCapability(client);
20345
+ listTagsCapabilityCache.set(authority, capability);
20346
+ }
20347
+ if (!await capability) {
20348
+ throw new Error(`REMOTE_TAGS_FILTER_UNSUPPORTED: configured Todos authority ${authority} does not advertise the tags ` + "query param on GET /v1/tasks; deploy the current @hasna/todos /v1 server to filter by tag; " + "no unfiltered task read was issued");
20349
+ }
20350
+ }
20252
20351
  async function cloudListTasks(client, filter = {}) {
20352
+ if (filter.tags?.length)
20353
+ await requireTagsFilterCapability(client);
20253
20354
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
20254
20355
  const envelope = res.raw;
20255
20356
  return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
@@ -20357,6 +20458,8 @@ function redactComment(comment) {
20357
20458
  }
20358
20459
  async function cloudCountTasks(client, filter = {}) {
20359
20460
  const { limit: _drop, offset: _o, ...rest } = filter;
20461
+ if (rest.tags?.length)
20462
+ await requireTagsFilterCapability(client);
20360
20463
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: { ...toListQuery(rest), limit: 1 } }));
20361
20464
  const envelope = res.raw;
20362
20465
  if (typeof envelope?.total === "number")
@@ -20419,14 +20522,23 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
20419
20522
  }
20420
20523
  throw new Error(`Task list not found: "${input}"`);
20421
20524
  }
20422
- var UUID_RE, CLOUD_MODES, VALID_STORAGE_MODES, completionCapabilityCache;
20525
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, listTagsCapabilityCache;
20423
20526
  var init_cloud_router = __esm(() => {
20527
+ init_types();
20424
20528
  init_redaction();
20425
20529
  init_http_client();
20426
20530
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20427
- CLOUD_MODES = new Set(["self_hosted", "cloud", "remote", "hybrid"]);
20428
- VALID_STORAGE_MODES = new Set(["local", ...CLOUD_MODES]);
20531
+ TRANSPORT_TOKENS = {
20532
+ sqlite: "sqlite",
20533
+ http: "http",
20534
+ local: "sqlite",
20535
+ remote: "http",
20536
+ self_hosted: "http",
20537
+ cloud: "http",
20538
+ hybrid: "http"
20539
+ };
20429
20540
  completionCapabilityCache = new Map;
20541
+ listTagsCapabilityCache = new Map;
20430
20542
  });
20431
20543
 
20432
20544
  // src/mcp/tools/task-crud.ts
@@ -24012,12 +24124,11 @@ ${task.description || ""}`.split(/\r?\n/);
24012
24124
  function normalizeStackLocation(value) {
24013
24125
  return value.replace(/:\d+(?::\d+)?/g, "").replace(/^file:\/\//, "").toLowerCase().trim();
24014
24126
  }
24015
- function fingerprint(task) {
24127
+ function fingerprintContent(task) {
24016
24128
  const body = task.description || "";
24017
24129
  const text = `${task.title}
24018
24130
  ${body}`;
24019
24131
  return {
24020
- task,
24021
24132
  title: normalizeText(task.title),
24022
24133
  body: normalizeText(body),
24023
24134
  text: normalizeText(text),
@@ -24026,6 +24137,9 @@ ${body}`;
24026
24137
  stackKeys: stackKeysFor(task)
24027
24138
  };
24028
24139
  }
24140
+ function fingerprint(task) {
24141
+ return { task, ...fingerprintContent(task) };
24142
+ }
24029
24143
  function intersects(left, right) {
24030
24144
  for (const value of left)
24031
24145
  if (right.has(value))
@@ -25362,7 +25476,7 @@ function createReleaseCompatibilityReport(options = {}) {
25362
25476
  "todos --help",
25363
25477
  "todos-mcp --help",
25364
25478
  "todos-serve --help",
25365
- "todos doctor"
25479
+ "todos doctor --no-fail-on-findings"
25366
25480
  ],
25367
25481
  smoke_tests: [
25368
25482
  "command -v todos",
@@ -25370,7 +25484,7 @@ function createReleaseCompatibilityReport(options = {}) {
25370
25484
  "command -v todos-serve",
25371
25485
  "todos --version",
25372
25486
  "todos --help",
25373
- "todos doctor"
25487
+ "todos doctor --no-fail-on-findings"
25374
25488
  ],
25375
25489
  rollback: [
25376
25490
  "npm view @hasna/todos versions --json",
@@ -29016,6 +29130,211 @@ var init_auto_assign = __esm(() => {
29016
29130
  init_agents();
29017
29131
  });
29018
29132
 
29133
+ // src/lib/integrity.ts
29134
+ function resolveIntegritySeverity(spec, measurement) {
29135
+ if (spec.kind === "dangling")
29136
+ return "error";
29137
+ if (spec.escalate_when_open && (measurement.open_count ?? 0) > 0)
29138
+ return "error";
29139
+ return spec.base_severity;
29140
+ }
29141
+ function plural(entity, count) {
29142
+ const [one, many] = ENTITY_LABEL[entity];
29143
+ return count === 1 ? one : many;
29144
+ }
29145
+ function formatIntegrityMessage(spec, measurement) {
29146
+ const noun = plural(spec.entity, measurement.count);
29147
+ const verb = measurement.count === 1 ? ["has", "references"] : ["have", "reference"];
29148
+ const open = measurement.open_count === null || measurement.open_count === 0 ? "" : ` (${measurement.open_count} still open)`;
29149
+ return spec.kind === "missing" ? `${measurement.count} ${noun} ${verb[0]} no ${spec.field}${open}` : `${measurement.count} ${noun} ${verb[1]} a ${spec.target.replace("_", " ")} that is not registered${open}`;
29150
+ }
29151
+ function measuredCondition(spec, measurement, source) {
29152
+ return {
29153
+ id: spec.id,
29154
+ entity: spec.entity,
29155
+ field: spec.field,
29156
+ kind: spec.kind,
29157
+ count: measurement.count,
29158
+ open_count: measurement.open_count,
29159
+ severity: measurement.count > 0 ? resolveIntegritySeverity(spec, measurement) : null,
29160
+ verified: true,
29161
+ source,
29162
+ message: formatIntegrityMessage(spec, measurement),
29163
+ impact: spec.impact
29164
+ };
29165
+ }
29166
+ function unverifiedCondition(spec, reason) {
29167
+ return {
29168
+ id: spec.id,
29169
+ entity: spec.entity,
29170
+ field: spec.field,
29171
+ kind: spec.kind,
29172
+ count: null,
29173
+ open_count: null,
29174
+ severity: null,
29175
+ verified: false,
29176
+ source: "unverified",
29177
+ unverified_reason: reason,
29178
+ message: `${spec.id}: NOT CHECKED \u2014 ${reason}`,
29179
+ impact: spec.impact
29180
+ };
29181
+ }
29182
+ function summarizeIntegrity(conditions) {
29183
+ const measured = conditions.filter((condition) => condition.verified && condition.count !== null);
29184
+ const findings = measured.filter((condition) => (condition.count ?? 0) > 0);
29185
+ const unverified = conditions.length - measured.length;
29186
+ return {
29187
+ ok: unverified === 0 && findings.length === 0,
29188
+ findings: findings.length,
29189
+ rows: findings.reduce((total, condition) => total + (condition.count ?? 0), 0),
29190
+ errors: findings.filter((condition) => condition.severity === "error").length,
29191
+ warnings: findings.filter((condition) => condition.severity === "warn").length,
29192
+ unverified,
29193
+ complete: unverified === 0
29194
+ };
29195
+ }
29196
+ function buildIntegrityReport(conditions, generatedAt) {
29197
+ const measuredSources = [...new Set(conditions.map((condition) => condition.source))].filter((source) => source !== "unverified");
29198
+ return {
29199
+ schema_version: TODOS_INTEGRITY_SCHEMA_VERSION,
29200
+ generated_at: generatedAt,
29201
+ source: measuredSources.length === 0 ? "unverified" : measuredSources.length === 1 ? measuredSources[0] : "remote-derived",
29202
+ conditions,
29203
+ summary: summarizeIntegrity(conditions)
29204
+ };
29205
+ }
29206
+ function sqliteOpenStatusList() {
29207
+ return OPEN_TASK_STATUSES.map((status) => `'${status}'`).join(", ");
29208
+ }
29209
+ function buildSqliteIntegritySql(spec) {
29210
+ const table = SQLITE_TABLE[spec.entity];
29211
+ const target = SQLITE_TABLE[spec.target];
29212
+ const column = `t."${spec.field}"`;
29213
+ const predicate = spec.kind === "missing" ? `(${column} IS NULL OR ${column} = '')` : `${column} IS NOT NULL AND ${column} <> '' ` + `AND NOT EXISTS (SELECT 1 FROM "${target}" r WHERE r."id" = ${column})`;
29214
+ const openExpr = spec.entity === "task" ? `SUM(CASE WHEN t."status" IN (${sqliteOpenStatusList()}) THEN 1 ELSE 0 END)` : "NULL";
29215
+ return `SELECT COUNT(*) AS count, ${openExpr} AS open_count FROM "${table}" t WHERE ${predicate}`;
29216
+ }
29217
+ function buildPostgresIntegritySql(spec, options) {
29218
+ const params = [options.service, RECORD_TYPE[spec.entity]];
29219
+ const p = (value) => {
29220
+ params.push(value);
29221
+ return `$${params.length}`;
29222
+ };
29223
+ const column = `t.payload->>'${spec.field}'`;
29224
+ const predicate = spec.kind === "missing" ? `(${column} IS NULL OR ${column} = '')` : `${column} IS NOT NULL AND ${column} <> '' AND NOT EXISTS (` + `SELECT 1 FROM ${options.table} r WHERE r.service = $1 AND r.object_type = ${p(RECORD_TYPE[spec.target])} ` + `AND r.deleted_at IS NULL AND r.object_id = ${column})`;
29225
+ const openExpr = spec.entity === "task" ? `COUNT(*) FILTER (WHERE t.payload->>'status' IN (${OPEN_TASK_STATUSES.map((status) => p(status)).join(", ")}))::int` : "NULL::int";
29226
+ const sql = `/* todos:integrity-${spec.id} */ SELECT COUNT(*)::int AS count, ${openExpr} AS open_count ` + `FROM ${options.table} t WHERE t.service = $1 AND t.object_type = $2 AND t.deleted_at IS NULL AND ${predicate}`;
29227
+ return { sql, params };
29228
+ }
29229
+ var TODOS_INTEGRITY_SCHEMA_VERSION = "todos.integrity.v1", TERMINAL_TASK_STATUSES, OPEN_TASK_STATUSES, INTEGRITY_CONDITIONS, ENTITY_LABEL, SQLITE_TABLE, RECORD_TYPE;
29230
+ var init_integrity = __esm(() => {
29231
+ init_types();
29232
+ TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
29233
+ OPEN_TASK_STATUSES = TASK_STATUSES.filter((status) => !TERMINAL_TASK_STATUSES.includes(status));
29234
+ INTEGRITY_CONDITIONS = [
29235
+ {
29236
+ id: "tasks_without_project",
29237
+ entity: "task",
29238
+ field: "project_id",
29239
+ target: "project",
29240
+ kind: "missing",
29241
+ base_severity: "warn",
29242
+ escalate_when_open: true,
29243
+ impact: "invisible to every project-scoped read (list, status, next, claim)"
29244
+ },
29245
+ {
29246
+ id: "tasks_without_task_list",
29247
+ entity: "task",
29248
+ field: "task_list_id",
29249
+ target: "task_list",
29250
+ kind: "missing",
29251
+ base_severity: "warn",
29252
+ escalate_when_open: true,
29253
+ impact: "invisible to every task-list read \u2014 the list reports zero open work"
29254
+ },
29255
+ {
29256
+ id: "tasks_with_unregistered_project",
29257
+ entity: "task",
29258
+ field: "project_id",
29259
+ target: "project",
29260
+ kind: "dangling",
29261
+ base_severity: "error",
29262
+ escalate_when_open: false,
29263
+ impact: "points at a project id that does not exist; the reference can never resolve"
29264
+ },
29265
+ {
29266
+ id: "tasks_with_unregistered_task_list",
29267
+ entity: "task",
29268
+ field: "task_list_id",
29269
+ target: "task_list",
29270
+ kind: "dangling",
29271
+ base_severity: "error",
29272
+ escalate_when_open: false,
29273
+ impact: "points at a task-list id that does not exist; the reference can never resolve"
29274
+ },
29275
+ {
29276
+ id: "task_lists_without_project",
29277
+ entity: "task_list",
29278
+ field: "project_id",
29279
+ target: "project",
29280
+ kind: "missing",
29281
+ base_severity: "warn",
29282
+ escalate_when_open: false,
29283
+ impact: "unbound list \u2014 unreachable from any project, so its tasks are unroutable"
29284
+ },
29285
+ {
29286
+ id: "task_lists_with_unregistered_project",
29287
+ entity: "task_list",
29288
+ field: "project_id",
29289
+ target: "project",
29290
+ kind: "dangling",
29291
+ base_severity: "error",
29292
+ escalate_when_open: false,
29293
+ impact: "points at a project id that does not exist; the list can never be reached"
29294
+ }
29295
+ ];
29296
+ ENTITY_LABEL = {
29297
+ task: ["task", "tasks"],
29298
+ task_list: ["task list", "task lists"]
29299
+ };
29300
+ SQLITE_TABLE = {
29301
+ task: "tasks",
29302
+ task_list: "task_lists",
29303
+ project: "projects"
29304
+ };
29305
+ RECORD_TYPE = {
29306
+ task: "tasks",
29307
+ task_list: "task_lists",
29308
+ project: "projects"
29309
+ };
29310
+ });
29311
+
29312
+ // src/db/integrity.ts
29313
+ function tableExists(db, table) {
29314
+ return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
29315
+ }
29316
+ function scanSqliteIntegrity(db = getDatabase()) {
29317
+ const missing = Object.keys(REQUIRED_TABLES2).filter((table) => !tableExists(db, table));
29318
+ const conditions = INTEGRITY_CONDITIONS.map((spec) => {
29319
+ if (missing.length > 0) {
29320
+ return unverifiedCondition(spec, `local schema is missing table(s): ${missing.join(", ")}`);
29321
+ }
29322
+ try {
29323
+ const row = db.query(buildSqliteIntegritySql(spec)).get();
29324
+ return measuredCondition(spec, { count: Number(row?.count ?? 0), open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null }, "sqlite");
29325
+ } catch (error) {
29326
+ return unverifiedCondition(spec, error instanceof Error ? error.message : String(error));
29327
+ }
29328
+ });
29329
+ return buildIntegrityReport(conditions, now());
29330
+ }
29331
+ var REQUIRED_TABLES2;
29332
+ var init_integrity2 = __esm(() => {
29333
+ init_database();
29334
+ init_integrity();
29335
+ REQUIRED_TABLES2 = { tasks: true, task_lists: true, projects: true };
29336
+ });
29337
+
29019
29338
  // src/lib/doctor.ts
29020
29339
  var exports_doctor = {};
29021
29340
  __export(exports_doctor, {
@@ -29023,7 +29342,7 @@ __export(exports_doctor, {
29023
29342
  });
29024
29343
  import { chmodSync, copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync5, statSync as statSync5 } from "fs";
29025
29344
  import { basename as basename4, dirname as dirname6, join as join8 } from "path";
29026
- function tableExists(db, table) {
29345
+ function tableExists2(db, table) {
29027
29346
  return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
29028
29347
  }
29029
29348
  function quoteIdent(identifier) {
@@ -29107,7 +29426,7 @@ function findDuplicateIndexes(db) {
29107
29426
  return duplicates;
29108
29427
  }
29109
29428
  function findMissingProjectRoots(db) {
29110
- if (!tableExists(db, "projects"))
29429
+ if (!tableExists2(db, "projects"))
29111
29430
  return 0;
29112
29431
  let missing = 0;
29113
29432
  const rows = db.query("SELECT path FROM projects WHERE path IS NOT NULL AND path != ''").all();
@@ -29122,7 +29441,7 @@ function findMissingProjectRoots(db) {
29122
29441
  return missing;
29123
29442
  }
29124
29443
  function addTaskStateChecks(db, checks) {
29125
- if (!tableExists(db, "tasks"))
29444
+ if (!tableExists2(db, "tasks"))
29126
29445
  return;
29127
29446
  const columns = getTableColumns(db, "tasks");
29128
29447
  const staleCutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
@@ -29194,15 +29513,42 @@ function createBackup(dbPath) {
29194
29513
  function pushRepair(repairs, type, message, applied, count) {
29195
29514
  repairs.push({ type, message, applied, count });
29196
29515
  }
29197
- function summarize2(checks, repairs) {
29516
+ function summarize2(checks, repairs, integrity) {
29198
29517
  return {
29199
29518
  errors: checks.filter((check) => check.severity === "error").length,
29200
29519
  warnings: checks.filter((check) => check.severity === "warn").length,
29201
29520
  infos: checks.filter((check) => check.severity === "info").length,
29202
29521
  repairable: checks.filter((check) => check.repairable).length,
29203
- applied: repairs.filter((repair) => repair.applied).length
29522
+ applied: repairs.filter((repair) => repair.applied).length,
29523
+ integrity_findings: integrity.summary.findings,
29524
+ integrity_rows: integrity.summary.rows,
29525
+ integrity_unverified: integrity.summary.unverified
29204
29526
  };
29205
29527
  }
29528
+ function addIntegrityChecks(integrity, checks) {
29529
+ for (const condition of integrity.conditions) {
29530
+ if (!condition.verified) {
29531
+ addCheck(checks, {
29532
+ severity: "warn",
29533
+ type: `${condition.id}_unverified`,
29534
+ message: condition.message,
29535
+ repairable: false,
29536
+ integrity: true
29537
+ });
29538
+ continue;
29539
+ }
29540
+ if ((condition.count ?? 0) === 0)
29541
+ continue;
29542
+ addCheck(checks, {
29543
+ severity: condition.severity ?? "warn",
29544
+ type: condition.id,
29545
+ message: `${condition.message} \u2014 ${condition.impact}`,
29546
+ count: condition.count ?? undefined,
29547
+ repairable: false,
29548
+ integrity: true
29549
+ });
29550
+ }
29551
+ }
29206
29552
  function deleteOrphans(db, table, where) {
29207
29553
  const before = countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
29208
29554
  if (before > 0)
@@ -29231,7 +29577,7 @@ function runTodosDoctor(options = {}) {
29231
29577
  message: `Schema at migration ${migrationCurrent}`
29232
29578
  });
29233
29579
  }
29234
- const missingTables = REQUIRED_TABLES2.filter((table) => !tableExists(db, table));
29580
+ const missingTables = REQUIRED_TABLES3.filter((table) => !tableExists2(db, table));
29235
29581
  if (missingTables.length > 0) {
29236
29582
  addCheck(checks, {
29237
29583
  severity: "error",
@@ -29241,7 +29587,7 @@ function runTodosDoctor(options = {}) {
29241
29587
  repairable: true
29242
29588
  });
29243
29589
  }
29244
- const orphanedParents = tableExists(db, "tasks") ? countQuery(db, "SELECT COUNT(*) as count FROM tasks t WHERE t.parent_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tasks p WHERE p.id = t.parent_id)") : 0;
29590
+ const orphanedParents = tableExists2(db, "tasks") ? countQuery(db, "SELECT COUNT(*) as count FROM tasks t WHERE t.parent_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tasks p WHERE p.id = t.parent_id)") : 0;
29245
29591
  if (orphanedParents > 0) {
29246
29592
  addCheck(checks, {
29247
29593
  severity: "error",
@@ -29251,7 +29597,7 @@ function runTodosDoctor(options = {}) {
29251
29597
  repairable: true
29252
29598
  });
29253
29599
  }
29254
- const orphanedDependencies = tableExists(db, "task_dependencies") && tableExists(db, "tasks") ? countQuery(db, "SELECT COUNT(*) as count FROM task_dependencies d WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.task_id) OR NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.depends_on)") : 0;
29600
+ const orphanedDependencies = tableExists2(db, "task_dependencies") && tableExists2(db, "tasks") ? countQuery(db, "SELECT COUNT(*) as count FROM task_dependencies d WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.task_id) OR NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.depends_on)") : 0;
29255
29601
  if (orphanedDependencies > 0) {
29256
29602
  addCheck(checks, {
29257
29603
  severity: "error",
@@ -29270,7 +29616,7 @@ function runTodosDoctor(options = {}) {
29270
29616
  ];
29271
29617
  let orphanedRows = 0;
29272
29618
  for (const [table, where] of orphanTables) {
29273
- if (!tableExists(db, table))
29619
+ if (!tableExists2(db, table))
29274
29620
  continue;
29275
29621
  orphanedRows += countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
29276
29622
  }
@@ -29284,6 +29630,8 @@ function runTodosDoctor(options = {}) {
29284
29630
  });
29285
29631
  }
29286
29632
  addTaskStateChecks(db, checks);
29633
+ const integrity = scanSqliteIntegrity(db);
29634
+ addIntegrityChecks(integrity, checks);
29287
29635
  const corruptJson = findCorruptJsonMetadata(db);
29288
29636
  if (corruptJson.length > 0) {
29289
29637
  addCheck(checks, {
@@ -29342,12 +29690,12 @@ function runTodosDoctor(options = {}) {
29342
29690
  db.run("UPDATE tasks SET parent_id = NULL WHERE parent_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM tasks p WHERE p.id = tasks.parent_id)");
29343
29691
  pushRepair(repairs, "orphaned_task_parents", "Cleared missing parent references", true, orphanedParents);
29344
29692
  }
29345
- if (orphanedDependencies > 0 && tableExists(db, "task_dependencies")) {
29693
+ if (orphanedDependencies > 0 && tableExists2(db, "task_dependencies")) {
29346
29694
  const count = deleteOrphans(db, "task_dependencies", "NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = task_dependencies.task_id) OR NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = task_dependencies.depends_on)");
29347
29695
  pushRepair(repairs, "orphaned_task_dependencies", "Deleted dependency rows referencing missing tasks", true, count);
29348
29696
  }
29349
29697
  for (const [table, where] of orphanTables) {
29350
- if (!tableExists(db, table))
29698
+ if (!tableExists2(db, table))
29351
29699
  continue;
29352
29700
  const count = deleteOrphans(db, table, where);
29353
29701
  if (count > 0)
@@ -29376,26 +29724,30 @@ function runTodosDoctor(options = {}) {
29376
29724
  }
29377
29725
  }
29378
29726
  }
29379
- const finalChecks = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }).checks : checks;
29380
- const summary = summarize2(finalChecks, repairs);
29727
+ const reread = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }) : undefined;
29728
+ const finalChecks = reread?.checks ?? checks;
29729
+ const finalIntegrity = reread?.integrity ?? integrity;
29730
+ const summary = summarize2(finalChecks, repairs, finalIntegrity);
29381
29731
  return {
29382
- ok: !finalChecks.some((check) => check.severity === "error"),
29732
+ ok: !finalChecks.some((check) => check.severity === "error") && finalIntegrity.summary.ok,
29383
29733
  dry_run: !apply,
29384
29734
  database_path: dbPath,
29385
29735
  migration: { current: getMigrationLevel(db), expected: migrationExpected },
29386
29736
  backup,
29387
29737
  checks: finalChecks,
29388
29738
  repairs,
29739
+ integrity: finalIntegrity,
29389
29740
  summary
29390
29741
  };
29391
29742
  }
29392
- var REQUIRED_TABLES2;
29743
+ var REQUIRED_TABLES3;
29393
29744
  var init_doctor = __esm(() => {
29394
29745
  init_database();
29746
+ init_integrity2();
29395
29747
  init_migrations();
29396
29748
  init_schema();
29397
29749
  init_recurrence();
29398
- REQUIRED_TABLES2 = [
29750
+ REQUIRED_TABLES3 = [
29399
29751
  "_migrations",
29400
29752
  "projects",
29401
29753
  "tasks",
@@ -29739,7 +30091,7 @@ function limits(input) {
29739
30091
  verification_limit: clamp(input.verification_limit, DEFAULT_LIMITS.verification_limit, 100),
29740
30092
  run_limit: clamp(input.run_limit, DEFAULT_LIMITS.run_limit, 20),
29741
30093
  dependency_limit: clamp(input.dependency_limit, DEFAULT_LIMITS.dependency_limit, 100),
29742
- plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, 100),
30094
+ plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, MAX_CONTEXT_TASKS),
29743
30095
  max_text_chars: clamp(input.max_text_chars, DEFAULT_LIMITS.max_text_chars, 50000),
29744
30096
  summary_char_limit: clamp(input.summary_char_limit, DEFAULT_LIMITS.summary_char_limit, 4000),
29745
30097
  stale_after_hours: clamp(input.stale_after_hours, DEFAULT_LIMITS.stale_after_hours, 24 * 365)
@@ -29762,6 +30114,13 @@ function taskSummary(task) {
29762
30114
  priority: task.priority
29763
30115
  };
29764
30116
  }
30117
+ function latestPlanTasks(planId, limit, db) {
30118
+ const rows = db.query(`SELECT * FROM tasks
30119
+ WHERE plan_id = ? AND archived_at IS NULL
30120
+ ORDER BY updated_at DESC, created_at DESC, id ASC
30121
+ LIMIT ?`).all(planId, limit);
30122
+ return rows.map(rowToTask);
30123
+ }
29765
30124
  function acceptanceCriteria(task, maxText) {
29766
30125
  const metadata = task.metadata || {};
29767
30126
  const raw = metadata["acceptance_criteria"] ?? metadata["acceptanceCriteria"] ?? metadata["criteria"];
@@ -29859,7 +30218,7 @@ function summarizeSection(pack, section, maxChars) {
29859
30218
  return "No plan was attached.";
29860
30219
  return summarizeStrings([
29861
30220
  `Plan ${pack.plan.name} is ${pack.plan.status}`,
29862
- `${pack.plan.tasks.length} listed plan tasks`,
30221
+ `${pack.plan.tasks.length} of ${pack.plan.total_tasks} plan tasks listed; ${pack.plan.omitted_tasks} omitted`,
29863
30222
  ...pack.plan.tasks.slice(0, 4).map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)
29864
30223
  ], maxChars);
29865
30224
  }
@@ -30009,7 +30368,8 @@ function createAgentContextPack(input, db) {
30009
30368
  const limit = limits(input);
30010
30369
  const project = task.project_id ? getProject(task.project_id, d) : null;
30011
30370
  const plan = task.plan_id ? getPlan(task.plan_id, d) : null;
30012
- const planTasks = task.plan_id ? listTasks({ plan_id: task.plan_id, limit: limit.plan_task_limit + 1 }, d) : [];
30371
+ const planTasks = task.plan_id ? latestPlanTasks(task.plan_id, limit.plan_task_limit, d) : [];
30372
+ const totalPlanTasks = task.plan_id ? countTasks({ plan_id: task.plan_id }, d) : 0;
30013
30373
  const upstream = getTaskDependencies(task.id, d).map((dep) => taskSummary(getTask(dep.depends_on, d))).filter((item) => Boolean(item));
30014
30374
  const downstream = getTaskDependents(task.id, d).map((dep) => taskSummary(getTask(dep.task_id, d))).filter((item) => Boolean(item));
30015
30375
  const comments = listComments(task.id, d);
@@ -30052,6 +30412,9 @@ function createAgentContextPack(input, db) {
30052
30412
  warnings.push(`${runs.length - selectedRuns.length} older runs omitted`);
30053
30413
  if (fileMap.size > relevantFiles.length)
30054
30414
  warnings.push(`${fileMap.size - relevantFiles.length} relevant files omitted`);
30415
+ if (totalPlanTasks > planTasks.length && plan) {
30416
+ warnings.push(`${totalPlanTasks - planTasks.length} plan tasks omitted (${totalPlanTasks} total); load more on demand with "todos plans --show ${plan.id}" and inspect task details with "todos show <task-id>" or "todos context-pack <task-id>"`);
30417
+ }
30055
30418
  const contextTask = {
30056
30419
  id: task.id,
30057
30420
  short_id: task.short_id,
@@ -30083,8 +30446,9 @@ function createAgentContextPack(input, db) {
30083
30446
  description: truncate2(plan.description, limit.max_text_chars),
30084
30447
  status: plan.status,
30085
30448
  agent_id: plan.agent_id,
30086
- tasks: planTasks.slice(0, limit.plan_task_limit).map(taskSummary).filter((item) => Boolean(item)),
30087
- omitted_tasks: Math.max(0, planTasks.length - limit.plan_task_limit)
30449
+ tasks: planTasks.map(taskSummary).filter((item) => Boolean(item)),
30450
+ total_tasks: totalPlanTasks,
30451
+ omitted_tasks: Math.max(0, totalPlanTasks - planTasks.length)
30088
30452
  } : null,
30089
30453
  acceptance_criteria: acceptanceCriteria(task, limit.max_text_chars),
30090
30454
  dependencies: {
@@ -30194,6 +30558,8 @@ ${pack.task.description}` : null,
30194
30558
  "## Project And Plan",
30195
30559
  pack.project ? `- Project: ${pack.project.name} (${pack.project.path})` : "- Project: none",
30196
30560
  pack.plan ? `- Plan: ${pack.plan.name} (${pack.plan.status})` : "- Plan: none",
30561
+ pack.plan ? `- Plan tasks: ${pack.plan.tasks.length} shown of ${pack.plan.total_tasks} total (${pack.plan.omitted_tasks} omitted)` : null,
30562
+ pack.plan && pack.plan.omitted_tasks > 0 ? `- Load more on demand: \`todos plans --show ${pack.plan.id}\`; inspect one task with \`todos show <task-id>\` or \`todos context-pack <task-id>\`.` : null,
30197
30563
  pack.plan && pack.plan.tasks.length > 0 ? bullet(pack.plan.tasks.map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)) : null,
30198
30564
  "",
30199
30565
  "## Acceptance Criteria",
@@ -30274,7 +30640,7 @@ function renderAgentContextPack(pack, format, compact2 = false) {
30274
30640
  return compact2 ? renderAgentContextPackCompactMarkdown(pack) : renderAgentContextPackMarkdown(pack);
30275
30641
  return compact2 ? JSON.stringify(pack) : JSON.stringify(pack, null, 2);
30276
30642
  }
30277
- var DEFAULT_LIMITS, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
30643
+ var DEFAULT_LIMITS, MAX_CONTEXT_TASKS = 24, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
30278
30644
  var init_context_packs = __esm(() => {
30279
30645
  init_comments();
30280
30646
  init_database();
@@ -30293,7 +30659,7 @@ var init_context_packs = __esm(() => {
30293
30659
  verification_limit: 10,
30294
30660
  run_limit: 3,
30295
30661
  dependency_limit: 12,
30296
- plan_task_limit: 20,
30662
+ plan_task_limit: 24,
30297
30663
  max_text_chars: 6000,
30298
30664
  summary_char_limit: 480,
30299
30665
  stale_after_hours: 72
@@ -31342,7 +31708,7 @@ function parseObject2(value) {
31342
31708
  function normalizeKey(value) {
31343
31709
  return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
31344
31710
  }
31345
- function normalizeFingerprint(value) {
31711
+ function normalizeTaskFindingFingerprint(value) {
31346
31712
  const normalized = normalizeKey(value);
31347
31713
  if (!normalized)
31348
31714
  throw new Error("finding fingerprint is required");
@@ -31451,7 +31817,7 @@ function assertTask(taskId, db) {
31451
31817
  throw new TaskNotFoundError(taskId);
31452
31818
  }
31453
31819
  function nextFinding(input, db) {
31454
- const fingerprint2 = normalizeFingerprint(input.fingerprint);
31820
+ const fingerprint2 = normalizeTaskFindingFingerprint(input.fingerprint);
31455
31821
  const title = redactOptional(input.title, 300);
31456
31822
  if (!title)
31457
31823
  throw new Error("finding title is required");
@@ -31599,7 +31965,7 @@ function resolveMissingTaskFindings(input, db) {
31599
31965
  const timestamp4 = input.resolved_at || now();
31600
31966
  const status = normalizeResolutionStatus(input.status);
31601
31967
  const runId = resolveRunForTask(input.run_id, input.task_id, d);
31602
- const present = new Set(input.fingerprints.map(normalizeFingerprint));
31968
+ const present = new Set(input.fingerprints.map(normalizeTaskFindingFingerprint));
31603
31969
  const warnings = [];
31604
31970
  const conditions = ["task_id = ?", "status = 'open'"];
31605
31971
  const params = [input.task_id];
@@ -34187,7 +34553,7 @@ var package_default;
34187
34553
  var init_package = __esm(() => {
34188
34554
  package_default = {
34189
34555
  name: "@hasna/todos",
34190
- version: "0.13.1",
34556
+ version: "0.13.3",
34191
34557
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
34192
34558
  type: "module",
34193
34559
  main: "dist/index.js",
@@ -34221,6 +34587,10 @@ var init_package = __esm(() => {
34221
34587
  "./storage": {
34222
34588
  types: "./dist/storage.d.ts",
34223
34589
  import: "./dist/storage.js"
34590
+ },
34591
+ "./testing": {
34592
+ types: "./dist/testing.d.ts",
34593
+ import: "./dist/testing.js"
34224
34594
  }
34225
34595
  },
34226
34596
  workspaces: [
@@ -34233,13 +34603,13 @@ var init_package = __esm(() => {
34233
34603
  "README.md"
34234
34604
  ],
34235
34605
  scripts: {
34236
- build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
34237
- "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
34606
+ build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
34607
+ "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
34238
34608
  migrate: "bun run src/server/index.ts migrate",
34239
34609
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
34240
34610
  "generate:sdk": "bun run scripts/generate-sdk.ts",
34241
34611
  "build:dashboard": "cd dashboard && bun install --frozen-lockfile && bun run build",
34242
- typecheck: "tsc --noEmit",
34612
+ typecheck: "tsc --noEmit -p tsconfig.typecheck.json",
34243
34613
  test: "bun test",
34244
34614
  "test:no-cloud": "bun test src/no-cloud-boundary.test.ts src/local-first.test.ts src/lib/public-release-gate.test.ts",
34245
34615
  "dev:cli": "bun run src/cli/index.tsx",
@@ -43940,15 +44310,15 @@ function resolveTaskRefLocal(db, ref) {
43940
44310
  return null;
43941
44311
  if (TASK_UUID_RE.test(raw))
43942
44312
  return getTask(raw, db);
43943
- const prefixRows = db.query("SELECT id FROM tasks WHERE LOWER(id) LIKE ? ESCAPE '\\' LIMIT 2").all(`${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
44313
+ const prefixRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(id) LIKE ? ESCAPE '\\' ORDER BY project_id, id LIMIT 2").all(`${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
43944
44314
  if (prefixRows.length > 1) {
43945
- throw new Error(`Task reference is ambiguous: "${ref}"`);
44315
+ throw new TaskReferenceAmbiguousError(ref, prefixRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
43946
44316
  }
43947
44317
  if (prefixRows.length === 1)
43948
44318
  return getTask(prefixRows[0].id, db);
43949
- const shortIdRows = db.query("SELECT id FROM tasks WHERE LOWER(short_id) = ? LIMIT 2").all(raw);
44319
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = ? ORDER BY project_id, id LIMIT 2").all(raw);
43950
44320
  if (shortIdRows.length > 1) {
43951
- throw new Error(`Task reference is ambiguous: "${ref}"`);
44321
+ throw new TaskReferenceAmbiguousError(ref, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
43952
44322
  }
43953
44323
  if (shortIdRows.length === 1)
43954
44324
  return getTask(shortIdRows[0].id, db);
@@ -44097,6 +44467,9 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
44097
44467
  exportSnapshot: () => exportSqliteTodosStorageSnapshot(database()),
44098
44468
  importSnapshot: (snapshot) => importSqliteTodosStorageSnapshot(snapshot, database())
44099
44469
  },
44470
+ integrity: {
44471
+ report: () => scanSqliteIntegrity(database())
44472
+ },
44100
44473
  transaction: (fn) => {
44101
44474
  const tx = database().transaction(() => fn(adapter));
44102
44475
  return tx();
@@ -44106,6 +44479,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
44106
44479
  }
44107
44480
  var TASK_UUID_RE;
44108
44481
  var init_local_sqlite = __esm(() => {
44482
+ init_types();
44109
44483
  init_search();
44110
44484
  init_tasks();
44111
44485
  init_projects();
@@ -44116,6 +44490,7 @@ var init_local_sqlite = __esm(() => {
44116
44490
  init_audit();
44117
44491
  init_comments();
44118
44492
  init_database();
44493
+ init_integrity2();
44119
44494
  init_sqlite_snapshot();
44120
44495
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44121
44496
  });
@@ -45040,6 +45415,9 @@ function createPostgresTodosStorageAdapter(options) {
45040
45415
  exportSnapshot: () => exportSnapshot(store),
45041
45416
  importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
45042
45417
  },
45418
+ integrity: {
45419
+ report: () => store.integrityReport()
45420
+ },
45043
45421
  transaction: (fn) => fn(adapter)
45044
45422
  };
45045
45423
  return adapter;
@@ -45146,8 +45524,9 @@ class PostgresJsonRecordStore {
45146
45524
  conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
45147
45525
  if (filter.session_id !== undefined)
45148
45526
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
45149
- if (filter.tags?.length)
45150
- conds.push(`payload->'tags' @> ${p(filter.tags)}::jsonb`);
45527
+ if (filter.tags?.length) {
45528
+ conds.push(`EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(payload->'tags', '[]'::jsonb)) AS task_tags(tag) ` + `WHERE ${inClause("task_tags.tag", filter.tags)})`);
45529
+ }
45151
45530
  if (filter.has_recurrence !== undefined) {
45152
45531
  conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
45153
45532
  }
@@ -45208,7 +45587,8 @@ class PostgresJsonRecordStore {
45208
45587
  AND object_id COLLATE "C" >= $2 AND object_id COLLATE "C" < $3
45209
45588
  LIMIT 2`, [this.service, raw, upper]);
45210
45589
  if (prefixResult.rows.length > 1) {
45211
- throw new Error(`Task reference is ambiguous: "${ref}"`);
45590
+ const candidates = prefixResult.rows.map((row) => payloadRecord2(row.payload));
45591
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task2) => ({ task_id: task2.id, project_id: task2.project_id })));
45212
45592
  }
45213
45593
  if (prefixResult.rows.length === 1) {
45214
45594
  return payloadRecord2(prefixResult.rows[0].payload);
@@ -45219,7 +45599,8 @@ class PostgresJsonRecordStore {
45219
45599
  AND LOWER(payload->>'short_id') = $2
45220
45600
  LIMIT 2`, [this.service, raw]);
45221
45601
  if (shortIdResult.rows.length > 1) {
45222
- throw new Error(`Task reference is ambiguous: "${ref}"`);
45602
+ const candidates = shortIdResult.rows.map((row) => payloadRecord2(row.payload));
45603
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task2) => ({ task_id: task2.id, project_id: task2.project_id })));
45223
45604
  }
45224
45605
  if (shortIdResult.rows.length === 1) {
45225
45606
  return payloadRecord2(shortIdResult.rows[0].payload);
@@ -45233,6 +45614,24 @@ class PostgresJsonRecordStore {
45233
45614
  const result = await this.options.client.query(sql, params);
45234
45615
  return Number(result.rows[0]?.count ?? 0);
45235
45616
  }
45617
+ async integrityReport() {
45618
+ await this.ensureSchema();
45619
+ const conditions = [];
45620
+ for (const spec of INTEGRITY_CONDITIONS) {
45621
+ const { sql, params } = buildPostgresIntegritySql(spec, { table: this.tableName, service: this.service });
45622
+ try {
45623
+ const result = await this.options.client.query(sql, params);
45624
+ const row = result.rows[0];
45625
+ conditions.push(measuredCondition(spec, {
45626
+ count: Number(row?.count ?? 0),
45627
+ open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null
45628
+ }, "postgres"));
45629
+ } catch (error) {
45630
+ conditions.push(unverifiedCondition(spec, error instanceof Error ? error.message : String(error)));
45631
+ }
45632
+ }
45633
+ return buildIntegrityReport(conditions, new Date().toISOString());
45634
+ }
45236
45635
  async listTombstones() {
45237
45636
  await this.ensureSchema();
45238
45637
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
@@ -45708,7 +46107,7 @@ async function startTask2(id, agentId, store) {
45708
46107
  if (task2.status !== "pending" && task2.status !== "in_progress") {
45709
46108
  throw new Error(`Task is ${task2.status} and cannot be started by ${agentId}`);
45710
46109
  }
45711
- return patchTask(task2, {
46110
+ const started = await patchTask(task2, {
45712
46111
  status: "in_progress",
45713
46112
  assigned_to: task2.assigned_to ?? agentId,
45714
46113
  agent_id: task2.agent_id ?? agentId,
@@ -45716,6 +46115,8 @@ async function startTask2(id, agentId, store) {
45716
46115
  locked_at: new Date().toISOString(),
45717
46116
  started_at: task2.started_at ?? new Date().toISOString()
45718
46117
  }, store);
46118
+ await logTaskChange2(task2.id, "start", "status", task2.status, "in_progress", agentId, store);
46119
+ return started;
45719
46120
  }
45720
46121
  async function completeTask2(id, agentId, options, store) {
45721
46122
  const task2 = await store.completeTask(id, agentId, options);
@@ -45832,9 +46233,11 @@ async function removeDependency2(taskId, dependsOn, store) {
45832
46233
  }
45833
46234
  async function listDependencies(taskId, store) {
45834
46235
  const edges = await store.list("dependencies");
46236
+ const incoming = edges.filter((edge) => edge.depends_on === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on }));
45835
46237
  return {
45836
46238
  dependencies: edges.filter((edge) => edge.task_id === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on })),
45837
- blocked_by: edges.filter((edge) => edge.depends_on === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on }))
46239
+ blocks: incoming,
46240
+ blocked_by: incoming
45838
46241
  };
45839
46242
  }
45840
46243
  async function addVerification(input, store, context) {
@@ -46380,6 +46783,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
46380
46783
  var init_postgres_adapter = __esm(() => {
46381
46784
  init_types();
46382
46785
  init_postgres_sync();
46786
+ init_integrity();
46383
46787
  init_redaction();
46384
46788
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
46385
46789
  });
@@ -46968,6 +47372,7 @@ __export(exports_cloud, {
46968
47372
  resolveCloudDatabaseUrl: () => resolveCloudDatabaseUrl,
46969
47373
  pingCloud: () => pingCloud,
46970
47374
  normalizeCloudPayloads: () => normalizeCloudPayloads,
47375
+ isPostgresBackendConfigured: () => isPostgresBackendConfigured,
46971
47376
  isCloudModeEnabled: () => isCloudModeEnabled,
46972
47377
  getCloudVerifier: () => getCloudVerifier,
46973
47378
  getCloudStorageAdapter: () => getCloudStorageAdapter,
@@ -46990,9 +47395,12 @@ function resolveCloudDatabaseUrl(env = process.env) {
46990
47395
  function resolveSigningSecret(env = process.env) {
46991
47396
  return env.HASNA_TODOS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
46992
47397
  }
46993
- function isCloudModeEnabled(env = process.env) {
47398
+ function isPostgresBackendConfigured(env = process.env) {
46994
47399
  return Boolean(resolveCloudDatabaseUrl(env));
46995
47400
  }
47401
+ function isCloudModeEnabled(env = process.env) {
47402
+ return isPostgresBackendConfigured(env);
47403
+ }
46996
47404
  function getClient() {
46997
47405
  if (cachedClient)
46998
47406
  return cachedClient;
@@ -47172,7 +47580,7 @@ function authNotConfiguredMessage(host) {
47172
47580
  `);
47173
47581
  }
47174
47582
  function resolveAuthPosture(input) {
47175
- const hosted = input.hosted ?? isCloudModeEnabled();
47583
+ const hosted = input.hosted ?? isPostgresBackendConfigured();
47176
47584
  const hasCredentialSource = Boolean(input.apiKey) || input.hasGeneratedKeys;
47177
47585
  if (hasCredentialSource) {
47178
47586
  return {
@@ -49133,6 +49541,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49133
49541
  { name: "task_list_id", in: "query", schema: { type: "string" } },
49134
49542
  { name: "assigned_to", in: "query", schema: { type: "string" } },
49135
49543
  { name: "agent_id", in: "query", schema: { type: "string" } },
49544
+ { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
49136
49545
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
49137
49546
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
49138
49547
  ],
@@ -50337,6 +50746,9 @@ async function handleV1Request(req, url, dependencies = {}) {
50337
50746
  ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
50338
50747
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
50339
50748
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
50749
+ ...url.searchParams.get("tags") ? {
50750
+ tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
50751
+ } : {},
50340
50752
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
50341
50753
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
50342
50754
  };
@@ -50618,6 +51030,13 @@ async function handleV1Request(req, url, dependencies = {}) {
50618
51030
  try {
50619
51031
  task2 = await store.tasks.resolveRef(id);
50620
51032
  } catch (e) {
51033
+ if (e instanceof TaskReferenceAmbiguousError) {
51034
+ return error(409, e.message, {
51035
+ code: TaskReferenceAmbiguousError.code,
51036
+ candidate_project_ids: e.candidateProjectIds,
51037
+ candidate_task_ids: e.candidateTaskIds
51038
+ });
51039
+ }
50621
51040
  const msg = e.message || "";
50622
51041
  if (/ambiguous/i.test(msg))
50623
51042
  return error(409, msg);
@@ -50975,6 +51394,15 @@ async function handleV1Request(req, url, dependencies = {}) {
50975
51394
  ]);
50976
51395
  return json3({ tasks, tasks_all: tasksAll, subtasks: tasksAll - tasks, projects: projects.length });
50977
51396
  }
51397
+ if (resource === "integrity" && !id) {
51398
+ if (method !== "GET")
51399
+ return error(405, `method ${method} not allowed on /v1/integrity`);
51400
+ if (typeof store.integrity?.report !== "function") {
51401
+ return error(501, "referential-integrity reporting is not supported by this storage backend");
51402
+ }
51403
+ const integrity = await store.integrity.report();
51404
+ return json3({ integrity });
51405
+ }
50978
51406
  if (resource === "import") {
50979
51407
  if (method !== "POST")
50980
51408
  return error(405, `method ${method} not allowed on /v1/import`);
@@ -50994,6 +51422,13 @@ async function handleV1Request(req, url, dependencies = {}) {
50994
51422
  }
50995
51423
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
50996
51424
  } catch (e) {
51425
+ if (e instanceof TaskReferenceAmbiguousError) {
51426
+ return error(409, e.message, {
51427
+ code: TaskReferenceAmbiguousError.code,
51428
+ candidate_project_ids: e.candidateProjectIds,
51429
+ candidate_task_ids: e.candidateTaskIds
51430
+ });
51431
+ }
50997
51432
  if (e instanceof LockError)
50998
51433
  return error(409, e.message, { code: LockError.code });
50999
51434
  if (e instanceof ResourceConflictError)
@@ -51489,7 +51924,7 @@ Dashboard not found at: ${dashboardDir}`);
51489
51924
  const path = url.pathname;
51490
51925
  const method = req.method;
51491
51926
  const reqOrigin = req.headers.get("origin") || undefined;
51492
- const corsHeaders = reqOrigin && (reqOrigin === `http://localhost:${port}` || reqOrigin === "http://localhost:0") ? {
51927
+ const corsHeaders = reqOrigin && reqOrigin === `http://localhost:${ctx.port}` ? {
51493
51928
  "Access-Control-Allow-Origin": reqOrigin,
51494
51929
  "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
51495
51930
  "Access-Control-Allow-Headers": "Content-Type, X-API-Key, Authorization",
@@ -51514,23 +51949,24 @@ Dashboard not found at: ${dashboardDir}`);
51514
51949
  }
51515
51950
  if ((path === "/health" || path === "/ready" || path === "/version") && method === "GET") {
51516
51951
  const { getPackageVersion: getPackageVersion2 } = await Promise.resolve().then(() => (init_package_version(), exports_package_version));
51517
- const { isCloudModeEnabled: isCloudModeEnabled2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
51518
- const mode = isCloudModeEnabled2() ? "remote" : "local";
51952
+ const { isPostgresBackendConfigured: isPostgresBackendConfigured2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
51953
+ const backend = isPostgresBackendConfigured2() ? "postgresql" : "sqlite";
51954
+ const mode = backend === "postgresql" ? "remote" : "local";
51519
51955
  const version = getPackageVersion2();
51520
51956
  if (path === "/version") {
51521
- return Response.json({ status: "ok", version, mode, name: "todos" });
51957
+ return Response.json({ status: "ok", version, mode, backend, name: "todos" });
51522
51958
  }
51523
51959
  if (path === "/ready") {
51524
- if (mode === "remote") {
51960
+ if (backend === "postgresql") {
51525
51961
  try {
51526
51962
  await pingCloud2();
51527
51963
  } catch (e) {
51528
- return Response.json({ status: "unavailable", version, mode, error: e.message }, { status: 503 });
51964
+ return Response.json({ status: "unavailable", version, mode, backend, error: e.message }, { status: 503 });
51529
51965
  }
51530
51966
  }
51531
- return Response.json({ status: "ready", version, mode });
51967
+ return Response.json({ status: "ready", version, mode, backend });
51532
51968
  }
51533
- return Response.json({ status: "ok", version, mode, name: "todos" });
51969
+ return Response.json({ status: "ok", version, mode, backend, name: "todos" });
51534
51970
  }
51535
51971
  if ((path === "/openapi.json" || path === "/v1/openapi.json") && method === "GET") {
51536
51972
  const { buildV1OpenApiDocument: buildV1OpenApiDocument2 } = await Promise.resolve().then(() => (init_openapi(), exports_openapi));
@@ -51777,7 +52213,9 @@ Dashboard not found at: ${dashboardDir}`);
51777
52213
  };
51778
52214
  process.on("SIGINT", shutdown);
51779
52215
  process.on("SIGTERM", shutdown);
51780
- const serverUrl = `http://localhost:${port}`;
52216
+ const boundPort = server.port ?? port;
52217
+ ctx.port = boundPort;
52218
+ const serverUrl = `http://localhost:${boundPort}`;
51781
52219
  console.log(`Todos Dashboard running at ${serverUrl}`);
51782
52220
  if (shouldOpen) {
51783
52221
  try {
@@ -51884,6 +52322,15 @@ function formatError(error2) {
51884
52322
  if (error2 instanceof TaskNotFoundError) {
51885
52323
  return JSON.stringify({ code: TaskNotFoundError.code, message: error2.message, suggestion: TaskNotFoundError.suggestion });
51886
52324
  }
52325
+ if (error2 instanceof TaskReferenceAmbiguousError) {
52326
+ return JSON.stringify({
52327
+ code: TaskReferenceAmbiguousError.code,
52328
+ message: error2.message,
52329
+ candidate_project_ids: error2.candidateProjectIds,
52330
+ candidate_task_ids: error2.candidateTaskIds,
52331
+ suggestion: "Use a full task UUID."
52332
+ });
52333
+ }
51887
52334
  if (error2 instanceof ProjectNotFoundError) {
51888
52335
  return JSON.stringify({ code: ProjectNotFoundError.code, message: error2.message, suggestion: ProjectNotFoundError.suggestion });
51889
52336
  }