@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
@@ -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.1",
73
+ version: "0.13.3",
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,13 +120,13 @@ 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",
124
128
  "build:dashboard": "cd dashboard && bun install --frozen-lockfile && bun run build",
125
- typecheck: "tsc --noEmit",
129
+ typecheck: "tsc --noEmit -p tsconfig.typecheck.json",
126
130
  test: "bun test",
127
131
  "test:no-cloud": "bun test src/no-cloud-boundary.test.ts src/local-first.test.ts src/lib/public-release-gate.test.ts",
128
132
  "dev:cli": "bun run src/cli/index.tsx",
@@ -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
@@ -466,7 +493,10 @@ var init_cloud_client = __esm(() => {
466
493
  });
467
494
 
468
495
  // src/types/index.ts
469
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
496
+ function isBlockingDependencyStatus(status) {
497
+ return status !== "completed" && status !== "cancelled";
498
+ }
499
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
470
500
  var init_types = __esm(() => {
471
501
  TASK_STATUSES = [
472
502
  "pending",
@@ -499,6 +529,22 @@ var init_types = __esm(() => {
499
529
  this.name = "TaskNotFoundError";
500
530
  }
501
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
+ };
502
548
  ProjectNotFoundError = class ProjectNotFoundError extends Error {
503
549
  projectId;
504
550
  static code = "PROJECT_NOT_FOUND";
@@ -1083,6 +1129,185 @@ var init_postgres_sync = __esm(() => {
1083
1129
  };
1084
1130
  });
1085
1131
 
1132
+ // src/lib/integrity.ts
1133
+ function resolveIntegritySeverity(spec, measurement) {
1134
+ if (spec.kind === "dangling")
1135
+ return "error";
1136
+ if (spec.escalate_when_open && (measurement.open_count ?? 0) > 0)
1137
+ return "error";
1138
+ return spec.base_severity;
1139
+ }
1140
+ function plural(entity, count) {
1141
+ const [one, many] = ENTITY_LABEL[entity];
1142
+ return count === 1 ? one : many;
1143
+ }
1144
+ function formatIntegrityMessage(spec, measurement) {
1145
+ const noun = plural(spec.entity, measurement.count);
1146
+ const verb = measurement.count === 1 ? ["has", "references"] : ["have", "reference"];
1147
+ const open = measurement.open_count === null || measurement.open_count === 0 ? "" : ` (${measurement.open_count} still open)`;
1148
+ 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}`;
1149
+ }
1150
+ function measuredCondition(spec, measurement, source) {
1151
+ return {
1152
+ id: spec.id,
1153
+ entity: spec.entity,
1154
+ field: spec.field,
1155
+ kind: spec.kind,
1156
+ count: measurement.count,
1157
+ open_count: measurement.open_count,
1158
+ severity: measurement.count > 0 ? resolveIntegritySeverity(spec, measurement) : null,
1159
+ verified: true,
1160
+ source,
1161
+ message: formatIntegrityMessage(spec, measurement),
1162
+ impact: spec.impact
1163
+ };
1164
+ }
1165
+ function unverifiedCondition(spec, reason) {
1166
+ return {
1167
+ id: spec.id,
1168
+ entity: spec.entity,
1169
+ field: spec.field,
1170
+ kind: spec.kind,
1171
+ count: null,
1172
+ open_count: null,
1173
+ severity: null,
1174
+ verified: false,
1175
+ source: "unverified",
1176
+ unverified_reason: reason,
1177
+ message: `${spec.id}: NOT CHECKED \u2014 ${reason}`,
1178
+ impact: spec.impact
1179
+ };
1180
+ }
1181
+ function summarizeIntegrity(conditions) {
1182
+ const measured = conditions.filter((condition) => condition.verified && condition.count !== null);
1183
+ const findings = measured.filter((condition) => (condition.count ?? 0) > 0);
1184
+ const unverified = conditions.length - measured.length;
1185
+ return {
1186
+ ok: unverified === 0 && findings.length === 0,
1187
+ findings: findings.length,
1188
+ rows: findings.reduce((total, condition) => total + (condition.count ?? 0), 0),
1189
+ errors: findings.filter((condition) => condition.severity === "error").length,
1190
+ warnings: findings.filter((condition) => condition.severity === "warn").length,
1191
+ unverified,
1192
+ complete: unverified === 0
1193
+ };
1194
+ }
1195
+ function buildIntegrityReport(conditions, generatedAt) {
1196
+ const measuredSources = [...new Set(conditions.map((condition) => condition.source))].filter((source) => source !== "unverified");
1197
+ return {
1198
+ schema_version: TODOS_INTEGRITY_SCHEMA_VERSION,
1199
+ generated_at: generatedAt,
1200
+ source: measuredSources.length === 0 ? "unverified" : measuredSources.length === 1 ? measuredSources[0] : "remote-derived",
1201
+ conditions,
1202
+ summary: summarizeIntegrity(conditions)
1203
+ };
1204
+ }
1205
+ function sqliteOpenStatusList() {
1206
+ return OPEN_TASK_STATUSES.map((status) => `'${status}'`).join(", ");
1207
+ }
1208
+ function buildSqliteIntegritySql(spec) {
1209
+ const table = SQLITE_TABLE[spec.entity];
1210
+ const target = SQLITE_TABLE[spec.target];
1211
+ const column = `t."${spec.field}"`;
1212
+ 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})`;
1213
+ const openExpr = spec.entity === "task" ? `SUM(CASE WHEN t."status" IN (${sqliteOpenStatusList()}) THEN 1 ELSE 0 END)` : "NULL";
1214
+ return `SELECT COUNT(*) AS count, ${openExpr} AS open_count FROM "${table}" t WHERE ${predicate}`;
1215
+ }
1216
+ function buildPostgresIntegritySql(spec, options) {
1217
+ const params = [options.service, RECORD_TYPE[spec.entity]];
1218
+ const p = (value) => {
1219
+ params.push(value);
1220
+ return `$${params.length}`;
1221
+ };
1222
+ const column = `t.payload->>'${spec.field}'`;
1223
+ 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})`;
1224
+ const openExpr = spec.entity === "task" ? `COUNT(*) FILTER (WHERE t.payload->>'status' IN (${OPEN_TASK_STATUSES.map((status) => p(status)).join(", ")}))::int` : "NULL::int";
1225
+ 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}`;
1226
+ return { sql, params };
1227
+ }
1228
+ var TODOS_INTEGRITY_SCHEMA_VERSION = "todos.integrity.v1", TERMINAL_TASK_STATUSES, OPEN_TASK_STATUSES, INTEGRITY_CONDITIONS, ENTITY_LABEL, SQLITE_TABLE, RECORD_TYPE;
1229
+ var init_integrity = __esm(() => {
1230
+ init_types();
1231
+ TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
1232
+ OPEN_TASK_STATUSES = TASK_STATUSES.filter((status) => !TERMINAL_TASK_STATUSES.includes(status));
1233
+ INTEGRITY_CONDITIONS = [
1234
+ {
1235
+ id: "tasks_without_project",
1236
+ entity: "task",
1237
+ field: "project_id",
1238
+ target: "project",
1239
+ kind: "missing",
1240
+ base_severity: "warn",
1241
+ escalate_when_open: true,
1242
+ impact: "invisible to every project-scoped read (list, status, next, claim)"
1243
+ },
1244
+ {
1245
+ id: "tasks_without_task_list",
1246
+ entity: "task",
1247
+ field: "task_list_id",
1248
+ target: "task_list",
1249
+ kind: "missing",
1250
+ base_severity: "warn",
1251
+ escalate_when_open: true,
1252
+ impact: "invisible to every task-list read \u2014 the list reports zero open work"
1253
+ },
1254
+ {
1255
+ id: "tasks_with_unregistered_project",
1256
+ entity: "task",
1257
+ field: "project_id",
1258
+ target: "project",
1259
+ kind: "dangling",
1260
+ base_severity: "error",
1261
+ escalate_when_open: false,
1262
+ impact: "points at a project id that does not exist; the reference can never resolve"
1263
+ },
1264
+ {
1265
+ id: "tasks_with_unregistered_task_list",
1266
+ entity: "task",
1267
+ field: "task_list_id",
1268
+ target: "task_list",
1269
+ kind: "dangling",
1270
+ base_severity: "error",
1271
+ escalate_when_open: false,
1272
+ impact: "points at a task-list id that does not exist; the reference can never resolve"
1273
+ },
1274
+ {
1275
+ id: "task_lists_without_project",
1276
+ entity: "task_list",
1277
+ field: "project_id",
1278
+ target: "project",
1279
+ kind: "missing",
1280
+ base_severity: "warn",
1281
+ escalate_when_open: false,
1282
+ impact: "unbound list \u2014 unreachable from any project, so its tasks are unroutable"
1283
+ },
1284
+ {
1285
+ id: "task_lists_with_unregistered_project",
1286
+ entity: "task_list",
1287
+ field: "project_id",
1288
+ target: "project",
1289
+ kind: "dangling",
1290
+ base_severity: "error",
1291
+ escalate_when_open: false,
1292
+ impact: "points at a project id that does not exist; the list can never be reached"
1293
+ }
1294
+ ];
1295
+ ENTITY_LABEL = {
1296
+ task: ["task", "tasks"],
1297
+ task_list: ["task list", "task lists"]
1298
+ };
1299
+ SQLITE_TABLE = {
1300
+ task: "tasks",
1301
+ task_list: "task_lists",
1302
+ project: "projects"
1303
+ };
1304
+ RECORD_TYPE = {
1305
+ task: "tasks",
1306
+ task_list: "task_lists",
1307
+ project: "projects"
1308
+ };
1309
+ });
1310
+
1086
1311
  // src/lib/sync-utils.ts
1087
1312
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
1088
1313
  import { join } from "path";
@@ -1201,11 +1426,15 @@ function secretPatterns() {
1201
1426
  return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
1202
1427
  }
1203
1428
  function isRedactionPlaceholderMatch(match) {
1204
- const placeholder = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
1205
1429
  const trimmed = match.trim();
1206
- return new RegExp(`^${placeholder}$`).test(trimmed) || new RegExp(`=\\s*['"]?${placeholder}['"]?$`).test(trimmed);
1430
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(trimmed) || new RegExp(`=\\s*['"]?${REDACTION_PLACEHOLDER}['"]?$`).test(trimmed);
1431
+ }
1432
+ function isRedactionPlaceholderKey(key) {
1433
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(key.trim());
1207
1434
  }
1208
1435
  function isSecretKey(key) {
1436
+ if (isRedactionPlaceholderKey(key))
1437
+ return false;
1209
1438
  if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
1210
1439
  return false;
1211
1440
  if (DEFAULT_SECRET_KEY_PATTERN.test(key))
@@ -1263,7 +1492,7 @@ function upsertSecretSafetyConfig(input) {
1263
1492
  saveConfig({ ...config, secret_safety: next });
1264
1493
  return next;
1265
1494
  }
1266
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS;
1495
+ var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
1267
1496
  var init_redaction = __esm(() => {
1268
1497
  init_config2();
1269
1498
  DEFAULT_SECRET_PATTERNS = [
@@ -1287,6 +1516,7 @@ var init_redaction = __esm(() => {
1287
1516
  "completion_tokens",
1288
1517
  "cost_tokens"
1289
1518
  ]);
1519
+ REDACTION_PLACEHOLDER = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
1290
1520
  });
1291
1521
 
1292
1522
  // src/storage/postgres-adapter.ts
@@ -1417,6 +1647,9 @@ function createPostgresTodosStorageAdapter(options) {
1417
1647
  exportSnapshot: () => exportSnapshot(store),
1418
1648
  importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
1419
1649
  },
1650
+ integrity: {
1651
+ report: () => store.integrityReport()
1652
+ },
1420
1653
  transaction: (fn) => fn(adapter)
1421
1654
  };
1422
1655
  return adapter;
@@ -1523,8 +1756,9 @@ class PostgresJsonRecordStore {
1523
1756
  conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
1524
1757
  if (filter.session_id !== undefined)
1525
1758
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
1526
- if (filter.tags?.length)
1527
- conds.push(`payload->'tags' @> ${p(filter.tags)}::jsonb`);
1759
+ if (filter.tags?.length) {
1760
+ 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)})`);
1761
+ }
1528
1762
  if (filter.has_recurrence !== undefined) {
1529
1763
  conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
1530
1764
  }
@@ -1585,7 +1819,8 @@ class PostgresJsonRecordStore {
1585
1819
  AND object_id COLLATE "C" >= $2 AND object_id COLLATE "C" < $3
1586
1820
  LIMIT 2`, [this.service, raw, upper]);
1587
1821
  if (prefixResult.rows.length > 1) {
1588
- throw new Error(`Task reference is ambiguous: "${ref}"`);
1822
+ const candidates = prefixResult.rows.map((row) => payloadRecord2(row.payload));
1823
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task) => ({ task_id: task.id, project_id: task.project_id })));
1589
1824
  }
1590
1825
  if (prefixResult.rows.length === 1) {
1591
1826
  return payloadRecord2(prefixResult.rows[0].payload);
@@ -1596,7 +1831,8 @@ class PostgresJsonRecordStore {
1596
1831
  AND LOWER(payload->>'short_id') = $2
1597
1832
  LIMIT 2`, [this.service, raw]);
1598
1833
  if (shortIdResult.rows.length > 1) {
1599
- throw new Error(`Task reference is ambiguous: "${ref}"`);
1834
+ const candidates = shortIdResult.rows.map((row) => payloadRecord2(row.payload));
1835
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task) => ({ task_id: task.id, project_id: task.project_id })));
1600
1836
  }
1601
1837
  if (shortIdResult.rows.length === 1) {
1602
1838
  return payloadRecord2(shortIdResult.rows[0].payload);
@@ -1610,6 +1846,24 @@ class PostgresJsonRecordStore {
1610
1846
  const result = await this.options.client.query(sql, params);
1611
1847
  return Number(result.rows[0]?.count ?? 0);
1612
1848
  }
1849
+ async integrityReport() {
1850
+ await this.ensureSchema();
1851
+ const conditions = [];
1852
+ for (const spec of INTEGRITY_CONDITIONS) {
1853
+ const { sql, params } = buildPostgresIntegritySql(spec, { table: this.tableName, service: this.service });
1854
+ try {
1855
+ const result = await this.options.client.query(sql, params);
1856
+ const row = result.rows[0];
1857
+ conditions.push(measuredCondition(spec, {
1858
+ count: Number(row?.count ?? 0),
1859
+ open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null
1860
+ }, "postgres"));
1861
+ } catch (error) {
1862
+ conditions.push(unverifiedCondition(spec, error instanceof Error ? error.message : String(error)));
1863
+ }
1864
+ }
1865
+ return buildIntegrityReport(conditions, new Date().toISOString());
1866
+ }
1613
1867
  async listTombstones() {
1614
1868
  await this.ensureSchema();
1615
1869
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
@@ -2085,7 +2339,7 @@ async function startTask(id, agentId, store) {
2085
2339
  if (task.status !== "pending" && task.status !== "in_progress") {
2086
2340
  throw new Error(`Task is ${task.status} and cannot be started by ${agentId}`);
2087
2341
  }
2088
- return patchTask(task, {
2342
+ const started = await patchTask(task, {
2089
2343
  status: "in_progress",
2090
2344
  assigned_to: task.assigned_to ?? agentId,
2091
2345
  agent_id: task.agent_id ?? agentId,
@@ -2093,6 +2347,8 @@ async function startTask(id, agentId, store) {
2093
2347
  locked_at: new Date().toISOString(),
2094
2348
  started_at: task.started_at ?? new Date().toISOString()
2095
2349
  }, store);
2350
+ await logTaskChange(task.id, "start", "status", task.status, "in_progress", agentId, store);
2351
+ return started;
2096
2352
  }
2097
2353
  async function completeTask(id, agentId, options, store) {
2098
2354
  const task = await store.completeTask(id, agentId, options);
@@ -2209,9 +2465,11 @@ async function removeDependency(taskId, dependsOn, store) {
2209
2465
  }
2210
2466
  async function listDependencies(taskId, store) {
2211
2467
  const edges = await store.list("dependencies");
2468
+ const incoming = edges.filter((edge) => edge.depends_on === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on }));
2212
2469
  return {
2213
2470
  dependencies: edges.filter((edge) => edge.task_id === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on })),
2214
- blocked_by: edges.filter((edge) => edge.depends_on === taskId).map((edge) => ({ task_id: edge.task_id, depends_on: edge.depends_on }))
2471
+ blocks: incoming,
2472
+ blocked_by: incoming
2215
2473
  };
2216
2474
  }
2217
2475
  async function addVerification(input, store, context) {
@@ -2757,6 +3015,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
2757
3015
  var init_postgres_adapter = __esm(() => {
2758
3016
  init_types();
2759
3017
  init_postgres_sync();
3018
+ init_integrity();
2760
3019
  init_redaction();
2761
3020
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
2762
3021
  });
@@ -4755,6 +5014,7 @@ __export(exports_cloud, {
4755
5014
  resolveCloudDatabaseUrl: () => resolveCloudDatabaseUrl,
4756
5015
  pingCloud: () => pingCloud,
4757
5016
  normalizeCloudPayloads: () => normalizeCloudPayloads,
5017
+ isPostgresBackendConfigured: () => isPostgresBackendConfigured,
4758
5018
  isCloudModeEnabled: () => isCloudModeEnabled,
4759
5019
  getCloudVerifier: () => getCloudVerifier,
4760
5020
  getCloudStorageAdapter: () => getCloudStorageAdapter,
@@ -4777,9 +5037,12 @@ function resolveCloudDatabaseUrl(env = process.env) {
4777
5037
  function resolveSigningSecret(env = process.env) {
4778
5038
  return env.HASNA_TODOS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
4779
5039
  }
4780
- function isCloudModeEnabled(env = process.env) {
5040
+ function isPostgresBackendConfigured(env = process.env) {
4781
5041
  return Boolean(resolveCloudDatabaseUrl(env));
4782
5042
  }
5043
+ function isCloudModeEnabled(env = process.env) {
5044
+ return isPostgresBackendConfigured(env);
5045
+ }
4783
5046
  function getClient() {
4784
5047
  if (cachedClient)
4785
5048
  return cachedClient;
@@ -8607,6 +8870,30 @@ function findGitRoot(startDir) {
8607
8870
  }
8608
8871
  return null;
8609
8872
  }
8873
+ function getGlobalDbPath() {
8874
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
8875
+ return join3(home, ".hasna", "todos", "todos.db");
8876
+ }
8877
+ function hasExplicitProjectArg(args = process.argv.slice(2)) {
8878
+ return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
8879
+ }
8880
+ function getCliCommand(args = process.argv.slice(2)) {
8881
+ const globalOptionsWithValues = new Set(["--project", "--agent", "--session"]);
8882
+ for (let index = 0;index < args.length; index += 1) {
8883
+ const arg = args[index];
8884
+ if (globalOptionsWithValues.has(arg)) {
8885
+ index += 1;
8886
+ continue;
8887
+ }
8888
+ if (arg.startsWith("-"))
8889
+ continue;
8890
+ return arg;
8891
+ }
8892
+ return null;
8893
+ }
8894
+ function canCreateScopedProjectDb(args = process.argv.slice(2)) {
8895
+ return getCliCommand(args) === "project-bootstrap" && !args.some((arg) => arg === "--dry-run" || arg.startsWith("--dry-run="));
8896
+ }
8610
8897
  function getDbPath() {
8611
8898
  if (process.env["HASNA_TODOS_DB_PATH"]) {
8612
8899
  return process.env["HASNA_TODOS_DB_PATH"];
@@ -8614,18 +8901,19 @@ function getDbPath() {
8614
8901
  if (process.env["TODOS_DB_PATH"]) {
8615
8902
  return process.env["TODOS_DB_PATH"];
8616
8903
  }
8904
+ if (hasExplicitProjectArg())
8905
+ return getGlobalDbPath();
8617
8906
  const cwd = process.cwd();
8618
8907
  const nearest = findNearestProjectDb(cwd);
8619
8908
  if (nearest)
8620
8909
  return nearest;
8621
8910
  if (process.env["TODOS_DB_SCOPE"] === "project") {
8622
8911
  const gitRoot = findGitRoot(cwd);
8623
- if (gitRoot) {
8912
+ if (gitRoot && canCreateScopedProjectDb()) {
8624
8913
  return join3(gitRoot, ".hasna", "todos", "todos.db");
8625
8914
  }
8626
8915
  }
8627
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
8628
- return join3(home, ".hasna", "todos", "todos.db");
8916
+ return getGlobalDbPath();
8629
8917
  }
8630
8918
  function getDatabasePath() {
8631
8919
  return getDbPath();
@@ -8641,8 +8929,8 @@ function ensureDir2(filePath) {
8641
8929
  function openDatabase(path) {
8642
8930
  ensureDir2(path);
8643
8931
  const db = new Database(path);
8644
- db.run("PRAGMA journal_mode = WAL");
8645
8932
  db.run("PRAGMA busy_timeout = 5000");
8933
+ db.run("PRAGMA journal_mode = WAL");
8646
8934
  db.run("PRAGMA foreign_keys = ON");
8647
8935
  runMigrations(db);
8648
8936
  ensureAgentIdentitySchema(db);
@@ -8728,10 +9016,13 @@ function resolvePartialId(db, table, partialId) {
8728
9016
  return null;
8729
9017
  }
8730
9018
  if (table === "tasks") {
8731
- const shortIdRows = db.query("SELECT id FROM tasks WHERE short_id = ?").all(partialId);
9019
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = LOWER(?) ORDER BY project_id, id").all(partialId);
8732
9020
  if (shortIdRows.length === 1) {
8733
9021
  return shortIdRows[0].id;
8734
9022
  }
9023
+ if (shortIdRows.length > 1) {
9024
+ throw new TaskReferenceAmbiguousError(partialId, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
9025
+ }
8735
9026
  }
8736
9027
  if (table === "task_lists") {
8737
9028
  const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
@@ -8895,7 +9186,7 @@ function authNotConfiguredMessage(host) {
8895
9186
  `);
8896
9187
  }
8897
9188
  function resolveAuthPosture(input) {
8898
- const hosted = input.hosted ?? isCloudModeEnabled();
9189
+ const hosted = input.hosted ?? isPostgresBackendConfigured();
8899
9190
  const hasCredentialSource = Boolean(input.apiKey) || input.hasGeneratedKeys;
8900
9191
  if (hasCredentialSource) {
8901
9192
  return {
@@ -11040,7 +11331,7 @@ function scanTextForSecrets(text, options = {}) {
11040
11331
  };
11041
11332
  }
11042
11333
  function redactText(text, options = {}) {
11043
- const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER;
11334
+ const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER2;
11044
11335
  let out = text;
11045
11336
  for (const { pattern, allowlist_ok } of DEFAULT_PATTERNS) {
11046
11337
  out = out.replace(new RegExp(pattern.source, pattern.flags), (match) => {
@@ -11067,7 +11358,7 @@ function redactExportRecord(record) {
11067
11358
  }
11068
11359
  return base;
11069
11360
  }
11070
- var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
11361
+ var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER2 = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
11071
11362
  var init_secret_redaction = __esm(() => {
11072
11363
  init_redaction();
11073
11364
  SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
@@ -13059,10 +13350,11 @@ function getTaskWithRelations(id, db) {
13059
13350
  JOIN task_dependencies td ON td.depends_on = t.id
13060
13351
  WHERE td.task_id = ?`).all(id);
13061
13352
  const dependencies = depRows.map(rowToTask);
13062
- const blockedByRows = d.query(`SELECT t.* FROM tasks t
13353
+ const blocked_by = dependencies.filter((dep) => isBlockingDependencyStatus(dep.status));
13354
+ const blocksRows = d.query(`SELECT t.* FROM tasks t
13063
13355
  JOIN task_dependencies td ON td.task_id = t.id
13064
13356
  WHERE td.depends_on = ?`).all(id);
13065
- const blocked_by = blockedByRows.map(rowToTask);
13357
+ const blocks = blocksRows.map(rowToTask);
13066
13358
  const comments = d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(id);
13067
13359
  const parent = task.parent_id ? getTask(task.parent_id, d) : null;
13068
13360
  const checklist = getChecklist(id, d);
@@ -13071,6 +13363,7 @@ function getTaskWithRelations(id, db) {
13071
13363
  subtasks,
13072
13364
  dependencies,
13073
13365
  blocked_by,
13366
+ blocks,
13074
13367
  comments,
13075
13368
  parent,
13076
13369
  checklist
@@ -17215,6 +17508,32 @@ var init_headless_boundaries = __esm(() => {
17215
17508
  LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
17216
17509
  });
17217
17510
 
17511
+ // src/db/integrity.ts
17512
+ function tableExists(db, table) {
17513
+ return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
17514
+ }
17515
+ function scanSqliteIntegrity(db = getDatabase()) {
17516
+ const missing = Object.keys(REQUIRED_TABLES).filter((table) => !tableExists(db, table));
17517
+ const conditions = INTEGRITY_CONDITIONS.map((spec) => {
17518
+ if (missing.length > 0) {
17519
+ return unverifiedCondition(spec, `local schema is missing table(s): ${missing.join(", ")}`);
17520
+ }
17521
+ try {
17522
+ const row = db.query(buildSqliteIntegritySql(spec)).get();
17523
+ return measuredCondition(spec, { count: Number(row?.count ?? 0), open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null }, "sqlite");
17524
+ } catch (error) {
17525
+ return unverifiedCondition(spec, error instanceof Error ? error.message : String(error));
17526
+ }
17527
+ });
17528
+ return buildIntegrityReport(conditions, now());
17529
+ }
17530
+ var REQUIRED_TABLES;
17531
+ var init_integrity2 = __esm(() => {
17532
+ init_database();
17533
+ init_integrity();
17534
+ REQUIRED_TABLES = { tasks: true, task_lists: true, projects: true };
17535
+ });
17536
+
17218
17537
  // src/lib/doctor.ts
17219
17538
  var exports_doctor = {};
17220
17539
  __export(exports_doctor, {
@@ -17222,7 +17541,7 @@ __export(exports_doctor, {
17222
17541
  });
17223
17542
  import { chmodSync, copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, statSync as statSync3 } from "fs";
17224
17543
  import { basename as basename2, dirname as dirname5, join as join6 } from "path";
17225
- function tableExists(db, table) {
17544
+ function tableExists2(db, table) {
17226
17545
  return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
17227
17546
  }
17228
17547
  function quoteIdent(identifier) {
@@ -17306,7 +17625,7 @@ function findDuplicateIndexes(db) {
17306
17625
  return duplicates;
17307
17626
  }
17308
17627
  function findMissingProjectRoots(db) {
17309
- if (!tableExists(db, "projects"))
17628
+ if (!tableExists2(db, "projects"))
17310
17629
  return 0;
17311
17630
  let missing = 0;
17312
17631
  const rows = db.query("SELECT path FROM projects WHERE path IS NOT NULL AND path != ''").all();
@@ -17321,7 +17640,7 @@ function findMissingProjectRoots(db) {
17321
17640
  return missing;
17322
17641
  }
17323
17642
  function addTaskStateChecks(db, checks) {
17324
- if (!tableExists(db, "tasks"))
17643
+ if (!tableExists2(db, "tasks"))
17325
17644
  return;
17326
17645
  const columns = getTableColumns(db, "tasks");
17327
17646
  const staleCutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
@@ -17393,15 +17712,42 @@ function createBackup(dbPath) {
17393
17712
  function pushRepair(repairs, type, message, applied, count) {
17394
17713
  repairs.push({ type, message, applied, count });
17395
17714
  }
17396
- function summarize2(checks, repairs) {
17715
+ function summarize2(checks, repairs, integrity) {
17397
17716
  return {
17398
17717
  errors: checks.filter((check) => check.severity === "error").length,
17399
17718
  warnings: checks.filter((check) => check.severity === "warn").length,
17400
17719
  infos: checks.filter((check) => check.severity === "info").length,
17401
17720
  repairable: checks.filter((check) => check.repairable).length,
17402
- applied: repairs.filter((repair) => repair.applied).length
17721
+ applied: repairs.filter((repair) => repair.applied).length,
17722
+ integrity_findings: integrity.summary.findings,
17723
+ integrity_rows: integrity.summary.rows,
17724
+ integrity_unverified: integrity.summary.unverified
17403
17725
  };
17404
17726
  }
17727
+ function addIntegrityChecks(integrity, checks) {
17728
+ for (const condition of integrity.conditions) {
17729
+ if (!condition.verified) {
17730
+ addCheck(checks, {
17731
+ severity: "warn",
17732
+ type: `${condition.id}_unverified`,
17733
+ message: condition.message,
17734
+ repairable: false,
17735
+ integrity: true
17736
+ });
17737
+ continue;
17738
+ }
17739
+ if ((condition.count ?? 0) === 0)
17740
+ continue;
17741
+ addCheck(checks, {
17742
+ severity: condition.severity ?? "warn",
17743
+ type: condition.id,
17744
+ message: `${condition.message} \u2014 ${condition.impact}`,
17745
+ count: condition.count ?? undefined,
17746
+ repairable: false,
17747
+ integrity: true
17748
+ });
17749
+ }
17750
+ }
17405
17751
  function deleteOrphans(db, table, where) {
17406
17752
  const before = countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17407
17753
  if (before > 0)
@@ -17430,7 +17776,7 @@ function runTodosDoctor(options = {}) {
17430
17776
  message: `Schema at migration ${migrationCurrent}`
17431
17777
  });
17432
17778
  }
17433
- const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
17779
+ const missingTables = REQUIRED_TABLES2.filter((table) => !tableExists2(db, table));
17434
17780
  if (missingTables.length > 0) {
17435
17781
  addCheck(checks, {
17436
17782
  severity: "error",
@@ -17440,7 +17786,7 @@ function runTodosDoctor(options = {}) {
17440
17786
  repairable: true
17441
17787
  });
17442
17788
  }
17443
- 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;
17789
+ 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;
17444
17790
  if (orphanedParents > 0) {
17445
17791
  addCheck(checks, {
17446
17792
  severity: "error",
@@ -17450,7 +17796,7 @@ function runTodosDoctor(options = {}) {
17450
17796
  repairable: true
17451
17797
  });
17452
17798
  }
17453
- 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;
17799
+ 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;
17454
17800
  if (orphanedDependencies > 0) {
17455
17801
  addCheck(checks, {
17456
17802
  severity: "error",
@@ -17469,7 +17815,7 @@ function runTodosDoctor(options = {}) {
17469
17815
  ];
17470
17816
  let orphanedRows = 0;
17471
17817
  for (const [table, where] of orphanTables) {
17472
- if (!tableExists(db, table))
17818
+ if (!tableExists2(db, table))
17473
17819
  continue;
17474
17820
  orphanedRows += countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17475
17821
  }
@@ -17483,6 +17829,8 @@ function runTodosDoctor(options = {}) {
17483
17829
  });
17484
17830
  }
17485
17831
  addTaskStateChecks(db, checks);
17832
+ const integrity = scanSqliteIntegrity(db);
17833
+ addIntegrityChecks(integrity, checks);
17486
17834
  const corruptJson = findCorruptJsonMetadata(db);
17487
17835
  if (corruptJson.length > 0) {
17488
17836
  addCheck(checks, {
@@ -17541,12 +17889,12 @@ function runTodosDoctor(options = {}) {
17541
17889
  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)");
17542
17890
  pushRepair(repairs, "orphaned_task_parents", "Cleared missing parent references", true, orphanedParents);
17543
17891
  }
17544
- if (orphanedDependencies > 0 && tableExists(db, "task_dependencies")) {
17892
+ if (orphanedDependencies > 0 && tableExists2(db, "task_dependencies")) {
17545
17893
  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)");
17546
17894
  pushRepair(repairs, "orphaned_task_dependencies", "Deleted dependency rows referencing missing tasks", true, count);
17547
17895
  }
17548
17896
  for (const [table, where] of orphanTables) {
17549
- if (!tableExists(db, table))
17897
+ if (!tableExists2(db, table))
17550
17898
  continue;
17551
17899
  const count = deleteOrphans(db, table, where);
17552
17900
  if (count > 0)
@@ -17575,26 +17923,30 @@ function runTodosDoctor(options = {}) {
17575
17923
  }
17576
17924
  }
17577
17925
  }
17578
- const finalChecks = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }).checks : checks;
17579
- const summary = summarize2(finalChecks, repairs);
17926
+ const reread = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }) : undefined;
17927
+ const finalChecks = reread?.checks ?? checks;
17928
+ const finalIntegrity = reread?.integrity ?? integrity;
17929
+ const summary = summarize2(finalChecks, repairs, finalIntegrity);
17580
17930
  return {
17581
- ok: !finalChecks.some((check) => check.severity === "error"),
17931
+ ok: !finalChecks.some((check) => check.severity === "error") && finalIntegrity.summary.ok,
17582
17932
  dry_run: !apply,
17583
17933
  database_path: dbPath,
17584
17934
  migration: { current: getMigrationLevel(db), expected: migrationExpected },
17585
17935
  backup,
17586
17936
  checks: finalChecks,
17587
17937
  repairs,
17938
+ integrity: finalIntegrity,
17588
17939
  summary
17589
17940
  };
17590
17941
  }
17591
- var REQUIRED_TABLES;
17942
+ var REQUIRED_TABLES2;
17592
17943
  var init_doctor = __esm(() => {
17593
17944
  init_database();
17945
+ init_integrity2();
17594
17946
  init_migrations();
17595
17947
  init_schema();
17596
17948
  init_recurrence();
17597
- REQUIRED_TABLES = [
17949
+ REQUIRED_TABLES2 = [
17598
17950
  "_migrations",
17599
17951
  "projects",
17600
17952
  "tasks",
@@ -18986,15 +19338,15 @@ function resolveTaskRefLocal(db, ref) {
18986
19338
  return null;
18987
19339
  if (TASK_UUID_RE.test(raw))
18988
19340
  return getTask(raw, db);
18989
- const prefixRows = db.query("SELECT id FROM tasks WHERE LOWER(id) LIKE ? ESCAPE '\\' LIMIT 2").all(`${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
19341
+ 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}`)}%`);
18990
19342
  if (prefixRows.length > 1) {
18991
- throw new Error(`Task reference is ambiguous: "${ref}"`);
19343
+ throw new TaskReferenceAmbiguousError(ref, prefixRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
18992
19344
  }
18993
19345
  if (prefixRows.length === 1)
18994
19346
  return getTask(prefixRows[0].id, db);
18995
- const shortIdRows = db.query("SELECT id FROM tasks WHERE LOWER(short_id) = ? LIMIT 2").all(raw);
19347
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = ? ORDER BY project_id, id LIMIT 2").all(raw);
18996
19348
  if (shortIdRows.length > 1) {
18997
- throw new Error(`Task reference is ambiguous: "${ref}"`);
19349
+ throw new TaskReferenceAmbiguousError(ref, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
18998
19350
  }
18999
19351
  if (shortIdRows.length === 1)
19000
19352
  return getTask(shortIdRows[0].id, db);
@@ -19143,6 +19495,9 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19143
19495
  exportSnapshot: () => exportSqliteTodosStorageSnapshot(database()),
19144
19496
  importSnapshot: (snapshot) => importSqliteTodosStorageSnapshot(snapshot, database())
19145
19497
  },
19498
+ integrity: {
19499
+ report: () => scanSqliteIntegrity(database())
19500
+ },
19146
19501
  transaction: (fn) => {
19147
19502
  const tx = database().transaction(() => fn(adapter));
19148
19503
  return tx();
@@ -19152,6 +19507,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19152
19507
  }
19153
19508
  var TASK_UUID_RE;
19154
19509
  var init_local_sqlite = __esm(() => {
19510
+ init_types();
19155
19511
  init_search();
19156
19512
  init_tasks();
19157
19513
  init_projects();
@@ -19162,6 +19518,7 @@ var init_local_sqlite = __esm(() => {
19162
19518
  init_audit();
19163
19519
  init_comments();
19164
19520
  init_database();
19521
+ init_integrity2();
19165
19522
  init_sqlite_snapshot();
19166
19523
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19167
19524
  });
@@ -20460,6 +20817,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
20460
20817
  { name: "task_list_id", in: "query", schema: { type: "string" } },
20461
20818
  { name: "assigned_to", in: "query", schema: { type: "string" } },
20462
20819
  { name: "agent_id", in: "query", schema: { type: "string" } },
20820
+ { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
20463
20821
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
20464
20822
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
20465
20823
  ],
@@ -21664,6 +22022,9 @@ async function handleV1Request(req, url, dependencies = {}) {
21664
22022
  ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
21665
22023
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
21666
22024
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
22025
+ ...url.searchParams.get("tags") ? {
22026
+ tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
22027
+ } : {},
21667
22028
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
21668
22029
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
21669
22030
  };
@@ -21945,6 +22306,13 @@ async function handleV1Request(req, url, dependencies = {}) {
21945
22306
  try {
21946
22307
  task = await store.tasks.resolveRef(id);
21947
22308
  } catch (e) {
22309
+ if (e instanceof TaskReferenceAmbiguousError) {
22310
+ return error(409, e.message, {
22311
+ code: TaskReferenceAmbiguousError.code,
22312
+ candidate_project_ids: e.candidateProjectIds,
22313
+ candidate_task_ids: e.candidateTaskIds
22314
+ });
22315
+ }
21948
22316
  const msg = e.message || "";
21949
22317
  if (/ambiguous/i.test(msg))
21950
22318
  return error(409, msg);
@@ -22302,6 +22670,15 @@ async function handleV1Request(req, url, dependencies = {}) {
22302
22670
  ]);
22303
22671
  return json3({ tasks, tasks_all: tasksAll, subtasks: tasksAll - tasks, projects: projects.length });
22304
22672
  }
22673
+ if (resource === "integrity" && !id) {
22674
+ if (method !== "GET")
22675
+ return error(405, `method ${method} not allowed on /v1/integrity`);
22676
+ if (typeof store.integrity?.report !== "function") {
22677
+ return error(501, "referential-integrity reporting is not supported by this storage backend");
22678
+ }
22679
+ const integrity = await store.integrity.report();
22680
+ return json3({ integrity });
22681
+ }
22305
22682
  if (resource === "import") {
22306
22683
  if (method !== "POST")
22307
22684
  return error(405, `method ${method} not allowed on /v1/import`);
@@ -22321,6 +22698,13 @@ async function handleV1Request(req, url, dependencies = {}) {
22321
22698
  }
22322
22699
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
22323
22700
  } catch (e) {
22701
+ if (e instanceof TaskReferenceAmbiguousError) {
22702
+ return error(409, e.message, {
22703
+ code: TaskReferenceAmbiguousError.code,
22704
+ candidate_project_ids: e.candidateProjectIds,
22705
+ candidate_task_ids: e.candidateTaskIds
22706
+ });
22707
+ }
22324
22708
  if (e instanceof LockError)
22325
22709
  return error(409, e.message, { code: LockError.code });
22326
22710
  if (e instanceof ResourceConflictError)
@@ -44653,9 +45037,6 @@ function cleanMode(value) {
44653
45037
  const normalized = value?.trim().toLowerCase();
44654
45038
  return normalized || null;
44655
45039
  }
44656
- function modeRole(mode) {
44657
- return mode === "local" ? "local" : "remote";
44658
- }
44659
45040
  function resolveTodosCliStorageMode(env = process.env) {
44660
45041
  for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
44661
45042
  if (env[source] !== undefined && env[source].trim() === "") {
@@ -44668,22 +45049,25 @@ function resolveTodosCliStorageMode(env = process.env) {
44668
45049
  ["HASNA_TODOS_STORAGE_MODE", canonical],
44669
45050
  ["TODOS_STORAGE_MODE", fallback]
44670
45051
  ]) {
44671
- if (value && !VALID_STORAGE_MODES.has(value)) {
44672
- 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");
45052
+ if (value && !(value in TRANSPORT_TOKENS)) {
45053
+ 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");
44673
45054
  }
44674
45055
  }
44675
- if (canonical && fallback && modeRole(canonical) !== modeRole(fallback)) {
45056
+ const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
45057
+ const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
45058
+ if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
44676
45059
  throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
44677
45060
  }
44678
- const mode = canonical ?? fallback ?? "local";
45061
+ const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
44679
45062
  return {
44680
- mode,
44681
- selected: CLOUD_MODES.has(mode),
45063
+ mode: transport,
45064
+ transport,
45065
+ selected: transport === "http",
44682
45066
  source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
44683
45067
  };
44684
45068
  }
44685
- function requestedStorageMode(env) {
44686
- return resolveTodosCliStorageMode(env).mode;
45069
+ function requestedTransport(env) {
45070
+ return resolveTodosCliStorageMode(env).transport;
44687
45071
  }
44688
45072
  function normalizeRemoteAuthorityUrl(value) {
44689
45073
  const trimmed = value?.trim();
@@ -44736,7 +45120,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
44736
45120
  return {
44737
45121
  selected: false,
44738
45122
  ok: true,
44739
- mode: mode || "local",
45123
+ mode,
44740
45124
  api_url_configured: false,
44741
45125
  api_key_configured: false,
44742
45126
  v1_base_url: null,
@@ -44848,8 +45232,7 @@ async function requiredRemoteRoute(client, route, request) {
44848
45232
  }
44849
45233
  }
44850
45234
  function getTodosCloudClient(env = process.env) {
44851
- const mode = requestedStorageMode(env);
44852
- if (!CLOUD_MODES.has(mode))
45235
+ if (requestedTransport(env) !== "http")
44853
45236
  return null;
44854
45237
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
44855
45238
  fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
@@ -44884,13 +45267,38 @@ function toListQuery(filter = {}) {
44884
45267
  query["assigned_to"] = filter.assigned_to;
44885
45268
  if (filter.agent_id)
44886
45269
  query["agent_id"] = filter.agent_id;
45270
+ if (filter.tags?.length)
45271
+ query["tags"] = filter.tags.join(",");
44887
45272
  if (typeof filter.limit === "number")
44888
45273
  query["limit"] = filter.limit;
44889
45274
  if (typeof filter.offset === "number")
44890
45275
  query["offset"] = filter.offset;
44891
45276
  return query;
44892
45277
  }
45278
+ async function fetchListTagsCapability(client) {
45279
+ const document = await requiredRemoteRoute(client, "/v1/openapi.json", () => client.transport.get("/openapi.json"));
45280
+ if (!document || typeof document !== "object" || Array.isArray(document))
45281
+ return false;
45282
+ const paths = document["paths"];
45283
+ const tasksPath = paths && typeof paths === "object" && !Array.isArray(paths) ? paths["/v1/tasks"] : undefined;
45284
+ const get = tasksPath && typeof tasksPath === "object" && !Array.isArray(tasksPath) ? tasksPath["get"] : undefined;
45285
+ const parameters = get && typeof get === "object" && !Array.isArray(get) ? get["parameters"] : undefined;
45286
+ return Array.isArray(parameters) && parameters.some((parameter) => parameter && typeof parameter === "object" && parameter["name"] === "tags");
45287
+ }
45288
+ async function requireTagsFilterCapability(client) {
45289
+ const authority = remoteAuthorityBase(client);
45290
+ let capability = listTagsCapabilityCache.get(authority);
45291
+ if (!capability) {
45292
+ capability = fetchListTagsCapability(client);
45293
+ listTagsCapabilityCache.set(authority, capability);
45294
+ }
45295
+ if (!await capability) {
45296
+ 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");
45297
+ }
45298
+ }
44893
45299
  async function cloudListTasks(client, filter = {}) {
45300
+ if (filter.tags?.length)
45301
+ await requireTagsFilterCapability(client);
44894
45302
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
44895
45303
  const envelope = res.raw;
44896
45304
  return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
@@ -44998,6 +45406,8 @@ function redactComment3(comment) {
44998
45406
  }
44999
45407
  async function cloudCountTasks(client, filter = {}) {
45000
45408
  const { limit: _drop, offset: _o, ...rest } = filter;
45409
+ if (rest.tags?.length)
45410
+ await requireTagsFilterCapability(client);
45001
45411
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: { ...toListQuery(rest), limit: 1 } }));
45002
45412
  const envelope = res.raw;
45003
45413
  if (typeof envelope?.total === "number")
@@ -45060,14 +45470,23 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
45060
45470
  }
45061
45471
  throw new Error(`Task list not found: "${input}"`);
45062
45472
  }
45063
- var UUID_RE, CLOUD_MODES, VALID_STORAGE_MODES, completionCapabilityCache;
45473
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, listTagsCapabilityCache;
45064
45474
  var init_cloud_router = __esm(() => {
45475
+ init_types();
45065
45476
  init_redaction();
45066
45477
  init_http_client();
45067
45478
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
45068
- CLOUD_MODES = new Set(["self_hosted", "cloud", "remote", "hybrid"]);
45069
- VALID_STORAGE_MODES = new Set(["local", ...CLOUD_MODES]);
45479
+ TRANSPORT_TOKENS = {
45480
+ sqlite: "sqlite",
45481
+ http: "http",
45482
+ local: "sqlite",
45483
+ remote: "http",
45484
+ self_hosted: "http",
45485
+ cloud: "http",
45486
+ hybrid: "http"
45487
+ };
45070
45488
  completionCapabilityCache = new Map;
45489
+ listTagsCapabilityCache = new Map;
45071
45490
  });
45072
45491
 
45073
45492
  // src/mcp/tools/task-crud.ts
@@ -48653,12 +49072,11 @@ ${task.description || ""}`.split(/\r?\n/);
48653
49072
  function normalizeStackLocation(value) {
48654
49073
  return value.replace(/:\d+(?::\d+)?/g, "").replace(/^file:\/\//, "").toLowerCase().trim();
48655
49074
  }
48656
- function fingerprint(task) {
49075
+ function fingerprintContent(task) {
48657
49076
  const body = task.description || "";
48658
49077
  const text = `${task.title}
48659
49078
  ${body}`;
48660
49079
  return {
48661
- task,
48662
49080
  title: normalizeText(task.title),
48663
49081
  body: normalizeText(body),
48664
49082
  text: normalizeText(text),
@@ -48667,6 +49085,9 @@ ${body}`;
48667
49085
  stackKeys: stackKeysFor(task)
48668
49086
  };
48669
49087
  }
49088
+ function fingerprint(task) {
49089
+ return { task, ...fingerprintContent(task) };
49090
+ }
48670
49091
  function intersects(left, right) {
48671
49092
  for (const value of left)
48672
49093
  if (right.has(value))
@@ -49875,7 +50296,7 @@ function tableColumns(db, table) {
49875
50296
  }
49876
50297
  function assertMigratedSchema(db) {
49877
50298
  const issues = [];
49878
- for (const table of REQUIRED_TABLES2) {
50299
+ for (const table of REQUIRED_TABLES3) {
49879
50300
  if (!hasTable(db, table))
49880
50301
  issues.push(`missing table ${table}`);
49881
50302
  }
@@ -49983,7 +50404,7 @@ function createReleaseCompatibilityReport(options = {}) {
49983
50404
  migrations: {
49984
50405
  current_level: MIGRATIONS.length,
49985
50406
  simulated_levels: simulatedLevels,
49986
- checked_tables: REQUIRED_TABLES2,
50407
+ checked_tables: REQUIRED_TABLES3,
49987
50408
  checked_columns: REQUIRED_COLUMNS
49988
50409
  },
49989
50410
  exports: {
@@ -50003,7 +50424,7 @@ function createReleaseCompatibilityReport(options = {}) {
50003
50424
  "todos --help",
50004
50425
  "todos-mcp --help",
50005
50426
  "todos-serve --help",
50006
- "todos doctor"
50427
+ "todos doctor --no-fail-on-findings"
50007
50428
  ],
50008
50429
  smoke_tests: [
50009
50430
  "command -v todos",
@@ -50011,7 +50432,7 @@ function createReleaseCompatibilityReport(options = {}) {
50011
50432
  "command -v todos-serve",
50012
50433
  "todos --version",
50013
50434
  "todos --help",
50014
- "todos doctor"
50435
+ "todos doctor --no-fail-on-findings"
50015
50436
  ],
50016
50437
  rollback: [
50017
50438
  "npm view @hasna/todos versions --json",
@@ -50060,14 +50481,14 @@ function renderReleaseCompatibilityMarkdown(report) {
50060
50481
  return lines.join(`
50061
50482
  `);
50062
50483
  }
50063
- 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;
50484
+ 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;
50064
50485
  var init_release_compatibility = __esm(() => {
50065
50486
  init_migrations();
50066
50487
  init_schema();
50067
50488
  EXPECTED_BINS = ["todos", "todos-mcp", "todos-serve"];
50068
50489
  EXPECTED_EXPORTS = [".", "./sdk", "./mcp", "./registry", "./contracts", "./storage"];
50069
50490
  REQUIRED_SCRIPTS = ["build", "test:no-cloud", "verify:release", "prepublishOnly"];
50070
- REQUIRED_TABLES2 = [
50491
+ REQUIRED_TABLES3 = [
50071
50492
  "_migrations",
50072
50493
  "projects",
50073
50494
  "tasks",
@@ -53826,7 +54247,7 @@ function limits(input) {
53826
54247
  verification_limit: clamp(input.verification_limit, DEFAULT_LIMITS.verification_limit, 100),
53827
54248
  run_limit: clamp(input.run_limit, DEFAULT_LIMITS.run_limit, 20),
53828
54249
  dependency_limit: clamp(input.dependency_limit, DEFAULT_LIMITS.dependency_limit, 100),
53829
- plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, 100),
54250
+ plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, MAX_CONTEXT_TASKS),
53830
54251
  max_text_chars: clamp(input.max_text_chars, DEFAULT_LIMITS.max_text_chars, 50000),
53831
54252
  summary_char_limit: clamp(input.summary_char_limit, DEFAULT_LIMITS.summary_char_limit, 4000),
53832
54253
  stale_after_hours: clamp(input.stale_after_hours, DEFAULT_LIMITS.stale_after_hours, 24 * 365)
@@ -53849,6 +54270,13 @@ function taskSummary(task) {
53849
54270
  priority: task.priority
53850
54271
  };
53851
54272
  }
54273
+ function latestPlanTasks(planId, limit, db) {
54274
+ const rows = db.query(`SELECT * FROM tasks
54275
+ WHERE plan_id = ? AND archived_at IS NULL
54276
+ ORDER BY updated_at DESC, created_at DESC, id ASC
54277
+ LIMIT ?`).all(planId, limit);
54278
+ return rows.map(rowToTask);
54279
+ }
53852
54280
  function acceptanceCriteria(task, maxText) {
53853
54281
  const metadata = task.metadata || {};
53854
54282
  const raw = metadata["acceptance_criteria"] ?? metadata["acceptanceCriteria"] ?? metadata["criteria"];
@@ -53946,7 +54374,7 @@ function summarizeSection(pack, section, maxChars) {
53946
54374
  return "No plan was attached.";
53947
54375
  return summarizeStrings([
53948
54376
  `Plan ${pack.plan.name} is ${pack.plan.status}`,
53949
- `${pack.plan.tasks.length} listed plan tasks`,
54377
+ `${pack.plan.tasks.length} of ${pack.plan.total_tasks} plan tasks listed; ${pack.plan.omitted_tasks} omitted`,
53950
54378
  ...pack.plan.tasks.slice(0, 4).map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)
53951
54379
  ], maxChars);
53952
54380
  }
@@ -54096,7 +54524,8 @@ function createAgentContextPack(input, db) {
54096
54524
  const limit = limits(input);
54097
54525
  const project = task.project_id ? getProject(task.project_id, d) : null;
54098
54526
  const plan = task.plan_id ? getPlan(task.plan_id, d) : null;
54099
- const planTasks = task.plan_id ? listTasks2({ plan_id: task.plan_id, limit: limit.plan_task_limit + 1 }, d) : [];
54527
+ const planTasks = task.plan_id ? latestPlanTasks(task.plan_id, limit.plan_task_limit, d) : [];
54528
+ const totalPlanTasks = task.plan_id ? countTasks({ plan_id: task.plan_id }, d) : 0;
54100
54529
  const upstream = getTaskDependencies(task.id, d).map((dep) => taskSummary(getTask(dep.depends_on, d))).filter((item) => Boolean(item));
54101
54530
  const downstream = getTaskDependents(task.id, d).map((dep) => taskSummary(getTask(dep.task_id, d))).filter((item) => Boolean(item));
54102
54531
  const comments = listComments(task.id, d);
@@ -54139,6 +54568,9 @@ function createAgentContextPack(input, db) {
54139
54568
  warnings.push(`${runs.length - selectedRuns.length} older runs omitted`);
54140
54569
  if (fileMap.size > relevantFiles.length)
54141
54570
  warnings.push(`${fileMap.size - relevantFiles.length} relevant files omitted`);
54571
+ if (totalPlanTasks > planTasks.length && plan) {
54572
+ 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>"`);
54573
+ }
54142
54574
  const contextTask = {
54143
54575
  id: task.id,
54144
54576
  short_id: task.short_id,
@@ -54170,8 +54602,9 @@ function createAgentContextPack(input, db) {
54170
54602
  description: truncate2(plan.description, limit.max_text_chars),
54171
54603
  status: plan.status,
54172
54604
  agent_id: plan.agent_id,
54173
- tasks: planTasks.slice(0, limit.plan_task_limit).map(taskSummary).filter((item) => Boolean(item)),
54174
- omitted_tasks: Math.max(0, planTasks.length - limit.plan_task_limit)
54605
+ tasks: planTasks.map(taskSummary).filter((item) => Boolean(item)),
54606
+ total_tasks: totalPlanTasks,
54607
+ omitted_tasks: Math.max(0, totalPlanTasks - planTasks.length)
54175
54608
  } : null,
54176
54609
  acceptance_criteria: acceptanceCriteria(task, limit.max_text_chars),
54177
54610
  dependencies: {
@@ -54281,6 +54714,8 @@ ${pack.task.description}` : null,
54281
54714
  "## Project And Plan",
54282
54715
  pack.project ? `- Project: ${pack.project.name} (${pack.project.path})` : "- Project: none",
54283
54716
  pack.plan ? `- Plan: ${pack.plan.name} (${pack.plan.status})` : "- Plan: none",
54717
+ pack.plan ? `- Plan tasks: ${pack.plan.tasks.length} shown of ${pack.plan.total_tasks} total (${pack.plan.omitted_tasks} omitted)` : null,
54718
+ 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,
54284
54719
  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,
54285
54720
  "",
54286
54721
  "## Acceptance Criteria",
@@ -54361,7 +54796,7 @@ function renderAgentContextPack(pack, format, compact2 = false) {
54361
54796
  return compact2 ? renderAgentContextPackCompactMarkdown(pack) : renderAgentContextPackMarkdown(pack);
54362
54797
  return compact2 ? JSON.stringify(pack) : JSON.stringify(pack, null, 2);
54363
54798
  }
54364
- var DEFAULT_LIMITS, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54799
+ var DEFAULT_LIMITS, MAX_CONTEXT_TASKS = 24, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54365
54800
  var init_context_packs = __esm(() => {
54366
54801
  init_comments();
54367
54802
  init_database();
@@ -54380,7 +54815,7 @@ var init_context_packs = __esm(() => {
54380
54815
  verification_limit: 10,
54381
54816
  run_limit: 3,
54382
54817
  dependency_limit: 12,
54383
- plan_task_limit: 20,
54818
+ plan_task_limit: 24,
54384
54819
  max_text_chars: 6000,
54385
54820
  summary_char_limit: 480,
54386
54821
  stale_after_hours: 72
@@ -79203,7 +79638,7 @@ function parseObject2(value) {
79203
79638
  function normalizeKey(value) {
79204
79639
  return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
79205
79640
  }
79206
- function normalizeFingerprint(value) {
79641
+ function normalizeTaskFindingFingerprint(value) {
79207
79642
  const normalized = normalizeKey(value);
79208
79643
  if (!normalized)
79209
79644
  throw new Error("finding fingerprint is required");
@@ -79312,7 +79747,7 @@ function assertTask(taskId, db) {
79312
79747
  throw new TaskNotFoundError(taskId);
79313
79748
  }
79314
79749
  function nextFinding(input, db) {
79315
- const fingerprint2 = normalizeFingerprint(input.fingerprint);
79750
+ const fingerprint2 = normalizeTaskFindingFingerprint(input.fingerprint);
79316
79751
  const title = redactOptional(input.title, 300);
79317
79752
  if (!title)
79318
79753
  throw new Error("finding title is required");
@@ -79460,7 +79895,7 @@ function resolveMissingTaskFindings(input, db) {
79460
79895
  const timestamp4 = input.resolved_at || now();
79461
79896
  const status = normalizeResolutionStatus(input.status);
79462
79897
  const runId = resolveRunForTask(input.run_id, input.task_id, d);
79463
- const present = new Set(input.fingerprints.map(normalizeFingerprint));
79898
+ const present = new Set(input.fingerprints.map(normalizeTaskFindingFingerprint));
79464
79899
  const warnings = [];
79465
79900
  const conditions = ["task_id = ?", "status = 'open'"];
79466
79901
  const params = [input.task_id];
@@ -91205,6 +91640,15 @@ function formatError2(error3) {
91205
91640
  if (error3 instanceof TaskNotFoundError) {
91206
91641
  return JSON.stringify({ code: TaskNotFoundError.code, message: error3.message, suggestion: TaskNotFoundError.suggestion });
91207
91642
  }
91643
+ if (error3 instanceof TaskReferenceAmbiguousError) {
91644
+ return JSON.stringify({
91645
+ code: TaskReferenceAmbiguousError.code,
91646
+ message: error3.message,
91647
+ candidate_project_ids: error3.candidateProjectIds,
91648
+ candidate_task_ids: error3.candidateTaskIds,
91649
+ suggestion: "Use a full task UUID."
91650
+ });
91651
+ }
91208
91652
  if (error3 instanceof ProjectNotFoundError) {
91209
91653
  return JSON.stringify({ code: ProjectNotFoundError.code, message: error3.message, suggestion: ProjectNotFoundError.suggestion });
91210
91654
  }
@@ -91977,7 +92421,7 @@ Dashboard not found at: ${dashboardDir}`);
91977
92421
  const path = url.pathname;
91978
92422
  const method = req.method;
91979
92423
  const reqOrigin = req.headers.get("origin") || undefined;
91980
- const corsHeaders = reqOrigin && (reqOrigin === `http://localhost:${port}` || reqOrigin === "http://localhost:0") ? {
92424
+ const corsHeaders = reqOrigin && reqOrigin === `http://localhost:${ctx.port}` ? {
91981
92425
  "Access-Control-Allow-Origin": reqOrigin,
91982
92426
  "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
91983
92427
  "Access-Control-Allow-Headers": "Content-Type, X-API-Key, Authorization",
@@ -92002,23 +92446,24 @@ Dashboard not found at: ${dashboardDir}`);
92002
92446
  }
92003
92447
  if ((path === "/health" || path === "/ready" || path === "/version") && method === "GET") {
92004
92448
  const { getPackageVersion: getPackageVersion2 } = await Promise.resolve().then(() => (init_package_version(), exports_package_version));
92005
- const { isCloudModeEnabled: isCloudModeEnabled2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
92006
- const mode = isCloudModeEnabled2() ? "remote" : "local";
92449
+ const { isPostgresBackendConfigured: isPostgresBackendConfigured2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
92450
+ const backend = isPostgresBackendConfigured2() ? "postgresql" : "sqlite";
92451
+ const mode = backend === "postgresql" ? "remote" : "local";
92007
92452
  const version2 = getPackageVersion2();
92008
92453
  if (path === "/version") {
92009
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92454
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92010
92455
  }
92011
92456
  if (path === "/ready") {
92012
- if (mode === "remote") {
92457
+ if (backend === "postgresql") {
92013
92458
  try {
92014
92459
  await pingCloud2();
92015
92460
  } catch (e) {
92016
- return Response.json({ status: "unavailable", version: version2, mode, error: e.message }, { status: 503 });
92461
+ return Response.json({ status: "unavailable", version: version2, mode, backend, error: e.message }, { status: 503 });
92017
92462
  }
92018
92463
  }
92019
- return Response.json({ status: "ready", version: version2, mode });
92464
+ return Response.json({ status: "ready", version: version2, mode, backend });
92020
92465
  }
92021
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92466
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92022
92467
  }
92023
92468
  if ((path === "/openapi.json" || path === "/v1/openapi.json") && method === "GET") {
92024
92469
  const { buildV1OpenApiDocument: buildV1OpenApiDocument2 } = await Promise.resolve().then(() => (init_openapi(), exports_openapi));
@@ -92265,7 +92710,9 @@ Dashboard not found at: ${dashboardDir}`);
92265
92710
  };
92266
92711
  process.on("SIGINT", shutdown);
92267
92712
  process.on("SIGTERM", shutdown);
92268
- const serverUrl = `http://localhost:${port}`;
92713
+ const boundPort = server.port ?? port;
92714
+ ctx.port = boundPort;
92715
+ const serverUrl = `http://localhost:${boundPort}`;
92269
92716
  console.log(`Todos Dashboard running at ${serverUrl}`);
92270
92717
  if (shouldOpen) {
92271
92718
  try {
@@ -92306,7 +92753,39 @@ var init_serve = __esm(() => {
92306
92753
 
92307
92754
  // src/server/index.ts
92308
92755
  init_package_version();
92756
+
92757
+ // src/server/port.ts
92309
92758
  var DEFAULT_PORT = 19427;
92759
+ function coercePort(raw) {
92760
+ if (raw === undefined)
92761
+ return;
92762
+ const trimmed = raw.trim();
92763
+ if (!/^\d+$/.test(trimmed))
92764
+ return;
92765
+ if (trimmed.length > 1 && trimmed.startsWith("0"))
92766
+ return;
92767
+ const parsed = Number.parseInt(trimmed, 10);
92768
+ if (!Number.isInteger(parsed) || parsed > 65535)
92769
+ return;
92770
+ return parsed;
92771
+ }
92772
+ function refuseInvalidPort(source, raw) {
92773
+ console.error(`Invalid ${source}: ${JSON.stringify(raw)} is not a port.
92774
+ ` + `Use an integer from 0 to 65535, where 0 asks the kernel for a free port.`);
92775
+ process.exit(1);
92776
+ }
92777
+ async function findFreePort(start) {
92778
+ for (let port = start;port < start + 100; port++) {
92779
+ try {
92780
+ const server = Bun.serve({ port, fetch: () => new Response("") });
92781
+ server.stop(true);
92782
+ return port;
92783
+ } catch {}
92784
+ }
92785
+ return start;
92786
+ }
92787
+
92788
+ // src/server/index.ts
92310
92789
  function hasVersionFlag2() {
92311
92790
  return process.argv.includes("--version") || process.argv.includes("-V");
92312
92791
  }
@@ -92341,11 +92820,8 @@ Environment:
92341
92820
  function parsePort() {
92342
92821
  const portArg = process.argv.find((a) => a === "--port" || a.startsWith("--port="));
92343
92822
  if (portArg) {
92344
- if (portArg.includes("=")) {
92345
- return parseInt(portArg.split("=")[1], 10) || DEFAULT_PORT;
92346
- }
92347
- const idx = process.argv.indexOf(portArg);
92348
- return parseInt(process.argv[idx + 1], 10) || DEFAULT_PORT;
92823
+ const raw = portArg.includes("=") ? portArg.split("=")[1] : process.argv[process.argv.indexOf(portArg) + 1];
92824
+ return coercePort(raw) ?? refuseInvalidPort("--port", raw ?? "");
92349
92825
  }
92350
92826
  return DEFAULT_PORT;
92351
92827
  }
@@ -92358,16 +92834,6 @@ function parseStringArg(name) {
92358
92834
  const idx = process.argv.indexOf(arg);
92359
92835
  return process.argv[idx + 1] || undefined;
92360
92836
  }
92361
- async function findFreePort(start) {
92362
- for (let port = start;port < start + 100; port++) {
92363
- try {
92364
- const server = Bun.serve({ port, fetch: () => new Response("") });
92365
- server.stop(true);
92366
- return port;
92367
- } catch {}
92368
- }
92369
- return start;
92370
- }
92371
92837
  async function runMigrate() {
92372
92838
  const {
92373
92839
  ensureCloudSchema: ensureCloudSchema2,
@@ -92463,9 +92929,10 @@ async function main2() {
92463
92929
  return;
92464
92930
  }
92465
92931
  const explicitPortArg = process.argv.some((a) => a === "--port" || a.startsWith("--port="));
92466
- const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
92932
+ const rawEnvPort = process.env.PORT?.trim();
92933
+ const envPort = rawEnvPort ? coercePort(rawEnvPort) ?? refuseInvalidPort("PORT", rawEnvPort) : undefined;
92467
92934
  const requestedPort = explicitPortArg ? parsePort() : envPort ?? parsePort();
92468
- const port = envPort || explicitPortArg ? requestedPort : await findFreePort(requestedPort);
92935
+ const port = envPort !== undefined || explicitPortArg ? requestedPort : await findFreePort(requestedPort);
92469
92936
  if (port !== requestedPort) {
92470
92937
  console.log(`Port ${requestedPort} in use, using ${port}`);
92471
92938
  }