@hasna/todos 0.13.2 → 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 (69) hide show
  1. package/README.md +78 -14
  2. package/dist/cli/cloud-router.d.ts +57 -7
  3. package/dist/cli/cloud-router.d.ts.map +1 -1
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/doctor-integrity.d.ts +106 -0
  6. package/dist/cli/doctor-integrity.d.ts.map +1 -0
  7. package/dist/cli/helpers.d.ts +2 -2
  8. package/dist/cli/index.js +1461 -778
  9. package/dist/cli/stage-a.d.ts.map +1 -1
  10. package/dist/contracts.js +118 -30
  11. package/dist/db/database.d.ts.map +1 -1
  12. package/dist/db/findings.d.ts +1 -0
  13. package/dist/db/findings.d.ts.map +1 -1
  14. package/dist/db/integrity.d.ts +20 -0
  15. package/dist/db/integrity.d.ts.map +1 -0
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +562 -132
  19. package/dist/lib/context-packs.d.ts +1 -0
  20. package/dist/lib/context-packs.d.ts.map +1 -1
  21. package/dist/lib/dependency-graph.d.ts +1 -1
  22. package/dist/lib/doctor.d.ts +20 -0
  23. package/dist/lib/doctor.d.ts.map +1 -1
  24. package/dist/lib/integrity.d.ts +215 -0
  25. package/dist/lib/integrity.d.ts.map +1 -0
  26. package/dist/lib/native-storage-status.d.ts +2 -2
  27. package/dist/lib/native-storage-status.d.ts.map +1 -1
  28. package/dist/lib/release-compatibility.d.ts.map +1 -1
  29. package/dist/lib/task-dedupe.d.ts +15 -0
  30. package/dist/lib/task-dedupe.d.ts.map +1 -1
  31. package/dist/mcp/index.d.ts.map +1 -1
  32. package/dist/mcp/index.js +517 -85
  33. package/dist/mcp.js +7 -3
  34. package/dist/registry.d.ts +1 -1
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +125 -30
  37. package/dist/release-provenance.json +5 -5
  38. package/dist/sdk/types.d.ts +42 -0
  39. package/dist/sdk/types.d.ts.map +1 -1
  40. package/dist/sdk/v1.generated.d.ts +1 -0
  41. package/dist/sdk/v1.generated.d.ts.map +1 -1
  42. package/dist/server/cloud.d.ts +8 -1
  43. package/dist/server/cloud.d.ts.map +1 -1
  44. package/dist/server/index.js +521 -89
  45. package/dist/server/openapi.d.ts +12 -0
  46. package/dist/server/openapi.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/server/v1.d.ts.map +1 -1
  49. package/dist/storage/config.d.ts +23 -5
  50. package/dist/storage/config.d.ts.map +1 -1
  51. package/dist/storage/factory.d.ts +9 -3
  52. package/dist/storage/factory.d.ts.map +1 -1
  53. package/dist/storage/hybrid.d.ts +7 -0
  54. package/dist/storage/hybrid.d.ts.map +1 -1
  55. package/dist/storage/index.d.ts +3 -3
  56. package/dist/storage/index.d.ts.map +1 -1
  57. package/dist/storage/interfaces.d.ts +29 -8
  58. package/dist/storage/interfaces.d.ts.map +1 -1
  59. package/dist/storage/local-sqlite.d.ts.map +1 -1
  60. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  61. package/dist/storage.d.ts +3 -3
  62. package/dist/storage.d.ts.map +1 -1
  63. package/dist/storage.js +601 -250
  64. package/dist/testing.d.ts +79 -0
  65. package/dist/testing.d.ts.map +1 -0
  66. package/dist/testing.js +107 -0
  67. package/dist/types/index.d.ts +12 -1
  68. package/dist/types/index.d.ts.map +1 -1
  69. 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.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,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";
@@ -1086,6 +1129,185 @@ var init_postgres_sync = __esm(() => {
1086
1129
  };
1087
1130
  });
1088
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
+
1089
1311
  // src/lib/sync-utils.ts
1090
1312
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
1091
1313
  import { join } from "path";
@@ -1425,6 +1647,9 @@ function createPostgresTodosStorageAdapter(options) {
1425
1647
  exportSnapshot: () => exportSnapshot(store),
1426
1648
  importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
1427
1649
  },
1650
+ integrity: {
1651
+ report: () => store.integrityReport()
1652
+ },
1428
1653
  transaction: (fn) => fn(adapter)
1429
1654
  };
1430
1655
  return adapter;
@@ -1531,8 +1756,9 @@ class PostgresJsonRecordStore {
1531
1756
  conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
1532
1757
  if (filter.session_id !== undefined)
1533
1758
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
1534
- if (filter.tags?.length)
1535
- 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
+ }
1536
1762
  if (filter.has_recurrence !== undefined) {
1537
1763
  conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
1538
1764
  }
@@ -1593,7 +1819,8 @@ class PostgresJsonRecordStore {
1593
1819
  AND object_id COLLATE "C" >= $2 AND object_id COLLATE "C" < $3
1594
1820
  LIMIT 2`, [this.service, raw, upper]);
1595
1821
  if (prefixResult.rows.length > 1) {
1596
- 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 })));
1597
1824
  }
1598
1825
  if (prefixResult.rows.length === 1) {
1599
1826
  return payloadRecord2(prefixResult.rows[0].payload);
@@ -1604,7 +1831,8 @@ class PostgresJsonRecordStore {
1604
1831
  AND LOWER(payload->>'short_id') = $2
1605
1832
  LIMIT 2`, [this.service, raw]);
1606
1833
  if (shortIdResult.rows.length > 1) {
1607
- 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 })));
1608
1836
  }
1609
1837
  if (shortIdResult.rows.length === 1) {
1610
1838
  return payloadRecord2(shortIdResult.rows[0].payload);
@@ -1618,6 +1846,24 @@ class PostgresJsonRecordStore {
1618
1846
  const result = await this.options.client.query(sql, params);
1619
1847
  return Number(result.rows[0]?.count ?? 0);
1620
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
+ }
1621
1867
  async listTombstones() {
1622
1868
  await this.ensureSchema();
1623
1869
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
@@ -2093,7 +2339,7 @@ async function startTask(id, agentId, store) {
2093
2339
  if (task.status !== "pending" && task.status !== "in_progress") {
2094
2340
  throw new Error(`Task is ${task.status} and cannot be started by ${agentId}`);
2095
2341
  }
2096
- return patchTask(task, {
2342
+ const started = await patchTask(task, {
2097
2343
  status: "in_progress",
2098
2344
  assigned_to: task.assigned_to ?? agentId,
2099
2345
  agent_id: task.agent_id ?? agentId,
@@ -2101,6 +2347,8 @@ async function startTask(id, agentId, store) {
2101
2347
  locked_at: new Date().toISOString(),
2102
2348
  started_at: task.started_at ?? new Date().toISOString()
2103
2349
  }, store);
2350
+ await logTaskChange(task.id, "start", "status", task.status, "in_progress", agentId, store);
2351
+ return started;
2104
2352
  }
2105
2353
  async function completeTask(id, agentId, options, store) {
2106
2354
  const task = await store.completeTask(id, agentId, options);
@@ -2767,6 +3015,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
2767
3015
  var init_postgres_adapter = __esm(() => {
2768
3016
  init_types();
2769
3017
  init_postgres_sync();
3018
+ init_integrity();
2770
3019
  init_redaction();
2771
3020
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
2772
3021
  });
@@ -4765,6 +5014,7 @@ __export(exports_cloud, {
4765
5014
  resolveCloudDatabaseUrl: () => resolveCloudDatabaseUrl,
4766
5015
  pingCloud: () => pingCloud,
4767
5016
  normalizeCloudPayloads: () => normalizeCloudPayloads,
5017
+ isPostgresBackendConfigured: () => isPostgresBackendConfigured,
4768
5018
  isCloudModeEnabled: () => isCloudModeEnabled,
4769
5019
  getCloudVerifier: () => getCloudVerifier,
4770
5020
  getCloudStorageAdapter: () => getCloudStorageAdapter,
@@ -4787,9 +5037,12 @@ function resolveCloudDatabaseUrl(env = process.env) {
4787
5037
  function resolveSigningSecret(env = process.env) {
4788
5038
  return env.HASNA_TODOS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
4789
5039
  }
4790
- function isCloudModeEnabled(env = process.env) {
5040
+ function isPostgresBackendConfigured(env = process.env) {
4791
5041
  return Boolean(resolveCloudDatabaseUrl(env));
4792
5042
  }
5043
+ function isCloudModeEnabled(env = process.env) {
5044
+ return isPostgresBackendConfigured(env);
5045
+ }
4793
5046
  function getClient() {
4794
5047
  if (cachedClient)
4795
5048
  return cachedClient;
@@ -8617,6 +8870,30 @@ function findGitRoot(startDir) {
8617
8870
  }
8618
8871
  return null;
8619
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
+ }
8620
8897
  function getDbPath() {
8621
8898
  if (process.env["HASNA_TODOS_DB_PATH"]) {
8622
8899
  return process.env["HASNA_TODOS_DB_PATH"];
@@ -8624,18 +8901,19 @@ function getDbPath() {
8624
8901
  if (process.env["TODOS_DB_PATH"]) {
8625
8902
  return process.env["TODOS_DB_PATH"];
8626
8903
  }
8904
+ if (hasExplicitProjectArg())
8905
+ return getGlobalDbPath();
8627
8906
  const cwd = process.cwd();
8628
8907
  const nearest = findNearestProjectDb(cwd);
8629
8908
  if (nearest)
8630
8909
  return nearest;
8631
8910
  if (process.env["TODOS_DB_SCOPE"] === "project") {
8632
8911
  const gitRoot = findGitRoot(cwd);
8633
- if (gitRoot) {
8912
+ if (gitRoot && canCreateScopedProjectDb()) {
8634
8913
  return join3(gitRoot, ".hasna", "todos", "todos.db");
8635
8914
  }
8636
8915
  }
8637
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
8638
- return join3(home, ".hasna", "todos", "todos.db");
8916
+ return getGlobalDbPath();
8639
8917
  }
8640
8918
  function getDatabasePath() {
8641
8919
  return getDbPath();
@@ -8738,10 +9016,13 @@ function resolvePartialId(db, table, partialId) {
8738
9016
  return null;
8739
9017
  }
8740
9018
  if (table === "tasks") {
8741
- 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);
8742
9020
  if (shortIdRows.length === 1) {
8743
9021
  return shortIdRows[0].id;
8744
9022
  }
9023
+ if (shortIdRows.length > 1) {
9024
+ throw new TaskReferenceAmbiguousError(partialId, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
9025
+ }
8745
9026
  }
8746
9027
  if (table === "task_lists") {
8747
9028
  const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
@@ -8905,7 +9186,7 @@ function authNotConfiguredMessage(host) {
8905
9186
  `);
8906
9187
  }
8907
9188
  function resolveAuthPosture(input) {
8908
- const hosted = input.hosted ?? isCloudModeEnabled();
9189
+ const hosted = input.hosted ?? isPostgresBackendConfigured();
8909
9190
  const hasCredentialSource = Boolean(input.apiKey) || input.hasGeneratedKeys;
8910
9191
  if (hasCredentialSource) {
8911
9192
  return {
@@ -17227,6 +17508,32 @@ var init_headless_boundaries = __esm(() => {
17227
17508
  LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
17228
17509
  });
17229
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
+
17230
17537
  // src/lib/doctor.ts
17231
17538
  var exports_doctor = {};
17232
17539
  __export(exports_doctor, {
@@ -17234,7 +17541,7 @@ __export(exports_doctor, {
17234
17541
  });
17235
17542
  import { chmodSync, copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync5, statSync as statSync3 } from "fs";
17236
17543
  import { basename as basename2, dirname as dirname5, join as join6 } from "path";
17237
- function tableExists(db, table) {
17544
+ function tableExists2(db, table) {
17238
17545
  return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
17239
17546
  }
17240
17547
  function quoteIdent(identifier) {
@@ -17318,7 +17625,7 @@ function findDuplicateIndexes(db) {
17318
17625
  return duplicates;
17319
17626
  }
17320
17627
  function findMissingProjectRoots(db) {
17321
- if (!tableExists(db, "projects"))
17628
+ if (!tableExists2(db, "projects"))
17322
17629
  return 0;
17323
17630
  let missing = 0;
17324
17631
  const rows = db.query("SELECT path FROM projects WHERE path IS NOT NULL AND path != ''").all();
@@ -17333,7 +17640,7 @@ function findMissingProjectRoots(db) {
17333
17640
  return missing;
17334
17641
  }
17335
17642
  function addTaskStateChecks(db, checks) {
17336
- if (!tableExists(db, "tasks"))
17643
+ if (!tableExists2(db, "tasks"))
17337
17644
  return;
17338
17645
  const columns = getTableColumns(db, "tasks");
17339
17646
  const staleCutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
@@ -17405,15 +17712,42 @@ function createBackup(dbPath) {
17405
17712
  function pushRepair(repairs, type, message, applied, count) {
17406
17713
  repairs.push({ type, message, applied, count });
17407
17714
  }
17408
- function summarize2(checks, repairs) {
17715
+ function summarize2(checks, repairs, integrity) {
17409
17716
  return {
17410
17717
  errors: checks.filter((check) => check.severity === "error").length,
17411
17718
  warnings: checks.filter((check) => check.severity === "warn").length,
17412
17719
  infos: checks.filter((check) => check.severity === "info").length,
17413
17720
  repairable: checks.filter((check) => check.repairable).length,
17414
- 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
17415
17725
  };
17416
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
+ }
17417
17751
  function deleteOrphans(db, table, where) {
17418
17752
  const before = countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17419
17753
  if (before > 0)
@@ -17442,7 +17776,7 @@ function runTodosDoctor(options = {}) {
17442
17776
  message: `Schema at migration ${migrationCurrent}`
17443
17777
  });
17444
17778
  }
17445
- const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
17779
+ const missingTables = REQUIRED_TABLES2.filter((table) => !tableExists2(db, table));
17446
17780
  if (missingTables.length > 0) {
17447
17781
  addCheck(checks, {
17448
17782
  severity: "error",
@@ -17452,7 +17786,7 @@ function runTodosDoctor(options = {}) {
17452
17786
  repairable: true
17453
17787
  });
17454
17788
  }
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;
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;
17456
17790
  if (orphanedParents > 0) {
17457
17791
  addCheck(checks, {
17458
17792
  severity: "error",
@@ -17462,7 +17796,7 @@ function runTodosDoctor(options = {}) {
17462
17796
  repairable: true
17463
17797
  });
17464
17798
  }
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;
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;
17466
17800
  if (orphanedDependencies > 0) {
17467
17801
  addCheck(checks, {
17468
17802
  severity: "error",
@@ -17481,7 +17815,7 @@ function runTodosDoctor(options = {}) {
17481
17815
  ];
17482
17816
  let orphanedRows = 0;
17483
17817
  for (const [table, where] of orphanTables) {
17484
- if (!tableExists(db, table))
17818
+ if (!tableExists2(db, table))
17485
17819
  continue;
17486
17820
  orphanedRows += countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
17487
17821
  }
@@ -17495,6 +17829,8 @@ function runTodosDoctor(options = {}) {
17495
17829
  });
17496
17830
  }
17497
17831
  addTaskStateChecks(db, checks);
17832
+ const integrity = scanSqliteIntegrity(db);
17833
+ addIntegrityChecks(integrity, checks);
17498
17834
  const corruptJson = findCorruptJsonMetadata(db);
17499
17835
  if (corruptJson.length > 0) {
17500
17836
  addCheck(checks, {
@@ -17553,12 +17889,12 @@ function runTodosDoctor(options = {}) {
17553
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)");
17554
17890
  pushRepair(repairs, "orphaned_task_parents", "Cleared missing parent references", true, orphanedParents);
17555
17891
  }
17556
- if (orphanedDependencies > 0 && tableExists(db, "task_dependencies")) {
17892
+ if (orphanedDependencies > 0 && tableExists2(db, "task_dependencies")) {
17557
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)");
17558
17894
  pushRepair(repairs, "orphaned_task_dependencies", "Deleted dependency rows referencing missing tasks", true, count);
17559
17895
  }
17560
17896
  for (const [table, where] of orphanTables) {
17561
- if (!tableExists(db, table))
17897
+ if (!tableExists2(db, table))
17562
17898
  continue;
17563
17899
  const count = deleteOrphans(db, table, where);
17564
17900
  if (count > 0)
@@ -17587,26 +17923,30 @@ function runTodosDoctor(options = {}) {
17587
17923
  }
17588
17924
  }
17589
17925
  }
17590
- const finalChecks = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }).checks : checks;
17591
- 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);
17592
17930
  return {
17593
- ok: !finalChecks.some((check) => check.severity === "error"),
17931
+ ok: !finalChecks.some((check) => check.severity === "error") && finalIntegrity.summary.ok,
17594
17932
  dry_run: !apply,
17595
17933
  database_path: dbPath,
17596
17934
  migration: { current: getMigrationLevel(db), expected: migrationExpected },
17597
17935
  backup,
17598
17936
  checks: finalChecks,
17599
17937
  repairs,
17938
+ integrity: finalIntegrity,
17600
17939
  summary
17601
17940
  };
17602
17941
  }
17603
- var REQUIRED_TABLES;
17942
+ var REQUIRED_TABLES2;
17604
17943
  var init_doctor = __esm(() => {
17605
17944
  init_database();
17945
+ init_integrity2();
17606
17946
  init_migrations();
17607
17947
  init_schema();
17608
17948
  init_recurrence();
17609
- REQUIRED_TABLES = [
17949
+ REQUIRED_TABLES2 = [
17610
17950
  "_migrations",
17611
17951
  "projects",
17612
17952
  "tasks",
@@ -18998,15 +19338,15 @@ function resolveTaskRefLocal(db, ref) {
18998
19338
  return null;
18999
19339
  if (TASK_UUID_RE.test(raw))
19000
19340
  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}`)}%`);
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}`)}%`);
19002
19342
  if (prefixRows.length > 1) {
19003
- 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 })));
19004
19344
  }
19005
19345
  if (prefixRows.length === 1)
19006
19346
  return getTask(prefixRows[0].id, db);
19007
- 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);
19008
19348
  if (shortIdRows.length > 1) {
19009
- 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 })));
19010
19350
  }
19011
19351
  if (shortIdRows.length === 1)
19012
19352
  return getTask(shortIdRows[0].id, db);
@@ -19155,6 +19495,9 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19155
19495
  exportSnapshot: () => exportSqliteTodosStorageSnapshot(database()),
19156
19496
  importSnapshot: (snapshot) => importSqliteTodosStorageSnapshot(snapshot, database())
19157
19497
  },
19498
+ integrity: {
19499
+ report: () => scanSqliteIntegrity(database())
19500
+ },
19158
19501
  transaction: (fn) => {
19159
19502
  const tx = database().transaction(() => fn(adapter));
19160
19503
  return tx();
@@ -19164,6 +19507,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19164
19507
  }
19165
19508
  var TASK_UUID_RE;
19166
19509
  var init_local_sqlite = __esm(() => {
19510
+ init_types();
19167
19511
  init_search();
19168
19512
  init_tasks();
19169
19513
  init_projects();
@@ -19174,6 +19518,7 @@ var init_local_sqlite = __esm(() => {
19174
19518
  init_audit();
19175
19519
  init_comments();
19176
19520
  init_database();
19521
+ init_integrity2();
19177
19522
  init_sqlite_snapshot();
19178
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;
19179
19524
  });
@@ -20472,6 +20817,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
20472
20817
  { name: "task_list_id", in: "query", schema: { type: "string" } },
20473
20818
  { name: "assigned_to", in: "query", schema: { type: "string" } },
20474
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" },
20475
20821
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
20476
20822
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
20477
20823
  ],
@@ -21676,6 +22022,9 @@ async function handleV1Request(req, url, dependencies = {}) {
21676
22022
  ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
21677
22023
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
21678
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
+ } : {},
21679
22028
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
21680
22029
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
21681
22030
  };
@@ -21957,6 +22306,13 @@ async function handleV1Request(req, url, dependencies = {}) {
21957
22306
  try {
21958
22307
  task = await store.tasks.resolveRef(id);
21959
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
+ }
21960
22316
  const msg = e.message || "";
21961
22317
  if (/ambiguous/i.test(msg))
21962
22318
  return error(409, msg);
@@ -22314,6 +22670,15 @@ async function handleV1Request(req, url, dependencies = {}) {
22314
22670
  ]);
22315
22671
  return json3({ tasks, tasks_all: tasksAll, subtasks: tasksAll - tasks, projects: projects.length });
22316
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
+ }
22317
22682
  if (resource === "import") {
22318
22683
  if (method !== "POST")
22319
22684
  return error(405, `method ${method} not allowed on /v1/import`);
@@ -22333,6 +22698,13 @@ async function handleV1Request(req, url, dependencies = {}) {
22333
22698
  }
22334
22699
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
22335
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
+ }
22336
22708
  if (e instanceof LockError)
22337
22709
  return error(409, e.message, { code: LockError.code });
22338
22710
  if (e instanceof ResourceConflictError)
@@ -44665,9 +45037,6 @@ function cleanMode(value) {
44665
45037
  const normalized = value?.trim().toLowerCase();
44666
45038
  return normalized || null;
44667
45039
  }
44668
- function modeRole(mode) {
44669
- return mode === "local" ? "local" : "remote";
44670
- }
44671
45040
  function resolveTodosCliStorageMode(env = process.env) {
44672
45041
  for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
44673
45042
  if (env[source] !== undefined && env[source].trim() === "") {
@@ -44680,22 +45049,25 @@ function resolveTodosCliStorageMode(env = process.env) {
44680
45049
  ["HASNA_TODOS_STORAGE_MODE", canonical],
44681
45050
  ["TODOS_STORAGE_MODE", fallback]
44682
45051
  ]) {
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");
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");
44685
45054
  }
44686
45055
  }
44687
- 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) {
44688
45059
  throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
44689
45060
  }
44690
- const mode = canonical ?? fallback ?? "local";
45061
+ const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
44691
45062
  return {
44692
- mode,
44693
- selected: CLOUD_MODES.has(mode),
45063
+ mode: transport,
45064
+ transport,
45065
+ selected: transport === "http",
44694
45066
  source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
44695
45067
  };
44696
45068
  }
44697
- function requestedStorageMode(env) {
44698
- return resolveTodosCliStorageMode(env).mode;
45069
+ function requestedTransport(env) {
45070
+ return resolveTodosCliStorageMode(env).transport;
44699
45071
  }
44700
45072
  function normalizeRemoteAuthorityUrl(value) {
44701
45073
  const trimmed = value?.trim();
@@ -44748,7 +45120,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
44748
45120
  return {
44749
45121
  selected: false,
44750
45122
  ok: true,
44751
- mode: mode || "local",
45123
+ mode,
44752
45124
  api_url_configured: false,
44753
45125
  api_key_configured: false,
44754
45126
  v1_base_url: null,
@@ -44860,8 +45232,7 @@ async function requiredRemoteRoute(client, route, request) {
44860
45232
  }
44861
45233
  }
44862
45234
  function getTodosCloudClient(env = process.env) {
44863
- const mode = requestedStorageMode(env);
44864
- if (!CLOUD_MODES.has(mode))
45235
+ if (requestedTransport(env) !== "http")
44865
45236
  return null;
44866
45237
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
44867
45238
  fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
@@ -44896,13 +45267,38 @@ function toListQuery(filter = {}) {
44896
45267
  query["assigned_to"] = filter.assigned_to;
44897
45268
  if (filter.agent_id)
44898
45269
  query["agent_id"] = filter.agent_id;
45270
+ if (filter.tags?.length)
45271
+ query["tags"] = filter.tags.join(",");
44899
45272
  if (typeof filter.limit === "number")
44900
45273
  query["limit"] = filter.limit;
44901
45274
  if (typeof filter.offset === "number")
44902
45275
  query["offset"] = filter.offset;
44903
45276
  return query;
44904
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
+ }
44905
45299
  async function cloudListTasks(client, filter = {}) {
45300
+ if (filter.tags?.length)
45301
+ await requireTagsFilterCapability(client);
44906
45302
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
44907
45303
  const envelope = res.raw;
44908
45304
  return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
@@ -45010,6 +45406,8 @@ function redactComment3(comment) {
45010
45406
  }
45011
45407
  async function cloudCountTasks(client, filter = {}) {
45012
45408
  const { limit: _drop, offset: _o, ...rest } = filter;
45409
+ if (rest.tags?.length)
45410
+ await requireTagsFilterCapability(client);
45013
45411
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: { ...toListQuery(rest), limit: 1 } }));
45014
45412
  const envelope = res.raw;
45015
45413
  if (typeof envelope?.total === "number")
@@ -45072,15 +45470,23 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
45072
45470
  }
45073
45471
  throw new Error(`Task list not found: "${input}"`);
45074
45472
  }
45075
- var UUID_RE, CLOUD_MODES, VALID_STORAGE_MODES, completionCapabilityCache;
45473
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, listTagsCapabilityCache;
45076
45474
  var init_cloud_router = __esm(() => {
45077
45475
  init_types();
45078
45476
  init_redaction();
45079
45477
  init_http_client();
45080
45478
  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]);
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
+ };
45083
45488
  completionCapabilityCache = new Map;
45489
+ listTagsCapabilityCache = new Map;
45084
45490
  });
45085
45491
 
45086
45492
  // src/mcp/tools/task-crud.ts
@@ -48666,12 +49072,11 @@ ${task.description || ""}`.split(/\r?\n/);
48666
49072
  function normalizeStackLocation(value) {
48667
49073
  return value.replace(/:\d+(?::\d+)?/g, "").replace(/^file:\/\//, "").toLowerCase().trim();
48668
49074
  }
48669
- function fingerprint(task) {
49075
+ function fingerprintContent(task) {
48670
49076
  const body = task.description || "";
48671
49077
  const text = `${task.title}
48672
49078
  ${body}`;
48673
49079
  return {
48674
- task,
48675
49080
  title: normalizeText(task.title),
48676
49081
  body: normalizeText(body),
48677
49082
  text: normalizeText(text),
@@ -48680,6 +49085,9 @@ ${body}`;
48680
49085
  stackKeys: stackKeysFor(task)
48681
49086
  };
48682
49087
  }
49088
+ function fingerprint(task) {
49089
+ return { task, ...fingerprintContent(task) };
49090
+ }
48683
49091
  function intersects(left, right) {
48684
49092
  for (const value of left)
48685
49093
  if (right.has(value))
@@ -49888,7 +50296,7 @@ function tableColumns(db, table) {
49888
50296
  }
49889
50297
  function assertMigratedSchema(db) {
49890
50298
  const issues = [];
49891
- for (const table of REQUIRED_TABLES2) {
50299
+ for (const table of REQUIRED_TABLES3) {
49892
50300
  if (!hasTable(db, table))
49893
50301
  issues.push(`missing table ${table}`);
49894
50302
  }
@@ -49996,7 +50404,7 @@ function createReleaseCompatibilityReport(options = {}) {
49996
50404
  migrations: {
49997
50405
  current_level: MIGRATIONS.length,
49998
50406
  simulated_levels: simulatedLevels,
49999
- checked_tables: REQUIRED_TABLES2,
50407
+ checked_tables: REQUIRED_TABLES3,
50000
50408
  checked_columns: REQUIRED_COLUMNS
50001
50409
  },
50002
50410
  exports: {
@@ -50016,7 +50424,7 @@ function createReleaseCompatibilityReport(options = {}) {
50016
50424
  "todos --help",
50017
50425
  "todos-mcp --help",
50018
50426
  "todos-serve --help",
50019
- "todos doctor"
50427
+ "todos doctor --no-fail-on-findings"
50020
50428
  ],
50021
50429
  smoke_tests: [
50022
50430
  "command -v todos",
@@ -50024,7 +50432,7 @@ function createReleaseCompatibilityReport(options = {}) {
50024
50432
  "command -v todos-serve",
50025
50433
  "todos --version",
50026
50434
  "todos --help",
50027
- "todos doctor"
50435
+ "todos doctor --no-fail-on-findings"
50028
50436
  ],
50029
50437
  rollback: [
50030
50438
  "npm view @hasna/todos versions --json",
@@ -50073,14 +50481,14 @@ function renderReleaseCompatibilityMarkdown(report) {
50073
50481
  return lines.join(`
50074
50482
  `);
50075
50483
  }
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;
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;
50077
50485
  var init_release_compatibility = __esm(() => {
50078
50486
  init_migrations();
50079
50487
  init_schema();
50080
50488
  EXPECTED_BINS = ["todos", "todos-mcp", "todos-serve"];
50081
50489
  EXPECTED_EXPORTS = [".", "./sdk", "./mcp", "./registry", "./contracts", "./storage"];
50082
50490
  REQUIRED_SCRIPTS = ["build", "test:no-cloud", "verify:release", "prepublishOnly"];
50083
- REQUIRED_TABLES2 = [
50491
+ REQUIRED_TABLES3 = [
50084
50492
  "_migrations",
50085
50493
  "projects",
50086
50494
  "tasks",
@@ -53839,7 +54247,7 @@ function limits(input) {
53839
54247
  verification_limit: clamp(input.verification_limit, DEFAULT_LIMITS.verification_limit, 100),
53840
54248
  run_limit: clamp(input.run_limit, DEFAULT_LIMITS.run_limit, 20),
53841
54249
  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),
54250
+ plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, MAX_CONTEXT_TASKS),
53843
54251
  max_text_chars: clamp(input.max_text_chars, DEFAULT_LIMITS.max_text_chars, 50000),
53844
54252
  summary_char_limit: clamp(input.summary_char_limit, DEFAULT_LIMITS.summary_char_limit, 4000),
53845
54253
  stale_after_hours: clamp(input.stale_after_hours, DEFAULT_LIMITS.stale_after_hours, 24 * 365)
@@ -53862,6 +54270,13 @@ function taskSummary(task) {
53862
54270
  priority: task.priority
53863
54271
  };
53864
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
+ }
53865
54280
  function acceptanceCriteria(task, maxText) {
53866
54281
  const metadata = task.metadata || {};
53867
54282
  const raw = metadata["acceptance_criteria"] ?? metadata["acceptanceCriteria"] ?? metadata["criteria"];
@@ -53959,7 +54374,7 @@ function summarizeSection(pack, section, maxChars) {
53959
54374
  return "No plan was attached.";
53960
54375
  return summarizeStrings([
53961
54376
  `Plan ${pack.plan.name} is ${pack.plan.status}`,
53962
- `${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`,
53963
54378
  ...pack.plan.tasks.slice(0, 4).map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)
53964
54379
  ], maxChars);
53965
54380
  }
@@ -54109,7 +54524,8 @@ function createAgentContextPack(input, db) {
54109
54524
  const limit = limits(input);
54110
54525
  const project = task.project_id ? getProject(task.project_id, d) : null;
54111
54526
  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) : [];
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;
54113
54529
  const upstream = getTaskDependencies(task.id, d).map((dep) => taskSummary(getTask(dep.depends_on, d))).filter((item) => Boolean(item));
54114
54530
  const downstream = getTaskDependents(task.id, d).map((dep) => taskSummary(getTask(dep.task_id, d))).filter((item) => Boolean(item));
54115
54531
  const comments = listComments(task.id, d);
@@ -54152,6 +54568,9 @@ function createAgentContextPack(input, db) {
54152
54568
  warnings.push(`${runs.length - selectedRuns.length} older runs omitted`);
54153
54569
  if (fileMap.size > relevantFiles.length)
54154
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
+ }
54155
54574
  const contextTask = {
54156
54575
  id: task.id,
54157
54576
  short_id: task.short_id,
@@ -54183,8 +54602,9 @@ function createAgentContextPack(input, db) {
54183
54602
  description: truncate2(plan.description, limit.max_text_chars),
54184
54603
  status: plan.status,
54185
54604
  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)
54605
+ tasks: planTasks.map(taskSummary).filter((item) => Boolean(item)),
54606
+ total_tasks: totalPlanTasks,
54607
+ omitted_tasks: Math.max(0, totalPlanTasks - planTasks.length)
54188
54608
  } : null,
54189
54609
  acceptance_criteria: acceptanceCriteria(task, limit.max_text_chars),
54190
54610
  dependencies: {
@@ -54294,6 +54714,8 @@ ${pack.task.description}` : null,
54294
54714
  "## Project And Plan",
54295
54715
  pack.project ? `- Project: ${pack.project.name} (${pack.project.path})` : "- Project: none",
54296
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,
54297
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,
54298
54720
  "",
54299
54721
  "## Acceptance Criteria",
@@ -54374,7 +54796,7 @@ function renderAgentContextPack(pack, format, compact2 = false) {
54374
54796
  return compact2 ? renderAgentContextPackCompactMarkdown(pack) : renderAgentContextPackMarkdown(pack);
54375
54797
  return compact2 ? JSON.stringify(pack) : JSON.stringify(pack, null, 2);
54376
54798
  }
54377
- var DEFAULT_LIMITS, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54799
+ var DEFAULT_LIMITS, MAX_CONTEXT_TASKS = 24, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
54378
54800
  var init_context_packs = __esm(() => {
54379
54801
  init_comments();
54380
54802
  init_database();
@@ -54393,7 +54815,7 @@ var init_context_packs = __esm(() => {
54393
54815
  verification_limit: 10,
54394
54816
  run_limit: 3,
54395
54817
  dependency_limit: 12,
54396
- plan_task_limit: 20,
54818
+ plan_task_limit: 24,
54397
54819
  max_text_chars: 6000,
54398
54820
  summary_char_limit: 480,
54399
54821
  stale_after_hours: 72
@@ -79216,7 +79638,7 @@ function parseObject2(value) {
79216
79638
  function normalizeKey(value) {
79217
79639
  return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
79218
79640
  }
79219
- function normalizeFingerprint(value) {
79641
+ function normalizeTaskFindingFingerprint(value) {
79220
79642
  const normalized = normalizeKey(value);
79221
79643
  if (!normalized)
79222
79644
  throw new Error("finding fingerprint is required");
@@ -79325,7 +79747,7 @@ function assertTask(taskId, db) {
79325
79747
  throw new TaskNotFoundError(taskId);
79326
79748
  }
79327
79749
  function nextFinding(input, db) {
79328
- const fingerprint2 = normalizeFingerprint(input.fingerprint);
79750
+ const fingerprint2 = normalizeTaskFindingFingerprint(input.fingerprint);
79329
79751
  const title = redactOptional(input.title, 300);
79330
79752
  if (!title)
79331
79753
  throw new Error("finding title is required");
@@ -79473,7 +79895,7 @@ function resolveMissingTaskFindings(input, db) {
79473
79895
  const timestamp4 = input.resolved_at || now();
79474
79896
  const status = normalizeResolutionStatus(input.status);
79475
79897
  const runId = resolveRunForTask(input.run_id, input.task_id, d);
79476
- const present = new Set(input.fingerprints.map(normalizeFingerprint));
79898
+ const present = new Set(input.fingerprints.map(normalizeTaskFindingFingerprint));
79477
79899
  const warnings = [];
79478
79900
  const conditions = ["task_id = ?", "status = 'open'"];
79479
79901
  const params = [input.task_id];
@@ -91218,6 +91640,15 @@ function formatError2(error3) {
91218
91640
  if (error3 instanceof TaskNotFoundError) {
91219
91641
  return JSON.stringify({ code: TaskNotFoundError.code, message: error3.message, suggestion: TaskNotFoundError.suggestion });
91220
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
+ }
91221
91652
  if (error3 instanceof ProjectNotFoundError) {
91222
91653
  return JSON.stringify({ code: ProjectNotFoundError.code, message: error3.message, suggestion: ProjectNotFoundError.suggestion });
91223
91654
  }
@@ -92015,23 +92446,24 @@ Dashboard not found at: ${dashboardDir}`);
92015
92446
  }
92016
92447
  if ((path === "/health" || path === "/ready" || path === "/version") && method === "GET") {
92017
92448
  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";
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";
92020
92452
  const version2 = getPackageVersion2();
92021
92453
  if (path === "/version") {
92022
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92454
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92023
92455
  }
92024
92456
  if (path === "/ready") {
92025
- if (mode === "remote") {
92457
+ if (backend === "postgresql") {
92026
92458
  try {
92027
92459
  await pingCloud2();
92028
92460
  } catch (e) {
92029
- 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 });
92030
92462
  }
92031
92463
  }
92032
- return Response.json({ status: "ready", version: version2, mode });
92464
+ return Response.json({ status: "ready", version: version2, mode, backend });
92033
92465
  }
92034
- return Response.json({ status: "ok", version: version2, mode, name: "todos" });
92466
+ return Response.json({ status: "ok", version: version2, mode, backend, name: "todos" });
92035
92467
  }
92036
92468
  if ((path === "/openapi.json" || path === "/v1/openapi.json") && method === "GET") {
92037
92469
  const { buildV1OpenApiDocument: buildV1OpenApiDocument2 } = await Promise.resolve().then(() => (init_openapi(), exports_openapi));