@hasna/todos 0.13.2 → 0.13.4

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 (74) hide show
  1. package/README.md +78 -14
  2. package/dist/cli/cloud-router.d.ts +100 -7
  3. package/dist/cli/cloud-router.d.ts.map +1 -1
  4. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  6. package/dist/cli/doctor-integrity.d.ts +106 -0
  7. package/dist/cli/doctor-integrity.d.ts.map +1 -0
  8. package/dist/cli/helpers.d.ts +2 -2
  9. package/dist/cli/index.js +1509 -786
  10. package/dist/cli/stage-a.d.ts.map +1 -1
  11. package/dist/contracts.js +118 -30
  12. package/dist/db/agent-names.d.ts +2 -1
  13. package/dist/db/agent-names.d.ts.map +1 -1
  14. package/dist/db/database.d.ts.map +1 -1
  15. package/dist/db/findings.d.ts +1 -0
  16. package/dist/db/findings.d.ts.map +1 -1
  17. package/dist/db/integrity.d.ts +20 -0
  18. package/dist/db/integrity.d.ts.map +1 -0
  19. package/dist/index.d.ts +5 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +584 -140
  22. package/dist/lib/agent-name-normalize.d.ts +17 -0
  23. package/dist/lib/agent-name-normalize.d.ts.map +1 -0
  24. package/dist/lib/context-packs.d.ts +1 -0
  25. package/dist/lib/context-packs.d.ts.map +1 -1
  26. package/dist/lib/dependency-graph.d.ts +1 -1
  27. package/dist/lib/doctor.d.ts +20 -0
  28. package/dist/lib/doctor.d.ts.map +1 -1
  29. package/dist/lib/integrity.d.ts +215 -0
  30. package/dist/lib/integrity.d.ts.map +1 -0
  31. package/dist/lib/native-storage-status.d.ts +2 -2
  32. package/dist/lib/native-storage-status.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 +554 -92
  38. package/dist/mcp.js +7 -3
  39. package/dist/registry.d.ts +1 -1
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js +125 -30
  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 +560 -98
  50. package/dist/server/openapi.d.ts +12 -0
  51. package/dist/server/openapi.d.ts.map +1 -1
  52. package/dist/server/serve.d.ts.map +1 -1
  53. package/dist/server/v1.d.ts.map +1 -1
  54. package/dist/storage/config.d.ts +23 -5
  55. package/dist/storage/config.d.ts.map +1 -1
  56. package/dist/storage/factory.d.ts +9 -3
  57. package/dist/storage/factory.d.ts.map +1 -1
  58. package/dist/storage/hybrid.d.ts +7 -0
  59. package/dist/storage/hybrid.d.ts.map +1 -1
  60. package/dist/storage/index.d.ts +3 -3
  61. package/dist/storage/index.d.ts.map +1 -1
  62. package/dist/storage/interfaces.d.ts +29 -8
  63. package/dist/storage/interfaces.d.ts.map +1 -1
  64. package/dist/storage/local-sqlite.d.ts.map +1 -1
  65. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  66. package/dist/storage.d.ts +3 -3
  67. package/dist/storage.d.ts.map +1 -1
  68. package/dist/storage.js +623 -258
  69. package/dist/testing.d.ts +79 -0
  70. package/dist/testing.d.ts.map +1 -0
  71. package/dist/testing.js +107 -0
  72. package/dist/types/index.d.ts +12 -1
  73. package/dist/types/index.d.ts.map +1 -1
  74. package/package.json +7 -3
@@ -70,7 +70,7 @@ var package_default;
70
70
  var init_package = __esm(() => {
71
71
  package_default = {
72
72
  name: "@hasna/todos",
73
- version: "0.13.2",
73
+ version: "0.13.4",
74
74
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
75
75
  type: "module",
76
76
  main: "dist/index.js",
@@ -104,6 +104,10 @@ var init_package = __esm(() => {
104
104
  "./storage": {
105
105
  types: "./dist/storage.d.ts",
106
106
  import: "./dist/storage.js"
107
+ },
108
+ "./testing": {
109
+ types: "./dist/testing.d.ts",
110
+ import: "./dist/testing.js"
107
111
  }
108
112
  },
109
113
  workspaces: [
@@ -116,8 +120,8 @@ var init_package = __esm(() => {
116
120
  "README.md"
117
121
  ],
118
122
  scripts: {
119
- 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",
120
- "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/*'",
123
+ 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",
124
+ "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/*'",
121
125
  migrate: "bun run src/server/index.ts migrate",
122
126
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
123
127
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -201,15 +205,18 @@ var init_package_version = __esm(() => {
201
205
  var exports_config = {};
202
206
  __export(exports_config, {
203
207
  parseStorageMode: () => parseStorageMode,
208
+ parseStorageBackend: () => parseStorageBackend,
204
209
  loadTodosStorageConfig: () => loadTodosStorageConfig,
205
210
  loadStorageConfig: () => loadStorageConfig,
206
211
  isTodosShadowEnabled: () => isTodosShadowEnabled,
207
212
  isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
213
+ isTodosPostgresBackend: () => isTodosPostgresBackend,
208
214
  getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
209
215
  getTodosStorageMode: () => getTodosStorageMode,
210
216
  getTodosStorageEnvName: () => getTodosStorageEnvName,
211
217
  getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
212
218
  getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
219
+ getTodosStorageBackend: () => getTodosStorageBackend,
213
220
  getStorageMode: () => getStorageMode,
214
221
  getStorageDatabaseUrl: () => getStorageDatabaseUrl,
215
222
  getStorageDatabaseEnv: () => getStorageDatabaseEnv,
@@ -234,7 +241,7 @@ function getCanonicalTodosRdsConfig(env = process.env) {
234
241
  };
235
242
  }
236
243
  function loadTodosStorageConfig(env = process.env) {
237
- const mode = getTodosStorageMode(env);
244
+ const mode = getTodosStorageBackend(env);
238
245
  const databaseUrl = getTodosStorageDatabaseUrl(env);
239
246
  const bucket = readStorageEnv(env, "s3Bucket").value;
240
247
  const prefix = readStorageEnv(env, "s3Prefix").value ?? "todos/";
@@ -271,29 +278,41 @@ function loadTodosStorageConfig(env = process.env) {
271
278
  function loadStorageConfig(env = process.env) {
272
279
  return loadTodosStorageConfig(env);
273
280
  }
281
+ function isTodosPostgresBackend(config) {
282
+ return config.mode === "postgres";
283
+ }
274
284
  function isTodosRemoteStorageEnabled(config) {
275
- return config.mode === "remote" || config.mode === "hybrid";
285
+ return isTodosPostgresBackend(config);
276
286
  }
277
287
  function assertTodosRemoteStorageConfig(config) {
278
- if (!isTodosRemoteStorageEnabled(config))
288
+ if (!isTodosPostgresBackend(config))
279
289
  return;
280
290
  if (!config.database?.url) {
281
291
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
282
292
  }
283
293
  }
284
- function parseStorageMode(value) {
294
+ function parseStorageBackend(value) {
285
295
  const normalized = clean(value)?.toLowerCase();
286
296
  if (!normalized)
287
- return "local";
288
- if (normalized === "local" || normalized === "remote" || normalized === "hybrid")
297
+ return "sqlite";
298
+ if (normalized === "sqlite" || normalized === "postgres")
289
299
  return normalized;
290
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be local, remote, or hybrid`);
300
+ const legacy = LEGACY_BACKEND_TOKENS[normalized];
301
+ if (legacy)
302
+ return legacy;
303
+ throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
304
+ }
305
+ function parseStorageMode(value) {
306
+ return parseStorageBackend(value);
307
+ }
308
+ function getTodosStorageBackend(env = process.env) {
309
+ return parseStorageBackend(readStorageEnv(env, "mode").value);
291
310
  }
292
311
  function getTodosStorageMode(env = process.env) {
293
- return parseStorageMode(readStorageEnv(env, "mode").value);
312
+ return getTodosStorageBackend(env);
294
313
  }
295
314
  function getStorageMode(env = process.env) {
296
- return getTodosStorageMode(env);
315
+ return getTodosStorageBackend(env);
297
316
  }
298
317
  function isTodosShadowEnabled(env = process.env) {
299
318
  return parseBoolean(readStorageEnv(env, "shadow").value, false);
@@ -304,8 +323,8 @@ function getTodosStorageShadowEnvName(env = process.env) {
304
323
  function assertTodosShadowConfig(config, env = process.env) {
305
324
  if (!isTodosShadowEnabled(env))
306
325
  return;
307
- if (config.mode !== "local") {
308
- throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires ${TODOS_STORAGE_ENV.mode}=local (got ${config.mode})`);
326
+ if (config.mode !== "sqlite") {
327
+ throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires the sqlite backend (got ${config.mode})`);
309
328
  }
310
329
  if (!config.database?.url) {
311
330
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${readStorageEnv(env, "shadow").name} is enabled`);
@@ -361,7 +380,7 @@ function parsePositiveInteger(value, fallback) {
361
380
  }
362
381
  return parsed;
363
382
  }
364
- 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";
383
+ 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;
365
384
  var init_config = __esm(() => {
366
385
  TODOS_STORAGE_TABLES = [
367
386
  "todos_sync_records",
@@ -402,6 +421,14 @@ var init_config = __esm(() => {
402
421
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
403
422
  syncDryRun: "TODOS_SYNC_DRY_RUN"
404
423
  };
424
+ LEGACY_BACKEND_TOKENS = {
425
+ local: "sqlite",
426
+ remote: "postgres",
427
+ postgresql: "postgres",
428
+ hybrid: "postgres",
429
+ self_hosted: "postgres",
430
+ cloud: "postgres"
431
+ };
405
432
  });
406
433
 
407
434
  // src/storage/cloud-client.ts
@@ -469,7 +496,7 @@ var init_cloud_client = __esm(() => {
469
496
  function isBlockingDependencyStatus(status) {
470
497
  return status !== "completed" && status !== "cancelled";
471
498
  }
472
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
499
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
473
500
  var init_types = __esm(() => {
474
501
  TASK_STATUSES = [
475
502
  "pending",
@@ -502,6 +529,22 @@ var init_types = __esm(() => {
502
529
  this.name = "TaskNotFoundError";
503
530
  }
504
531
  };
532
+ TaskReferenceAmbiguousError = class TaskReferenceAmbiguousError extends Error {
533
+ reference;
534
+ static code = "TASK_REFERENCE_AMBIGUOUS";
535
+ candidateTaskIds;
536
+ candidateProjectIds;
537
+ constructor(reference, candidates) {
538
+ const candidateTaskIds = [...new Set(candidates.map((candidate) => candidate.task_id))].sort();
539
+ const candidateProjectIds = [...new Set(candidates.map((candidate) => candidate.project_id).filter((projectId) => typeof projectId === "string" && projectId.length > 0))].sort();
540
+ const projectList = candidateProjectIds.length > 0 ? candidateProjectIds.join(", ") : "(unscoped)";
541
+ super(`Task reference is ambiguous: "${reference}". Candidate project IDs: ${projectList}. ` + "Use a full task UUID.");
542
+ this.reference = reference;
543
+ this.name = "TaskReferenceAmbiguousError";
544
+ this.candidateTaskIds = candidateTaskIds;
545
+ this.candidateProjectIds = candidateProjectIds;
546
+ }
547
+ };
505
548
  ProjectNotFoundError = class ProjectNotFoundError extends Error {
506
549
  projectId;
507
550
  static code = "PROJECT_NOT_FOUND";
@@ -614,6 +657,11 @@ var init_types = __esm(() => {
614
657
  };
615
658
  });
616
659
 
660
+ // src/lib/agent-name-normalize.ts
661
+ function normalizeAgentNameInput(name) {
662
+ return name.trim().toLowerCase();
663
+ }
664
+
617
665
  // src/lib/slugs.ts
618
666
  function normalizeSlug(value) {
619
667
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1086,6 +1134,185 @@ var init_postgres_sync = __esm(() => {
1086
1134
  };
1087
1135
  });
1088
1136
 
1137
+ // src/lib/integrity.ts
1138
+ function resolveIntegritySeverity(spec, measurement) {
1139
+ if (spec.kind === "dangling")
1140
+ return "error";
1141
+ if (spec.escalate_when_open && (measurement.open_count ?? 0) > 0)
1142
+ return "error";
1143
+ return spec.base_severity;
1144
+ }
1145
+ function plural(entity, count) {
1146
+ const [one, many] = ENTITY_LABEL[entity];
1147
+ return count === 1 ? one : many;
1148
+ }
1149
+ function formatIntegrityMessage(spec, measurement) {
1150
+ const noun = plural(spec.entity, measurement.count);
1151
+ const verb = measurement.count === 1 ? ["has", "references"] : ["have", "reference"];
1152
+ const open = measurement.open_count === null || measurement.open_count === 0 ? "" : ` (${measurement.open_count} still open)`;
1153
+ 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}`;
1154
+ }
1155
+ function measuredCondition(spec, measurement, source) {
1156
+ return {
1157
+ id: spec.id,
1158
+ entity: spec.entity,
1159
+ field: spec.field,
1160
+ kind: spec.kind,
1161
+ count: measurement.count,
1162
+ open_count: measurement.open_count,
1163
+ severity: measurement.count > 0 ? resolveIntegritySeverity(spec, measurement) : null,
1164
+ verified: true,
1165
+ source,
1166
+ message: formatIntegrityMessage(spec, measurement),
1167
+ impact: spec.impact
1168
+ };
1169
+ }
1170
+ function unverifiedCondition(spec, reason) {
1171
+ return {
1172
+ id: spec.id,
1173
+ entity: spec.entity,
1174
+ field: spec.field,
1175
+ kind: spec.kind,
1176
+ count: null,
1177
+ open_count: null,
1178
+ severity: null,
1179
+ verified: false,
1180
+ source: "unverified",
1181
+ unverified_reason: reason,
1182
+ message: `${spec.id}: NOT CHECKED \u2014 ${reason}`,
1183
+ impact: spec.impact
1184
+ };
1185
+ }
1186
+ function summarizeIntegrity(conditions) {
1187
+ const measured = conditions.filter((condition) => condition.verified && condition.count !== null);
1188
+ const findings = measured.filter((condition) => (condition.count ?? 0) > 0);
1189
+ const unverified = conditions.length - measured.length;
1190
+ return {
1191
+ ok: unverified === 0 && findings.length === 0,
1192
+ findings: findings.length,
1193
+ rows: findings.reduce((total, condition) => total + (condition.count ?? 0), 0),
1194
+ errors: findings.filter((condition) => condition.severity === "error").length,
1195
+ warnings: findings.filter((condition) => condition.severity === "warn").length,
1196
+ unverified,
1197
+ complete: unverified === 0
1198
+ };
1199
+ }
1200
+ function buildIntegrityReport(conditions, generatedAt) {
1201
+ const measuredSources = [...new Set(conditions.map((condition) => condition.source))].filter((source) => source !== "unverified");
1202
+ return {
1203
+ schema_version: TODOS_INTEGRITY_SCHEMA_VERSION,
1204
+ generated_at: generatedAt,
1205
+ source: measuredSources.length === 0 ? "unverified" : measuredSources.length === 1 ? measuredSources[0] : "remote-derived",
1206
+ conditions,
1207
+ summary: summarizeIntegrity(conditions)
1208
+ };
1209
+ }
1210
+ function sqliteOpenStatusList() {
1211
+ return OPEN_TASK_STATUSES.map((status) => `'${status}'`).join(", ");
1212
+ }
1213
+ function buildSqliteIntegritySql(spec) {
1214
+ const table = SQLITE_TABLE[spec.entity];
1215
+ const target = SQLITE_TABLE[spec.target];
1216
+ const column = `t."${spec.field}"`;
1217
+ 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})`;
1218
+ const openExpr = spec.entity === "task" ? `SUM(CASE WHEN t."status" IN (${sqliteOpenStatusList()}) THEN 1 ELSE 0 END)` : "NULL";
1219
+ return `SELECT COUNT(*) AS count, ${openExpr} AS open_count FROM "${table}" t WHERE ${predicate}`;
1220
+ }
1221
+ function buildPostgresIntegritySql(spec, options) {
1222
+ const params = [options.service, RECORD_TYPE[spec.entity]];
1223
+ const p = (value) => {
1224
+ params.push(value);
1225
+ return `$${params.length}`;
1226
+ };
1227
+ const column = `t.payload->>'${spec.field}'`;
1228
+ 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})`;
1229
+ const openExpr = spec.entity === "task" ? `COUNT(*) FILTER (WHERE t.payload->>'status' IN (${OPEN_TASK_STATUSES.map((status) => p(status)).join(", ")}))::int` : "NULL::int";
1230
+ 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}`;
1231
+ return { sql, params };
1232
+ }
1233
+ var TODOS_INTEGRITY_SCHEMA_VERSION = "todos.integrity.v1", TERMINAL_TASK_STATUSES, OPEN_TASK_STATUSES, INTEGRITY_CONDITIONS, ENTITY_LABEL, SQLITE_TABLE, RECORD_TYPE;
1234
+ var init_integrity = __esm(() => {
1235
+ init_types();
1236
+ TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
1237
+ OPEN_TASK_STATUSES = TASK_STATUSES.filter((status) => !TERMINAL_TASK_STATUSES.includes(status));
1238
+ INTEGRITY_CONDITIONS = [
1239
+ {
1240
+ id: "tasks_without_project",
1241
+ entity: "task",
1242
+ field: "project_id",
1243
+ target: "project",
1244
+ kind: "missing",
1245
+ base_severity: "warn",
1246
+ escalate_when_open: true,
1247
+ impact: "invisible to every project-scoped read (list, status, next, claim)"
1248
+ },
1249
+ {
1250
+ id: "tasks_without_task_list",
1251
+ entity: "task",
1252
+ field: "task_list_id",
1253
+ target: "task_list",
1254
+ kind: "missing",
1255
+ base_severity: "warn",
1256
+ escalate_when_open: true,
1257
+ impact: "invisible to every task-list read \u2014 the list reports zero open work"
1258
+ },
1259
+ {
1260
+ id: "tasks_with_unregistered_project",
1261
+ entity: "task",
1262
+ field: "project_id",
1263
+ target: "project",
1264
+ kind: "dangling",
1265
+ base_severity: "error",
1266
+ escalate_when_open: false,
1267
+ impact: "points at a project id that does not exist; the reference can never resolve"
1268
+ },
1269
+ {
1270
+ id: "tasks_with_unregistered_task_list",
1271
+ entity: "task",
1272
+ field: "task_list_id",
1273
+ target: "task_list",
1274
+ kind: "dangling",
1275
+ base_severity: "error",
1276
+ escalate_when_open: false,
1277
+ impact: "points at a task-list id that does not exist; the reference can never resolve"
1278
+ },
1279
+ {
1280
+ id: "task_lists_without_project",
1281
+ entity: "task_list",
1282
+ field: "project_id",
1283
+ target: "project",
1284
+ kind: "missing",
1285
+ base_severity: "warn",
1286
+ escalate_when_open: false,
1287
+ impact: "unbound list \u2014 unreachable from any project, so its tasks are unroutable"
1288
+ },
1289
+ {
1290
+ id: "task_lists_with_unregistered_project",
1291
+ entity: "task_list",
1292
+ field: "project_id",
1293
+ target: "project",
1294
+ kind: "dangling",
1295
+ base_severity: "error",
1296
+ escalate_when_open: false,
1297
+ impact: "points at a project id that does not exist; the list can never be reached"
1298
+ }
1299
+ ];
1300
+ ENTITY_LABEL = {
1301
+ task: ["task", "tasks"],
1302
+ task_list: ["task list", "task lists"]
1303
+ };
1304
+ SQLITE_TABLE = {
1305
+ task: "tasks",
1306
+ task_list: "task_lists",
1307
+ project: "projects"
1308
+ };
1309
+ RECORD_TYPE = {
1310
+ task: "tasks",
1311
+ task_list: "task_lists",
1312
+ project: "projects"
1313
+ };
1314
+ });
1315
+
1089
1316
  // src/lib/sync-utils.ts
1090
1317
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
1091
1318
  import { join } from "path";
@@ -1368,7 +1595,7 @@ function createPostgresTodosStorageAdapter(options) {
1368
1595
  agents: {
1369
1596
  register: (input, context) => registerAgent(input, store, context),
1370
1597
  get: (id) => store.get("agents", id),
1371
- getByName: async (name) => (await store.list("agents")).find((agent) => agent.name === name) ?? null,
1598
+ getByName: async (name) => matchAgentByName(await store.list("agents"), name),
1372
1599
  list: async (options2) => (await store.list("agents")).filter((agent) => options2?.include_archived || agent.status !== "archived").sort((a, b) => a.name.localeCompare(b.name)),
1373
1600
  update: (id, input) => updateAgent(id, input, store),
1374
1601
  heartbeat: (idOrName, context) => heartbeatAgent(idOrName, store, context),
@@ -1425,6 +1652,9 @@ function createPostgresTodosStorageAdapter(options) {
1425
1652
  exportSnapshot: () => exportSnapshot(store),
1426
1653
  importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
1427
1654
  },
1655
+ integrity: {
1656
+ report: () => store.integrityReport()
1657
+ },
1428
1658
  transaction: (fn) => fn(adapter)
1429
1659
  };
1430
1660
  return adapter;
@@ -1531,8 +1761,9 @@ class PostgresJsonRecordStore {
1531
1761
  conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
1532
1762
  if (filter.session_id !== undefined)
1533
1763
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
1534
- if (filter.tags?.length)
1535
- conds.push(`payload->'tags' @> ${p(filter.tags)}::jsonb`);
1764
+ if (filter.tags?.length) {
1765
+ 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)})`);
1766
+ }
1536
1767
  if (filter.has_recurrence !== undefined) {
1537
1768
  conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
1538
1769
  }
@@ -1593,7 +1824,8 @@ class PostgresJsonRecordStore {
1593
1824
  AND object_id COLLATE "C" >= $2 AND object_id COLLATE "C" < $3
1594
1825
  LIMIT 2`, [this.service, raw, upper]);
1595
1826
  if (prefixResult.rows.length > 1) {
1596
- throw new Error(`Task reference is ambiguous: "${ref}"`);
1827
+ const candidates = prefixResult.rows.map((row) => payloadRecord2(row.payload));
1828
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task) => ({ task_id: task.id, project_id: task.project_id })));
1597
1829
  }
1598
1830
  if (prefixResult.rows.length === 1) {
1599
1831
  return payloadRecord2(prefixResult.rows[0].payload);
@@ -1604,7 +1836,8 @@ class PostgresJsonRecordStore {
1604
1836
  AND LOWER(payload->>'short_id') = $2
1605
1837
  LIMIT 2`, [this.service, raw]);
1606
1838
  if (shortIdResult.rows.length > 1) {
1607
- throw new Error(`Task reference is ambiguous: "${ref}"`);
1839
+ const candidates = shortIdResult.rows.map((row) => payloadRecord2(row.payload));
1840
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task) => ({ task_id: task.id, project_id: task.project_id })));
1608
1841
  }
1609
1842
  if (shortIdResult.rows.length === 1) {
1610
1843
  return payloadRecord2(shortIdResult.rows[0].payload);
@@ -1618,6 +1851,24 @@ class PostgresJsonRecordStore {
1618
1851
  const result = await this.options.client.query(sql, params);
1619
1852
  return Number(result.rows[0]?.count ?? 0);
1620
1853
  }
1854
+ async integrityReport() {
1855
+ await this.ensureSchema();
1856
+ const conditions = [];
1857
+ for (const spec of INTEGRITY_CONDITIONS) {
1858
+ const { sql, params } = buildPostgresIntegritySql(spec, { table: this.tableName, service: this.service });
1859
+ try {
1860
+ const result = await this.options.client.query(sql, params);
1861
+ const row = result.rows[0];
1862
+ conditions.push(measuredCondition(spec, {
1863
+ count: Number(row?.count ?? 0),
1864
+ open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null
1865
+ }, "postgres"));
1866
+ } catch (error) {
1867
+ conditions.push(unverifiedCondition(spec, error instanceof Error ? error.message : String(error)));
1868
+ }
1869
+ }
1870
+ return buildIntegrityReport(conditions, new Date().toISOString());
1871
+ }
1621
1872
  async listTombstones() {
1622
1873
  await this.ensureSchema();
1623
1874
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
@@ -2093,7 +2344,7 @@ async function startTask(id, agentId, store) {
2093
2344
  if (task.status !== "pending" && task.status !== "in_progress") {
2094
2345
  throw new Error(`Task is ${task.status} and cannot be started by ${agentId}`);
2095
2346
  }
2096
- return patchTask(task, {
2347
+ const started = await patchTask(task, {
2097
2348
  status: "in_progress",
2098
2349
  assigned_to: task.assigned_to ?? agentId,
2099
2350
  agent_id: task.agent_id ?? agentId,
@@ -2101,6 +2352,8 @@ async function startTask(id, agentId, store) {
2101
2352
  locked_at: new Date().toISOString(),
2102
2353
  started_at: task.started_at ?? new Date().toISOString()
2103
2354
  }, store);
2355
+ await logTaskChange(task.id, "start", "status", task.status, "in_progress", agentId, store);
2356
+ return started;
2104
2357
  }
2105
2358
  async function completeTask(id, agentId, options, store) {
2106
2359
  const task = await store.completeTask(id, agentId, options);
@@ -2403,15 +2656,27 @@ async function updatePlan(id, input, store) {
2403
2656
  }
2404
2657
  return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
2405
2658
  }
2659
+ function matchAgentByName(agents, name, options) {
2660
+ const target = normalizeAgentNameInput(name);
2661
+ if (!target)
2662
+ return null;
2663
+ const matches = agents.filter((agent) => normalizeAgentNameInput(agent.name) === target && (options?.includeArchived !== false || agent.status !== "archived"));
2664
+ if (matches.length === 0)
2665
+ return null;
2666
+ return matches.reduce((freshest, candidate) => new Date(candidate.last_seen_at).getTime() > new Date(freshest.last_seen_at).getTime() ? candidate : freshest);
2667
+ }
2406
2668
  async function registerAgent(input, store, context) {
2407
- const existing = (await store.list("agents")).find((agent2) => agent2.name === input.name && agent2.status !== "archived");
2669
+ const canonicalName = normalizeAgentNameInput(input.name);
2670
+ const existing = matchAgentByName(await store.list("agents"), canonicalName, {
2671
+ includeArchived: false
2672
+ });
2408
2673
  if (existing && !input.force && existing.session_id && existing.session_id !== input.session_id) {
2409
- return { conflict: true, message: `Agent name '${input.name}' is already active` };
2674
+ return { conflict: true, message: `Agent name '${canonicalName}' is already active` };
2410
2675
  }
2411
2676
  const timestamp = new Date().toISOString();
2412
2677
  const agent = {
2413
2678
  id: existing?.id ?? randomUUID().slice(0, 8),
2414
- name: input.name,
2679
+ name: canonicalName,
2415
2680
  description: input.description ?? existing?.description ?? null,
2416
2681
  role: input.role ?? existing?.role ?? null,
2417
2682
  title: input.title ?? existing?.title ?? null,
@@ -2449,7 +2714,7 @@ async function resolveAgent(idOrName, store) {
2449
2714
  const byId = await store.get("agents", idOrName);
2450
2715
  if (byId)
2451
2716
  return byId;
2452
- return (await store.list("agents")).find((agent) => agent.name === idOrName) ?? null;
2717
+ return matchAgentByName(await store.list("agents"), idOrName);
2453
2718
  }
2454
2719
  async function heartbeatAgent(idOrName, store, context) {
2455
2720
  const agent = await resolveAgent(idOrName, store);
@@ -2767,6 +3032,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
2767
3032
  var init_postgres_adapter = __esm(() => {
2768
3033
  init_types();
2769
3034
  init_postgres_sync();
3035
+ init_integrity();
2770
3036
  init_redaction();
2771
3037
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
2772
3038
  });
@@ -4765,6 +5031,7 @@ __export(exports_cloud, {
4765
5031
  resolveCloudDatabaseUrl: () => resolveCloudDatabaseUrl,
4766
5032
  pingCloud: () => pingCloud,
4767
5033
  normalizeCloudPayloads: () => normalizeCloudPayloads,
5034
+ isPostgresBackendConfigured: () => isPostgresBackendConfigured,
4768
5035
  isCloudModeEnabled: () => isCloudModeEnabled,
4769
5036
  getCloudVerifier: () => getCloudVerifier,
4770
5037
  getCloudStorageAdapter: () => getCloudStorageAdapter,
@@ -4787,9 +5054,12 @@ function resolveCloudDatabaseUrl(env = process.env) {
4787
5054
  function resolveSigningSecret(env = process.env) {
4788
5055
  return env.HASNA_TODOS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
4789
5056
  }
4790
- function isCloudModeEnabled(env = process.env) {
5057
+ function isPostgresBackendConfigured(env = process.env) {
4791
5058
  return Boolean(resolveCloudDatabaseUrl(env));
4792
5059
  }
5060
+ function isCloudModeEnabled(env = process.env) {
5061
+ return isPostgresBackendConfigured(env);
5062
+ }
4793
5063
  function getClient() {
4794
5064
  if (cachedClient)
4795
5065
  return cachedClient;
@@ -8617,6 +8887,30 @@ function findGitRoot(startDir) {
8617
8887
  }
8618
8888
  return null;
8619
8889
  }
8890
+ function getGlobalDbPath() {
8891
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
8892
+ return join3(home, ".hasna", "todos", "todos.db");
8893
+ }
8894
+ function hasExplicitProjectArg(args = process.argv.slice(2)) {
8895
+ return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
8896
+ }
8897
+ function getCliCommand(args = process.argv.slice(2)) {
8898
+ const globalOptionsWithValues = new Set(["--project", "--agent", "--session"]);
8899
+ for (let index = 0;index < args.length; index += 1) {
8900
+ const arg = args[index];
8901
+ if (globalOptionsWithValues.has(arg)) {
8902
+ index += 1;
8903
+ continue;
8904
+ }
8905
+ if (arg.startsWith("-"))
8906
+ continue;
8907
+ return arg;
8908
+ }
8909
+ return null;
8910
+ }
8911
+ function canCreateScopedProjectDb(args = process.argv.slice(2)) {
8912
+ return getCliCommand(args) === "project-bootstrap" && !args.some((arg) => arg === "--dry-run" || arg.startsWith("--dry-run="));
8913
+ }
8620
8914
  function getDbPath() {
8621
8915
  if (process.env["HASNA_TODOS_DB_PATH"]) {
8622
8916
  return process.env["HASNA_TODOS_DB_PATH"];
@@ -8624,18 +8918,19 @@ function getDbPath() {
8624
8918
  if (process.env["TODOS_DB_PATH"]) {
8625
8919
  return process.env["TODOS_DB_PATH"];
8626
8920
  }
8921
+ if (hasExplicitProjectArg())
8922
+ return getGlobalDbPath();
8627
8923
  const cwd = process.cwd();
8628
8924
  const nearest = findNearestProjectDb(cwd);
8629
8925
  if (nearest)
8630
8926
  return nearest;
8631
8927
  if (process.env["TODOS_DB_SCOPE"] === "project") {
8632
8928
  const gitRoot = findGitRoot(cwd);
8633
- if (gitRoot) {
8929
+ if (gitRoot && canCreateScopedProjectDb()) {
8634
8930
  return join3(gitRoot, ".hasna", "todos", "todos.db");
8635
8931
  }
8636
8932
  }
8637
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
8638
- return join3(home, ".hasna", "todos", "todos.db");
8933
+ return getGlobalDbPath();
8639
8934
  }
8640
8935
  function getDatabasePath() {
8641
8936
  return getDbPath();
@@ -8738,10 +9033,13 @@ function resolvePartialId(db, table, partialId) {
8738
9033
  return null;
8739
9034
  }
8740
9035
  if (table === "tasks") {
8741
- const shortIdRows = db.query("SELECT id FROM tasks WHERE short_id = ?").all(partialId);
9036
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = LOWER(?) ORDER BY project_id, id").all(partialId);
8742
9037
  if (shortIdRows.length === 1) {
8743
9038
  return shortIdRows[0].id;
8744
9039
  }
9040
+ if (shortIdRows.length > 1) {
9041
+ throw new TaskReferenceAmbiguousError(partialId, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
9042
+ }
8745
9043
  }
8746
9044
  if (table === "task_lists") {
8747
9045
  const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
@@ -8905,7 +9203,7 @@ function authNotConfiguredMessage(host) {
8905
9203
  `);
8906
9204
  }
8907
9205
  function resolveAuthPosture(input) {
8908
- const hosted = input.hosted ?? isCloudModeEnabled();
9206
+ const hosted = input.hosted ?? isPostgresBackendConfigured();
8909
9207
  const hasCredentialSource = Boolean(input.apiKey) || input.hasGeneratedKeys;
8910
9208
  if (hasCredentialSource) {
8911
9209
  return {
@@ -16413,9 +16711,6 @@ var init_tasks = __esm(() => {
16413
16711
  });
16414
16712
 
16415
16713
  // src/db/agent-names.ts
16416
- function normalizeAgentNameInput(name) {
16417
- return name.trim().toLowerCase();
16418
- }
16419
16714
  function hasGeneratedNumericSuffix(name) {
16420
16715
  return NUMERIC_SUFFIX_RE.test(normalizeAgentNameInput(name));
16421
16716
  }
@@ -17227,6 +17522,32 @@ var init_headless_boundaries = __esm(() => {
17227
17522
  LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
17228
17523
  });
17229
17524
 
17525
+ // src/db/integrity.ts
17526
+ function tableExists(db, table) {
17527
+ return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
17528
+ }
17529
+ function scanSqliteIntegrity(db = getDatabase()) {
17530
+ const missing = Object.keys(REQUIRED_TABLES).filter((table) => !tableExists(db, table));
17531
+ const conditions = INTEGRITY_CONDITIONS.map((spec) => {
17532
+ if (missing.length > 0) {
17533
+ return unverifiedCondition(spec, `local schema is missing table(s): ${missing.join(", ")}`);
17534
+ }
17535
+ try {
17536
+ const row = db.query(buildSqliteIntegritySql(spec)).get();
17537
+ return measuredCondition(spec, { count: Number(row?.count ?? 0), open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null }, "sqlite");
17538
+ } catch (error) {
17539
+ return unverifiedCondition(spec, error instanceof Error ? error.message : String(error));
17540
+ }
17541
+ });
17542
+ return buildIntegrityReport(conditions, now());
17543
+ }
17544
+ var REQUIRED_TABLES;
17545
+ var init_integrity2 = __esm(() => {
17546
+ init_database();
17547
+ init_integrity();
17548
+ REQUIRED_TABLES = { tasks: true, task_lists: true, projects: true };
17549
+ });
17550
+
17230
17551
  // src/lib/doctor.ts
17231
17552
  var exports_doctor = {};
17232
17553
  __export(exports_doctor, {
@@ -17234,7 +17555,7 @@ __export(exports_doctor, {
17234
17555
  });
17235
17556
  import { chmodSync, copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, statSync as statSync3 } from "fs";
17236
17557
  import { basename as basename2, dirname as dirname5, join as join6 } from "path";
17237
- function tableExists(db, table) {
17558
+ function tableExists2(db, table) {
17238
17559
  return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
17239
17560
  }
17240
17561
  function quoteIdent(identifier) {
@@ -17318,7 +17639,7 @@ function findDuplicateIndexes(db) {
17318
17639
  return duplicates;
17319
17640
  }
17320
17641
  function findMissingProjectRoots(db) {
17321
- if (!tableExists(db, "projects"))
17642
+ if (!tableExists2(db, "projects"))
17322
17643
  return 0;
17323
17644
  let missing = 0;
17324
17645
  const rows = db.query("SELECT path FROM projects WHERE path IS NOT NULL AND path != ''").all();
@@ -17333,7 +17654,7 @@ function findMissingProjectRoots(db) {
17333
17654
  return missing;
17334
17655
  }
17335
17656
  function addTaskStateChecks(db, checks) {
17336
- if (!tableExists(db, "tasks"))
17657
+ if (!tableExists2(db, "tasks"))
17337
17658
  return;
17338
17659
  const columns = getTableColumns(db, "tasks");
17339
17660
  const staleCutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
@@ -17405,15 +17726,42 @@ function createBackup(dbPath) {
17405
17726
  function pushRepair(repairs, type, message, applied, count) {
17406
17727
  repairs.push({ type, message, applied, count });
17407
17728
  }
17408
- function summarize2(checks, repairs) {
17729
+ function summarize2(checks, repairs, integrity) {
17409
17730
  return {
17410
17731
  errors: checks.filter((check) => check.severity === "error").length,
17411
17732
  warnings: checks.filter((check) => check.severity === "warn").length,
17412
17733
  infos: checks.filter((check) => check.severity === "info").length,
17413
17734
  repairable: checks.filter((check) => check.repairable).length,
17414
- applied: repairs.filter((repair) => repair.applied).length
17735
+ applied: repairs.filter((repair) => repair.applied).length,
17736
+ integrity_findings: integrity.summary.findings,
17737
+ integrity_rows: integrity.summary.rows,
17738
+ integrity_unverified: integrity.summary.unverified
17415
17739
  };
17416
17740
  }
17741
+ function addIntegrityChecks(integrity, checks) {
17742
+ for (const condition of integrity.conditions) {
17743
+ if (!condition.verified) {
17744
+ addCheck(checks, {
17745
+ severity: "warn",
17746
+ type: `${condition.id}_unverified`,
17747
+ message: condition.message,
17748
+ repairable: false,
17749
+ integrity: true
17750
+ });
17751
+ continue;
17752
+ }
17753
+ if ((condition.count ?? 0) === 0)
17754
+ continue;
17755
+ addCheck(checks, {
17756
+ severity: condition.severity ?? "warn",
17757
+ type: condition.id,
17758
+ message: `${condition.message} \u2014 ${condition.impact}`,
17759
+ count: condition.count ?? undefined,
17760
+ repairable: false,
17761
+ integrity: true
17762
+ });
17763
+ }
17764
+ }
17417
17765
  function deleteOrphans(db, table, where) {
17418
17766
  const before = countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17419
17767
  if (before > 0)
@@ -17442,7 +17790,7 @@ function runTodosDoctor(options = {}) {
17442
17790
  message: `Schema at migration ${migrationCurrent}`
17443
17791
  });
17444
17792
  }
17445
- const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
17793
+ const missingTables = REQUIRED_TABLES2.filter((table) => !tableExists2(db, table));
17446
17794
  if (missingTables.length > 0) {
17447
17795
  addCheck(checks, {
17448
17796
  severity: "error",
@@ -17452,7 +17800,7 @@ function runTodosDoctor(options = {}) {
17452
17800
  repairable: true
17453
17801
  });
17454
17802
  }
17455
- 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;
17803
+ 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;
17456
17804
  if (orphanedParents > 0) {
17457
17805
  addCheck(checks, {
17458
17806
  severity: "error",
@@ -17462,7 +17810,7 @@ function runTodosDoctor(options = {}) {
17462
17810
  repairable: true
17463
17811
  });
17464
17812
  }
17465
- 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;
17813
+ 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;
17466
17814
  if (orphanedDependencies > 0) {
17467
17815
  addCheck(checks, {
17468
17816
  severity: "error",
@@ -17481,7 +17829,7 @@ function runTodosDoctor(options = {}) {
17481
17829
  ];
17482
17830
  let orphanedRows = 0;
17483
17831
  for (const [table, where] of orphanTables) {
17484
- if (!tableExists(db, table))
17832
+ if (!tableExists2(db, table))
17485
17833
  continue;
17486
17834
  orphanedRows += countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17487
17835
  }
@@ -17495,6 +17843,8 @@ function runTodosDoctor(options = {}) {
17495
17843
  });
17496
17844
  }
17497
17845
  addTaskStateChecks(db, checks);
17846
+ const integrity = scanSqliteIntegrity(db);
17847
+ addIntegrityChecks(integrity, checks);
17498
17848
  const corruptJson = findCorruptJsonMetadata(db);
17499
17849
  if (corruptJson.length > 0) {
17500
17850
  addCheck(checks, {
@@ -17553,12 +17903,12 @@ function runTodosDoctor(options = {}) {
17553
17903
  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)");
17554
17904
  pushRepair(repairs, "orphaned_task_parents", "Cleared missing parent references", true, orphanedParents);
17555
17905
  }
17556
- if (orphanedDependencies > 0 && tableExists(db, "task_dependencies")) {
17906
+ if (orphanedDependencies > 0 && tableExists2(db, "task_dependencies")) {
17557
17907
  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)");
17558
17908
  pushRepair(repairs, "orphaned_task_dependencies", "Deleted dependency rows referencing missing tasks", true, count);
17559
17909
  }
17560
17910
  for (const [table, where] of orphanTables) {
17561
- if (!tableExists(db, table))
17911
+ if (!tableExists2(db, table))
17562
17912
  continue;
17563
17913
  const count = deleteOrphans(db, table, where);
17564
17914
  if (count > 0)
@@ -17587,26 +17937,30 @@ function runTodosDoctor(options = {}) {
17587
17937
  }
17588
17938
  }
17589
17939
  }
17590
- const finalChecks = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }).checks : checks;
17591
- const summary = summarize2(finalChecks, repairs);
17940
+ const reread = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }) : undefined;
17941
+ const finalChecks = reread?.checks ?? checks;
17942
+ const finalIntegrity = reread?.integrity ?? integrity;
17943
+ const summary = summarize2(finalChecks, repairs, finalIntegrity);
17592
17944
  return {
17593
- ok: !finalChecks.some((check) => check.severity === "error"),
17945
+ ok: !finalChecks.some((check) => check.severity === "error") && finalIntegrity.summary.ok,
17594
17946
  dry_run: !apply,
17595
17947
  database_path: dbPath,
17596
17948
  migration: { current: getMigrationLevel(db), expected: migrationExpected },
17597
17949
  backup,
17598
17950
  checks: finalChecks,
17599
17951
  repairs,
17952
+ integrity: finalIntegrity,
17600
17953
  summary
17601
17954
  };
17602
17955
  }
17603
- var REQUIRED_TABLES;
17956
+ var REQUIRED_TABLES2;
17604
17957
  var init_doctor = __esm(() => {
17605
17958
  init_database();
17959
+ init_integrity2();
17606
17960
  init_migrations();
17607
17961
  init_schema();
17608
17962
  init_recurrence();
17609
- REQUIRED_TABLES = [
17963
+ REQUIRED_TABLES2 = [
17610
17964
  "_migrations",
17611
17965
  "projects",
17612
17966
  "tasks",
@@ -18998,15 +19352,15 @@ function resolveTaskRefLocal(db, ref) {
18998
19352
  return null;
18999
19353
  if (TASK_UUID_RE.test(raw))
19000
19354
  return getTask(raw, db);
19001
- const prefixRows = db.query("SELECT id FROM tasks WHERE LOWER(id) LIKE ? ESCAPE '\\' LIMIT 2").all(`${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
19355
+ 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}`)}%`);
19002
19356
  if (prefixRows.length > 1) {
19003
- throw new Error(`Task reference is ambiguous: "${ref}"`);
19357
+ throw new TaskReferenceAmbiguousError(ref, prefixRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
19004
19358
  }
19005
19359
  if (prefixRows.length === 1)
19006
19360
  return getTask(prefixRows[0].id, db);
19007
- const shortIdRows = db.query("SELECT id FROM tasks WHERE LOWER(short_id) = ? LIMIT 2").all(raw);
19361
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = ? ORDER BY project_id, id LIMIT 2").all(raw);
19008
19362
  if (shortIdRows.length > 1) {
19009
- throw new Error(`Task reference is ambiguous: "${ref}"`);
19363
+ throw new TaskReferenceAmbiguousError(ref, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
19010
19364
  }
19011
19365
  if (shortIdRows.length === 1)
19012
19366
  return getTask(shortIdRows[0].id, db);
@@ -19155,6 +19509,9 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19155
19509
  exportSnapshot: () => exportSqliteTodosStorageSnapshot(database()),
19156
19510
  importSnapshot: (snapshot) => importSqliteTodosStorageSnapshot(snapshot, database())
19157
19511
  },
19512
+ integrity: {
19513
+ report: () => scanSqliteIntegrity(database())
19514
+ },
19158
19515
  transaction: (fn) => {
19159
19516
  const tx = database().transaction(() => fn(adapter));
19160
19517
  return tx();
@@ -19164,6 +19521,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19164
19521
  }
19165
19522
  var TASK_UUID_RE;
19166
19523
  var init_local_sqlite = __esm(() => {
19524
+ init_types();
19167
19525
  init_search();
19168
19526
  init_tasks();
19169
19527
  init_projects();
@@ -19174,6 +19532,7 @@ var init_local_sqlite = __esm(() => {
19174
19532
  init_audit();
19175
19533
  init_comments();
19176
19534
  init_database();
19535
+ init_integrity2();
19177
19536
  init_sqlite_snapshot();
19178
19537
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19179
19538
  });
@@ -20472,6 +20831,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
20472
20831
  { name: "task_list_id", in: "query", schema: { type: "string" } },
20473
20832
  { name: "assigned_to", in: "query", schema: { type: "string" } },
20474
20833
  { name: "agent_id", in: "query", schema: { type: "string" } },
20834
+ { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
20475
20835
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
20476
20836
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
20477
20837
  ],
@@ -21676,6 +22036,9 @@ async function handleV1Request(req, url, dependencies = {}) {
21676
22036
  ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
21677
22037
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
21678
22038
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
22039
+ ...url.searchParams.get("tags") ? {
22040
+ tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
22041
+ } : {},
21679
22042
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
21680
22043
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
21681
22044
  };
@@ -21957,6 +22320,13 @@ async function handleV1Request(req, url, dependencies = {}) {
21957
22320
  try {
21958
22321
  task = await store.tasks.resolveRef(id);
21959
22322
  } catch (e) {
22323
+ if (e instanceof TaskReferenceAmbiguousError) {
22324
+ return error(409, e.message, {
22325
+ code: TaskReferenceAmbiguousError.code,
22326
+ candidate_project_ids: e.candidateProjectIds,
22327
+ candidate_task_ids: e.candidateTaskIds
22328
+ });
22329
+ }
21960
22330
  const msg = e.message || "";
21961
22331
  if (/ambiguous/i.test(msg))
21962
22332
  return error(409, msg);
@@ -22314,6 +22684,15 @@ async function handleV1Request(req, url, dependencies = {}) {
22314
22684
  ]);
22315
22685
  return json3({ tasks, tasks_all: tasksAll, subtasks: tasksAll - tasks, projects: projects.length });
22316
22686
  }
22687
+ if (resource === "integrity" && !id) {
22688
+ if (method !== "GET")
22689
+ return error(405, `method ${method} not allowed on /v1/integrity`);
22690
+ if (typeof store.integrity?.report !== "function") {
22691
+ return error(501, "referential-integrity reporting is not supported by this storage backend");
22692
+ }
22693
+ const integrity = await store.integrity.report();
22694
+ return json3({ integrity });
22695
+ }
22317
22696
  if (resource === "import") {
22318
22697
  if (method !== "POST")
22319
22698
  return error(405, `method ${method} not allowed on /v1/import`);
@@ -22333,6 +22712,13 @@ async function handleV1Request(req, url, dependencies = {}) {
22333
22712
  }
22334
22713
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
22335
22714
  } catch (e) {
22715
+ if (e instanceof TaskReferenceAmbiguousError) {
22716
+ return error(409, e.message, {
22717
+ code: TaskReferenceAmbiguousError.code,
22718
+ candidate_project_ids: e.candidateProjectIds,
22719
+ candidate_task_ids: e.candidateTaskIds
22720
+ });
22721
+ }
22336
22722
  if (e instanceof LockError)
22337
22723
  return error(409, e.message, { code: LockError.code });
22338
22724
  if (e instanceof ResourceConflictError)
@@ -44660,14 +45046,12 @@ var init_http_client = __esm(() => {
44660
45046
 
44661
45047
  // src/cli/cloud-router.ts
44662
45048
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
45049
+ import { normalizeStorageMode } from "@hasna/contracts/mode";
44663
45050
  import { resolve as resolvePath } from "path";
44664
45051
  function cleanMode(value) {
44665
45052
  const normalized = value?.trim().toLowerCase();
44666
45053
  return normalized || null;
44667
45054
  }
44668
- function modeRole(mode) {
44669
- return mode === "local" ? "local" : "remote";
44670
- }
44671
45055
  function resolveTodosCliStorageMode(env = process.env) {
44672
45056
  for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
44673
45057
  if (env[source] !== undefined && env[source].trim() === "") {
@@ -44680,22 +45064,25 @@ function resolveTodosCliStorageMode(env = process.env) {
44680
45064
  ["HASNA_TODOS_STORAGE_MODE", canonical],
44681
45065
  ["TODOS_STORAGE_MODE", fallback]
44682
45066
  ]) {
44683
- if (value && !VALID_STORAGE_MODES.has(value)) {
44684
- 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");
45067
+ if (value && !(value in TRANSPORT_TOKENS)) {
45068
+ 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");
44685
45069
  }
44686
45070
  }
44687
- if (canonical && fallback && modeRole(canonical) !== modeRole(fallback)) {
45071
+ const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
45072
+ const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
45073
+ if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
44688
45074
  throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
44689
45075
  }
44690
- const mode = canonical ?? fallback ?? "local";
45076
+ const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
44691
45077
  return {
44692
- mode,
44693
- selected: CLOUD_MODES.has(mode),
45078
+ mode: transport,
45079
+ transport,
45080
+ selected: transport === "http",
44694
45081
  source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
44695
45082
  };
44696
45083
  }
44697
- function requestedStorageMode(env) {
44698
- return resolveTodosCliStorageMode(env).mode;
45084
+ function requestedTransport(env) {
45085
+ return resolveTodosCliStorageMode(env).transport;
44699
45086
  }
44700
45087
  function normalizeRemoteAuthorityUrl(value) {
44701
45088
  const trimmed = value?.trim();
@@ -44748,7 +45135,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
44748
45135
  return {
44749
45136
  selected: false,
44750
45137
  ok: true,
44751
- mode: mode || "local",
45138
+ mode,
44752
45139
  api_url_configured: false,
44753
45140
  api_key_configured: false,
44754
45141
  v1_base_url: null,
@@ -44781,13 +45168,27 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
44781
45168
  local_fallback: false
44782
45169
  };
44783
45170
  }
45171
+ function serverStorageMode(normalize = normalizeStorageMode) {
45172
+ const useCache = normalize === normalizeStorageMode;
45173
+ if (useCache && cachedServerMode !== null)
45174
+ return cachedServerMode;
45175
+ for (const candidate of SERVER_MODE_CANDIDATES) {
45176
+ try {
45177
+ normalize(candidate);
45178
+ if (useCache)
45179
+ cachedServerMode = candidate;
45180
+ return candidate;
45181
+ } catch {}
45182
+ }
45183
+ throw new Error(`REMOTE_STORAGE_MODE_UNSUPPORTED: no known server storage mode is accepted by the installed ` + `@hasna/contracts (tried ${SERVER_MODE_CANDIDATES.join(", ")}); the storage-mode enum has changed. ` + `Add the new server token to SERVER_MODE_CANDIDATES in src/cli/cloud-router.ts; ` + `local SQLite fallback is disabled.`);
45184
+ }
44784
45185
  function requireTodosRemoteAuthorityEnv(env) {
44785
45186
  const status = getTodosRemoteAuthorityConfigStatus(env);
44786
45187
  if (!status.ok)
44787
45188
  throw new Error(status.issues[0]);
44788
45189
  return {
44789
45190
  ...env,
44790
- HASNA_TODOS_STORAGE_MODE: "cloud",
45191
+ HASNA_TODOS_STORAGE_MODE: serverStorageMode(),
44791
45192
  HASNA_TODOS_API_URL: status.v1_base_url.replace(/\/v1$/, ""),
44792
45193
  HASNA_TODOS_API_KEY: env.HASNA_TODOS_API_KEY.trim()
44793
45194
  };
@@ -44860,8 +45261,7 @@ async function requiredRemoteRoute(client, route, request) {
44860
45261
  }
44861
45262
  }
44862
45263
  function getTodosCloudClient(env = process.env) {
44863
- const mode = requestedStorageMode(env);
44864
- if (!CLOUD_MODES.has(mode))
45264
+ if (requestedTransport(env) !== "http")
44865
45265
  return null;
44866
45266
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
44867
45267
  fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
@@ -44896,13 +45296,38 @@ function toListQuery(filter = {}) {
44896
45296
  query["assigned_to"] = filter.assigned_to;
44897
45297
  if (filter.agent_id)
44898
45298
  query["agent_id"] = filter.agent_id;
45299
+ if (filter.tags?.length)
45300
+ query["tags"] = filter.tags.join(",");
44899
45301
  if (typeof filter.limit === "number")
44900
45302
  query["limit"] = filter.limit;
44901
45303
  if (typeof filter.offset === "number")
44902
45304
  query["offset"] = filter.offset;
44903
45305
  return query;
44904
45306
  }
45307
+ async function fetchListTagsCapability(client) {
45308
+ const document = await requiredRemoteRoute(client, "/v1/openapi.json", () => client.transport.get("/openapi.json"));
45309
+ if (!document || typeof document !== "object" || Array.isArray(document))
45310
+ return false;
45311
+ const paths = document["paths"];
45312
+ const tasksPath = paths && typeof paths === "object" && !Array.isArray(paths) ? paths["/v1/tasks"] : undefined;
45313
+ const get = tasksPath && typeof tasksPath === "object" && !Array.isArray(tasksPath) ? tasksPath["get"] : undefined;
45314
+ const parameters = get && typeof get === "object" && !Array.isArray(get) ? get["parameters"] : undefined;
45315
+ return Array.isArray(parameters) && parameters.some((parameter) => parameter && typeof parameter === "object" && parameter["name"] === "tags");
45316
+ }
45317
+ async function requireTagsFilterCapability(client) {
45318
+ const authority = remoteAuthorityBase(client);
45319
+ let capability = listTagsCapabilityCache.get(authority);
45320
+ if (!capability) {
45321
+ capability = fetchListTagsCapability(client);
45322
+ listTagsCapabilityCache.set(authority, capability);
45323
+ }
45324
+ if (!await capability) {
45325
+ 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");
45326
+ }
45327
+ }
44905
45328
  async function cloudListTasks(client, filter = {}) {
45329
+ if (filter.tags?.length)
45330
+ await requireTagsFilterCapability(client);
44906
45331
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
44907
45332
  const envelope = res.raw;
44908
45333
  return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
@@ -45010,6 +45435,8 @@ function redactComment3(comment) {
45010
45435
  }
45011
45436
  async function cloudCountTasks(client, filter = {}) {
45012
45437
  const { limit: _drop, offset: _o, ...rest } = filter;
45438
+ if (rest.tags?.length)
45439
+ await requireTagsFilterCapability(client);
45013
45440
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: { ...toListQuery(rest), limit: 1 } }));
45014
45441
  const envelope = res.raw;
45015
45442
  if (typeof envelope?.total === "number")
@@ -45072,15 +45499,24 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
45072
45499
  }
45073
45500
  throw new Error(`Task list not found: "${input}"`);
45074
45501
  }
45075
- var UUID_RE, CLOUD_MODES, VALID_STORAGE_MODES, completionCapabilityCache;
45502
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, listTagsCapabilityCache;
45076
45503
  var init_cloud_router = __esm(() => {
45077
45504
  init_types();
45078
45505
  init_redaction();
45079
45506
  init_http_client();
45080
45507
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45081
- CLOUD_MODES = new Set(["self_hosted", "cloud", "remote", "hybrid"]);
45082
- VALID_STORAGE_MODES = new Set(["local", ...CLOUD_MODES]);
45508
+ TRANSPORT_TOKENS = {
45509
+ sqlite: "sqlite",
45510
+ http: "http",
45511
+ local: "sqlite",
45512
+ remote: "http",
45513
+ self_hosted: "http",
45514
+ cloud: "http",
45515
+ hybrid: "http"
45516
+ };
45083
45517
  completionCapabilityCache = new Map;
45518
+ SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
45519
+ listTagsCapabilityCache = new Map;
45084
45520
  });
45085
45521
 
45086
45522
  // src/mcp/tools/task-crud.ts
@@ -48666,12 +49102,11 @@ ${task.description || ""}`.split(/\r?\n/);
48666
49102
  function normalizeStackLocation(value) {
48667
49103
  return value.replace(/:\d+(?::\d+)?/g, "").replace(/^file:\/\//, "").toLowerCase().trim();
48668
49104
  }
48669
- function fingerprint(task) {
49105
+ function fingerprintContent(task) {
48670
49106
  const body = task.description || "";
48671
49107
  const text = `${task.title}
48672
49108
  ${body}`;
48673
49109
  return {
48674
- task,
48675
49110
  title: normalizeText(task.title),
48676
49111
  body: normalizeText(body),
48677
49112
  text: normalizeText(text),
@@ -48680,6 +49115,9 @@ ${body}`;
48680
49115
  stackKeys: stackKeysFor(task)
48681
49116
  };
48682
49117
  }
49118
+ function fingerprint(task) {
49119
+ return { task, ...fingerprintContent(task) };
49120
+ }
48683
49121
  function intersects(left, right) {
48684
49122
  for (const value of left)
48685
49123
  if (right.has(value))
@@ -49888,7 +50326,7 @@ function tableColumns(db, table) {
49888
50326
  }
49889
50327
  function assertMigratedSchema(db) {
49890
50328
  const issues = [];
49891
- for (const table of REQUIRED_TABLES2) {
50329
+ for (const table of REQUIRED_TABLES3) {
49892
50330
  if (!hasTable(db, table))
49893
50331
  issues.push(`missing table ${table}`);
49894
50332
  }
@@ -49996,7 +50434,7 @@ function createReleaseCompatibilityReport(options = {}) {
49996
50434
  migrations: {
49997
50435
  current_level: MIGRATIONS.length,
49998
50436
  simulated_levels: simulatedLevels,
49999
- checked_tables: REQUIRED_TABLES2,
50437
+ checked_tables: REQUIRED_TABLES3,
50000
50438
  checked_columns: REQUIRED_COLUMNS
50001
50439
  },
50002
50440
  exports: {
@@ -50016,7 +50454,7 @@ function createReleaseCompatibilityReport(options = {}) {
50016
50454
  "todos --help",
50017
50455
  "todos-mcp --help",
50018
50456
  "todos-serve --help",
50019
- "todos doctor"
50457
+ "todos doctor --no-fail-on-findings"
50020
50458
  ],
50021
50459
  smoke_tests: [
50022
50460
  "command -v todos",
@@ -50024,7 +50462,7 @@ function createReleaseCompatibilityReport(options = {}) {
50024
50462
  "command -v todos-serve",
50025
50463
  "todos --version",
50026
50464
  "todos --help",
50027
- "todos doctor"
50465
+ "todos doctor --no-fail-on-findings"
50028
50466
  ],
50029
50467
  rollback: [
50030
50468
  "npm view @hasna/todos versions --json",
@@ -50073,14 +50511,14 @@ function renderReleaseCompatibilityMarkdown(report) {
50073
50511
  return lines.join(`
50074
50512
  `);
50075
50513
  }
50076
- var LOCAL_RELEASE_COMPATIBILITY_SCHEMA_VERSION = 1, EXPECTED_PACKAGE_NAME = "@hasna/todos", EXPECTED_REPOSITORY = "https://github.com/hasna/todos.git", EXPECTED_BINS, EXPECTED_EXPORTS, REQUIRED_SCRIPTS, REQUIRED_TABLES2, REQUIRED_COLUMNS;
50514
+ var LOCAL_RELEASE_COMPATIBILITY_SCHEMA_VERSION = 1, EXPECTED_PACKAGE_NAME = "@hasna/todos", EXPECTED_REPOSITORY = "https://github.com/hasna/todos.git", EXPECTED_BINS, EXPECTED_EXPORTS, REQUIRED_SCRIPTS, REQUIRED_TABLES3, REQUIRED_COLUMNS;
50077
50515
  var init_release_compatibility = __esm(() => {
50078
50516
  init_migrations();
50079
50517
  init_schema();
50080
50518
  EXPECTED_BINS = ["todos", "todos-mcp", "todos-serve"];
50081
50519
  EXPECTED_EXPORTS = [".", "./sdk", "./mcp", "./registry", "./contracts", "./storage"];
50082
50520
  REQUIRED_SCRIPTS = ["build", "test:no-cloud", "verify:release", "prepublishOnly"];
50083
- REQUIRED_TABLES2 = [
50521
+ REQUIRED_TABLES3 = [
50084
50522
  "_migrations",
50085
50523
  "projects",
50086
50524
  "tasks",
@@ -53839,7 +54277,7 @@ function limits(input) {
53839
54277
  verification_limit: clamp(input.verification_limit, DEFAULT_LIMITS.verification_limit, 100),
53840
54278
  run_limit: clamp(input.run_limit, DEFAULT_LIMITS.run_limit, 20),
53841
54279
  dependency_limit: clamp(input.dependency_limit, DEFAULT_LIMITS.dependency_limit, 100),
53842
- plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, 100),
54280
+ plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, MAX_CONTEXT_TASKS),
53843
54281
  max_text_chars: clamp(input.max_text_chars, DEFAULT_LIMITS.max_text_chars, 50000),
53844
54282
  summary_char_limit: clamp(input.summary_char_limit, DEFAULT_LIMITS.summary_char_limit, 4000),
53845
54283
  stale_after_hours: clamp(input.stale_after_hours, DEFAULT_LIMITS.stale_after_hours, 24 * 365)
@@ -53862,6 +54300,13 @@ function taskSummary(task) {
53862
54300
  priority: task.priority
53863
54301
  };
53864
54302
  }
54303
+ function latestPlanTasks(planId, limit, db) {
54304
+ const rows = db.query(`SELECT * FROM tasks
54305
+ WHERE plan_id = ? AND archived_at IS NULL
54306
+ ORDER BY updated_at DESC, created_at DESC, id ASC
54307
+ LIMIT ?`).all(planId, limit);
54308
+ return rows.map(rowToTask);
54309
+ }
53865
54310
  function acceptanceCriteria(task, maxText) {
53866
54311
  const metadata = task.metadata || {};
53867
54312
  const raw = metadata["acceptance_criteria"] ?? metadata["acceptanceCriteria"] ?? metadata["criteria"];
@@ -53959,7 +54404,7 @@ function summarizeSection(pack, section, maxChars) {
53959
54404
  return "No plan was attached.";
53960
54405
  return summarizeStrings([
53961
54406
  `Plan ${pack.plan.name} is ${pack.plan.status}`,
53962
- `${pack.plan.tasks.length} listed plan tasks`,
54407
+ `${pack.plan.tasks.length} of ${pack.plan.total_tasks} plan tasks listed; ${pack.plan.omitted_tasks} omitted`,
53963
54408
  ...pack.plan.tasks.slice(0, 4).map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)
53964
54409
  ], maxChars);
53965
54410
  }
@@ -54109,7 +54554,8 @@ function createAgentContextPack(input, db) {
54109
54554
  const limit = limits(input);
54110
54555
  const project = task.project_id ? getProject(task.project_id, d) : null;
54111
54556
  const plan = task.plan_id ? getPlan(task.plan_id, d) : null;
54112
- const planTasks = task.plan_id ? listTasks2({ plan_id: task.plan_id, limit: limit.plan_task_limit + 1 }, d) : [];
54557
+ const planTasks = task.plan_id ? latestPlanTasks(task.plan_id, limit.plan_task_limit, d) : [];
54558
+ const totalPlanTasks = task.plan_id ? countTasks({ plan_id: task.plan_id }, d) : 0;
54113
54559
  const upstream = getTaskDependencies(task.id, d).map((dep) => taskSummary(getTask(dep.depends_on, d))).filter((item) => Boolean(item));
54114
54560
  const downstream = getTaskDependents(task.id, d).map((dep) => taskSummary(getTask(dep.task_id, d))).filter((item) => Boolean(item));
54115
54561
  const comments = listComments(task.id, d);
@@ -54152,6 +54598,9 @@ function createAgentContextPack(input, db) {
54152
54598
  warnings.push(`${runs.length - selectedRuns.length} older runs omitted`);
54153
54599
  if (fileMap.size > relevantFiles.length)
54154
54600
  warnings.push(`${fileMap.size - relevantFiles.length} relevant files omitted`);
54601
+ if (totalPlanTasks > planTasks.length && plan) {
54602
+ 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>"`);
54603
+ }
54155
54604
  const contextTask = {
54156
54605
  id: task.id,
54157
54606
  short_id: task.short_id,
@@ -54183,8 +54632,9 @@ function createAgentContextPack(input, db) {
54183
54632
  description: truncate2(plan.description, limit.max_text_chars),
54184
54633
  status: plan.status,
54185
54634
  agent_id: plan.agent_id,
54186
- tasks: planTasks.slice(0, limit.plan_task_limit).map(taskSummary).filter((item) => Boolean(item)),
54187
- omitted_tasks: Math.max(0, planTasks.length - limit.plan_task_limit)
54635
+ tasks: planTasks.map(taskSummary).filter((item) => Boolean(item)),
54636
+ total_tasks: totalPlanTasks,
54637
+ omitted_tasks: Math.max(0, totalPlanTasks - planTasks.length)
54188
54638
  } : null,
54189
54639
  acceptance_criteria: acceptanceCriteria(task, limit.max_text_chars),
54190
54640
  dependencies: {
@@ -54294,6 +54744,8 @@ ${pack.task.description}` : null,
54294
54744
  "## Project And Plan",
54295
54745
  pack.project ? `- Project: ${pack.project.name} (${pack.project.path})` : "- Project: none",
54296
54746
  pack.plan ? `- Plan: ${pack.plan.name} (${pack.plan.status})` : "- Plan: none",
54747
+ pack.plan ? `- Plan tasks: ${pack.plan.tasks.length} shown of ${pack.plan.total_tasks} total (${pack.plan.omitted_tasks} omitted)` : null,
54748
+ 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,
54297
54749
  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,
54298
54750
  "",
54299
54751
  "## Acceptance Criteria",
@@ -54374,7 +54826,7 @@ function renderAgentContextPack(pack, format, compact2 = false) {
54374
54826
  return compact2 ? renderAgentContextPackCompactMarkdown(pack) : renderAgentContextPackMarkdown(pack);
54375
54827
  return compact2 ? JSON.stringify(pack) : JSON.stringify(pack, null, 2);
54376
54828
  }
54377
- var DEFAULT_LIMITS, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54829
+ var DEFAULT_LIMITS, MAX_CONTEXT_TASKS = 24, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54378
54830
  var init_context_packs = __esm(() => {
54379
54831
  init_comments();
54380
54832
  init_database();
@@ -54393,7 +54845,7 @@ var init_context_packs = __esm(() => {
54393
54845
  verification_limit: 10,
54394
54846
  run_limit: 3,
54395
54847
  dependency_limit: 12,
54396
- plan_task_limit: 20,
54848
+ plan_task_limit: 24,
54397
54849
  max_text_chars: 6000,
54398
54850
  summary_char_limit: 480,
54399
54851
  stale_after_hours: 72
@@ -79216,7 +79668,7 @@ function parseObject2(value) {
79216
79668
  function normalizeKey(value) {
79217
79669
  return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
79218
79670
  }
79219
- function normalizeFingerprint(value) {
79671
+ function normalizeTaskFindingFingerprint(value) {
79220
79672
  const normalized = normalizeKey(value);
79221
79673
  if (!normalized)
79222
79674
  throw new Error("finding fingerprint is required");
@@ -79325,7 +79777,7 @@ function assertTask(taskId, db) {
79325
79777
  throw new TaskNotFoundError(taskId);
79326
79778
  }
79327
79779
  function nextFinding(input, db) {
79328
- const fingerprint2 = normalizeFingerprint(input.fingerprint);
79780
+ const fingerprint2 = normalizeTaskFindingFingerprint(input.fingerprint);
79329
79781
  const title = redactOptional(input.title, 300);
79330
79782
  if (!title)
79331
79783
  throw new Error("finding title is required");
@@ -79473,7 +79925,7 @@ function resolveMissingTaskFindings(input, db) {
79473
79925
  const timestamp4 = input.resolved_at || now();
79474
79926
  const status = normalizeResolutionStatus(input.status);
79475
79927
  const runId = resolveRunForTask(input.run_id, input.task_id, d);
79476
- const present = new Set(input.fingerprints.map(normalizeFingerprint));
79928
+ const present = new Set(input.fingerprints.map(normalizeTaskFindingFingerprint));
79477
79929
  const warnings = [];
79478
79930
  const conditions = ["task_id = ?", "status = 'open'"];
79479
79931
  const params = [input.task_id];
@@ -91218,6 +91670,15 @@ function formatError2(error3) {
91218
91670
  if (error3 instanceof TaskNotFoundError) {
91219
91671
  return JSON.stringify({ code: TaskNotFoundError.code, message: error3.message, suggestion: TaskNotFoundError.suggestion });
91220
91672
  }
91673
+ if (error3 instanceof TaskReferenceAmbiguousError) {
91674
+ return JSON.stringify({
91675
+ code: TaskReferenceAmbiguousError.code,
91676
+ message: error3.message,
91677
+ candidate_project_ids: error3.candidateProjectIds,
91678
+ candidate_task_ids: error3.candidateTaskIds,
91679
+ suggestion: "Use a full task UUID."
91680
+ });
91681
+ }
91221
91682
  if (error3 instanceof ProjectNotFoundError) {
91222
91683
  return JSON.stringify({ code: ProjectNotFoundError.code, message: error3.message, suggestion: ProjectNotFoundError.suggestion });
91223
91684
  }
@@ -92015,23 +92476,24 @@ Dashboard not found at: ${dashboardDir}`);
92015
92476
  }
92016
92477
  if ((path === "/health" || path === "/ready" || path === "/version") && method === "GET") {
92017
92478
  const { getPackageVersion: getPackageVersion2 } = await Promise.resolve().then(() => (init_package_version(), exports_package_version));
92018
- const { isCloudModeEnabled: isCloudModeEnabled2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
92019
- const mode = isCloudModeEnabled2() ? "remote" : "local";
92479
+ const { isPostgresBackendConfigured: isPostgresBackendConfigured2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
92480
+ const backend = isPostgresBackendConfigured2() ? "postgresql" : "sqlite";
92481
+ const mode = backend === "postgresql" ? "remote" : "local";
92020
92482
  const version2 = getPackageVersion2();
92021
92483
  if (path === "/version") {
92022
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92484
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92023
92485
  }
92024
92486
  if (path === "/ready") {
92025
- if (mode === "remote") {
92487
+ if (backend === "postgresql") {
92026
92488
  try {
92027
92489
  await pingCloud2();
92028
92490
  } catch (e) {
92029
- return Response.json({ status: "unavailable", version: version2, mode, error: e.message }, { status: 503 });
92491
+ return Response.json({ status: "unavailable", version: version2, mode, backend, error: e.message }, { status: 503 });
92030
92492
  }
92031
92493
  }
92032
- return Response.json({ status: "ready", version: version2, mode });
92494
+ return Response.json({ status: "ready", version: version2, mode, backend });
92033
92495
  }
92034
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92496
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92035
92497
  }
92036
92498
  if ((path === "/openapi.json" || path === "/v1/openapi.json") && method === "GET") {
92037
92499
  const { buildV1OpenApiDocument: buildV1OpenApiDocument2 } = await Promise.resolve().then(() => (init_openapi(), exports_openapi));