@hasna/todos 0.13.2 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +78 -14
  2. package/dist/cli/cloud-router.d.ts +100 -7
  3. package/dist/cli/cloud-router.d.ts.map +1 -1
  4. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  6. package/dist/cli/doctor-integrity.d.ts +106 -0
  7. package/dist/cli/doctor-integrity.d.ts.map +1 -0
  8. package/dist/cli/helpers.d.ts +2 -2
  9. package/dist/cli/index.js +1509 -786
  10. package/dist/cli/stage-a.d.ts.map +1 -1
  11. package/dist/contracts.js +118 -30
  12. package/dist/db/agent-names.d.ts +2 -1
  13. package/dist/db/agent-names.d.ts.map +1 -1
  14. package/dist/db/database.d.ts.map +1 -1
  15. package/dist/db/findings.d.ts +1 -0
  16. package/dist/db/findings.d.ts.map +1 -1
  17. package/dist/db/integrity.d.ts +20 -0
  18. package/dist/db/integrity.d.ts.map +1 -0
  19. package/dist/index.d.ts +5 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +584 -140
  22. package/dist/lib/agent-name-normalize.d.ts +17 -0
  23. package/dist/lib/agent-name-normalize.d.ts.map +1 -0
  24. package/dist/lib/context-packs.d.ts +1 -0
  25. package/dist/lib/context-packs.d.ts.map +1 -1
  26. package/dist/lib/dependency-graph.d.ts +1 -1
  27. package/dist/lib/doctor.d.ts +20 -0
  28. package/dist/lib/doctor.d.ts.map +1 -1
  29. package/dist/lib/integrity.d.ts +215 -0
  30. package/dist/lib/integrity.d.ts.map +1 -0
  31. package/dist/lib/native-storage-status.d.ts +2 -2
  32. package/dist/lib/native-storage-status.d.ts.map +1 -1
  33. package/dist/lib/release-compatibility.d.ts.map +1 -1
  34. package/dist/lib/task-dedupe.d.ts +15 -0
  35. package/dist/lib/task-dedupe.d.ts.map +1 -1
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +554 -92
  38. package/dist/mcp.js +7 -3
  39. package/dist/registry.d.ts +1 -1
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js +125 -30
  42. package/dist/release-provenance.json +5 -5
  43. package/dist/sdk/types.d.ts +42 -0
  44. package/dist/sdk/types.d.ts.map +1 -1
  45. package/dist/sdk/v1.generated.d.ts +1 -0
  46. package/dist/sdk/v1.generated.d.ts.map +1 -1
  47. package/dist/server/cloud.d.ts +8 -1
  48. package/dist/server/cloud.d.ts.map +1 -1
  49. package/dist/server/index.js +560 -98
  50. package/dist/server/openapi.d.ts +12 -0
  51. package/dist/server/openapi.d.ts.map +1 -1
  52. package/dist/server/serve.d.ts.map +1 -1
  53. package/dist/server/v1.d.ts.map +1 -1
  54. package/dist/storage/config.d.ts +23 -5
  55. package/dist/storage/config.d.ts.map +1 -1
  56. package/dist/storage/factory.d.ts +9 -3
  57. package/dist/storage/factory.d.ts.map +1 -1
  58. package/dist/storage/hybrid.d.ts +7 -0
  59. package/dist/storage/hybrid.d.ts.map +1 -1
  60. package/dist/storage/index.d.ts +3 -3
  61. package/dist/storage/index.d.ts.map +1 -1
  62. package/dist/storage/interfaces.d.ts +29 -8
  63. package/dist/storage/interfaces.d.ts.map +1 -1
  64. package/dist/storage/local-sqlite.d.ts.map +1 -1
  65. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  66. package/dist/storage.d.ts +3 -3
  67. package/dist/storage.d.ts.map +1 -1
  68. package/dist/storage.js +623 -258
  69. package/dist/testing.d.ts +79 -0
  70. package/dist/testing.d.ts.map +1 -0
  71. package/dist/testing.js +107 -0
  72. package/dist/types/index.d.ts +12 -1
  73. package/dist/types/index.d.ts.map +1 -1
  74. package/package.json +7 -3
package/dist/mcp/index.js CHANGED
@@ -44,7 +44,7 @@ var __require = import.meta.require;
44
44
  function isBlockingDependencyStatus(status) {
45
45
  return status !== "completed" && status !== "cancelled";
46
46
  }
47
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
47
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
48
48
  var init_types = __esm(() => {
49
49
  TASK_STATUSES = [
50
50
  "pending",
@@ -77,6 +77,22 @@ var init_types = __esm(() => {
77
77
  this.name = "TaskNotFoundError";
78
78
  }
79
79
  };
80
+ TaskReferenceAmbiguousError = class TaskReferenceAmbiguousError extends Error {
81
+ reference;
82
+ static code = "TASK_REFERENCE_AMBIGUOUS";
83
+ candidateTaskIds;
84
+ candidateProjectIds;
85
+ constructor(reference, candidates) {
86
+ const candidateTaskIds = [...new Set(candidates.map((candidate) => candidate.task_id))].sort();
87
+ const candidateProjectIds = [...new Set(candidates.map((candidate) => candidate.project_id).filter((projectId) => typeof projectId === "string" && projectId.length > 0))].sort();
88
+ const projectList = candidateProjectIds.length > 0 ? candidateProjectIds.join(", ") : "(unscoped)";
89
+ super(`Task reference is ambiguous: "${reference}". Candidate project IDs: ${projectList}. ` + "Use a full task UUID.");
90
+ this.reference = reference;
91
+ this.name = "TaskReferenceAmbiguousError";
92
+ this.candidateTaskIds = candidateTaskIds;
93
+ this.candidateProjectIds = candidateProjectIds;
94
+ }
95
+ };
80
96
  ProjectNotFoundError = class ProjectNotFoundError extends Error {
81
97
  projectId;
82
98
  static code = "PROJECT_NOT_FOUND";
@@ -3766,15 +3782,18 @@ var init_identity_mapping = __esm(() => {
3766
3782
  var exports_config = {};
3767
3783
  __export(exports_config, {
3768
3784
  parseStorageMode: () => parseStorageMode,
3785
+ parseStorageBackend: () => parseStorageBackend,
3769
3786
  loadTodosStorageConfig: () => loadTodosStorageConfig,
3770
3787
  loadStorageConfig: () => loadStorageConfig,
3771
3788
  isTodosShadowEnabled: () => isTodosShadowEnabled,
3772
3789
  isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
3790
+ isTodosPostgresBackend: () => isTodosPostgresBackend,
3773
3791
  getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
3774
3792
  getTodosStorageMode: () => getTodosStorageMode,
3775
3793
  getTodosStorageEnvName: () => getTodosStorageEnvName,
3776
3794
  getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
3777
3795
  getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
3796
+ getTodosStorageBackend: () => getTodosStorageBackend,
3778
3797
  getStorageMode: () => getStorageMode,
3779
3798
  getStorageDatabaseUrl: () => getStorageDatabaseUrl,
3780
3799
  getStorageDatabaseEnv: () => getStorageDatabaseEnv,
@@ -3799,7 +3818,7 @@ function getCanonicalTodosRdsConfig(env = process.env) {
3799
3818
  };
3800
3819
  }
3801
3820
  function loadTodosStorageConfig(env = process.env) {
3802
- const mode = getTodosStorageMode(env);
3821
+ const mode = getTodosStorageBackend(env);
3803
3822
  const databaseUrl = getTodosStorageDatabaseUrl(env);
3804
3823
  const bucket = readStorageEnv(env, "s3Bucket").value;
3805
3824
  const prefix = readStorageEnv(env, "s3Prefix").value ?? "todos/";
@@ -3836,29 +3855,41 @@ function loadTodosStorageConfig(env = process.env) {
3836
3855
  function loadStorageConfig(env = process.env) {
3837
3856
  return loadTodosStorageConfig(env);
3838
3857
  }
3858
+ function isTodosPostgresBackend(config) {
3859
+ return config.mode === "postgres";
3860
+ }
3839
3861
  function isTodosRemoteStorageEnabled(config) {
3840
- return config.mode === "remote" || config.mode === "hybrid";
3862
+ return isTodosPostgresBackend(config);
3841
3863
  }
3842
3864
  function assertTodosRemoteStorageConfig(config) {
3843
- if (!isTodosRemoteStorageEnabled(config))
3865
+ if (!isTodosPostgresBackend(config))
3844
3866
  return;
3845
3867
  if (!config.database?.url) {
3846
3868
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
3847
3869
  }
3848
3870
  }
3849
- function parseStorageMode(value) {
3871
+ function parseStorageBackend(value) {
3850
3872
  const normalized = clean(value)?.toLowerCase();
3851
3873
  if (!normalized)
3852
- return "local";
3853
- if (normalized === "local" || normalized === "remote" || normalized === "hybrid")
3874
+ return "sqlite";
3875
+ if (normalized === "sqlite" || normalized === "postgres")
3854
3876
  return normalized;
3855
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be local, remote, or hybrid`);
3877
+ const legacy = LEGACY_BACKEND_TOKENS[normalized];
3878
+ if (legacy)
3879
+ return legacy;
3880
+ throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
3881
+ }
3882
+ function parseStorageMode(value) {
3883
+ return parseStorageBackend(value);
3884
+ }
3885
+ function getTodosStorageBackend(env = process.env) {
3886
+ return parseStorageBackend(readStorageEnv(env, "mode").value);
3856
3887
  }
3857
3888
  function getTodosStorageMode(env = process.env) {
3858
- return parseStorageMode(readStorageEnv(env, "mode").value);
3889
+ return getTodosStorageBackend(env);
3859
3890
  }
3860
3891
  function getStorageMode(env = process.env) {
3861
- return getTodosStorageMode(env);
3892
+ return getTodosStorageBackend(env);
3862
3893
  }
3863
3894
  function isTodosShadowEnabled(env = process.env) {
3864
3895
  return parseBoolean(readStorageEnv(env, "shadow").value, false);
@@ -3869,8 +3900,8 @@ function getTodosStorageShadowEnvName(env = process.env) {
3869
3900
  function assertTodosShadowConfig(config, env = process.env) {
3870
3901
  if (!isTodosShadowEnabled(env))
3871
3902
  return;
3872
- if (config.mode !== "local") {
3873
- throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires ${TODOS_STORAGE_ENV.mode}=local (got ${config.mode})`);
3903
+ if (config.mode !== "sqlite") {
3904
+ throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires the sqlite backend (got ${config.mode})`);
3874
3905
  }
3875
3906
  if (!config.database?.url) {
3876
3907
  throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${readStorageEnv(env, "shadow").name} is enabled`);
@@ -3926,7 +3957,7 @@ function parsePositiveInteger(value, fallback) {
3926
3957
  }
3927
3958
  return parsed;
3928
3959
  }
3929
- var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos";
3960
+ var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos", LEGACY_BACKEND_TOKENS;
3930
3961
  var init_config = __esm(() => {
3931
3962
  TODOS_STORAGE_TABLES = [
3932
3963
  "todos_sync_records",
@@ -3967,6 +3998,14 @@ var init_config = __esm(() => {
3967
3998
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
3968
3999
  syncDryRun: "TODOS_SYNC_DRY_RUN"
3969
4000
  };
4001
+ LEGACY_BACKEND_TOKENS = {
4002
+ local: "sqlite",
4003
+ remote: "postgres",
4004
+ postgresql: "postgres",
4005
+ hybrid: "postgres",
4006
+ self_hosted: "postgres",
4007
+ cloud: "postgres"
4008
+ };
3970
4009
  });
3971
4010
 
3972
4011
  // src/storage/shadow-outbox-schema.ts
@@ -4100,6 +4139,30 @@ function findGitRoot(startDir) {
4100
4139
  }
4101
4140
  return null;
4102
4141
  }
4142
+ function getGlobalDbPath() {
4143
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4144
+ return join(home, ".hasna", "todos", "todos.db");
4145
+ }
4146
+ function hasExplicitProjectArg(args = process.argv.slice(2)) {
4147
+ return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
4148
+ }
4149
+ function getCliCommand(args = process.argv.slice(2)) {
4150
+ const globalOptionsWithValues = new Set(["--project", "--agent", "--session"]);
4151
+ for (let index = 0;index < args.length; index += 1) {
4152
+ const arg = args[index];
4153
+ if (globalOptionsWithValues.has(arg)) {
4154
+ index += 1;
4155
+ continue;
4156
+ }
4157
+ if (arg.startsWith("-"))
4158
+ continue;
4159
+ return arg;
4160
+ }
4161
+ return null;
4162
+ }
4163
+ function canCreateScopedProjectDb(args = process.argv.slice(2)) {
4164
+ return getCliCommand(args) === "project-bootstrap" && !args.some((arg) => arg === "--dry-run" || arg.startsWith("--dry-run="));
4165
+ }
4103
4166
  function getDbPath() {
4104
4167
  if (process.env["HASNA_TODOS_DB_PATH"]) {
4105
4168
  return process.env["HASNA_TODOS_DB_PATH"];
@@ -4107,18 +4170,19 @@ function getDbPath() {
4107
4170
  if (process.env["TODOS_DB_PATH"]) {
4108
4171
  return process.env["TODOS_DB_PATH"];
4109
4172
  }
4173
+ if (hasExplicitProjectArg())
4174
+ return getGlobalDbPath();
4110
4175
  const cwd = process.cwd();
4111
4176
  const nearest = findNearestProjectDb(cwd);
4112
4177
  if (nearest)
4113
4178
  return nearest;
4114
4179
  if (process.env["TODOS_DB_SCOPE"] === "project") {
4115
4180
  const gitRoot = findGitRoot(cwd);
4116
- if (gitRoot) {
4181
+ if (gitRoot && canCreateScopedProjectDb()) {
4117
4182
  return join(gitRoot, ".hasna", "todos", "todos.db");
4118
4183
  }
4119
4184
  }
4120
- const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4121
- return join(home, ".hasna", "todos", "todos.db");
4185
+ return getGlobalDbPath();
4122
4186
  }
4123
4187
  function getDatabasePath() {
4124
4188
  return getDbPath();
@@ -4221,10 +4285,13 @@ function resolvePartialId(db, table, partialId) {
4221
4285
  return null;
4222
4286
  }
4223
4287
  if (table === "tasks") {
4224
- const shortIdRows = db.query("SELECT id FROM tasks WHERE short_id = ?").all(partialId);
4288
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = LOWER(?) ORDER BY project_id, id").all(partialId);
4225
4289
  if (shortIdRows.length === 1) {
4226
4290
  return shortIdRows[0].id;
4227
4291
  }
4292
+ if (shortIdRows.length > 1) {
4293
+ throw new TaskReferenceAmbiguousError(partialId, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
4294
+ }
4228
4295
  }
4229
4296
  if (table === "task_lists") {
4230
4297
  const slugRow = db.query("SELECT id FROM task_lists WHERE slug = ?").get(partialId);
@@ -4346,10 +4413,12 @@ var init_storage_tombstones = __esm(() => {
4346
4413
  init_machines();
4347
4414
  });
4348
4415
 
4349
- // src/db/agent-names.ts
4416
+ // src/lib/agent-name-normalize.ts
4350
4417
  function normalizeAgentNameInput(name) {
4351
4418
  return name.trim().toLowerCase();
4352
4419
  }
4420
+
4421
+ // src/db/agent-names.ts
4353
4422
  function hasGeneratedNumericSuffix(name) {
4354
4423
  return NUMERIC_SUFFIX_RE.test(normalizeAgentNameInput(name));
4355
4424
  }
@@ -20017,14 +20086,12 @@ var init_http_client = __esm(() => {
20017
20086
 
20018
20087
  // src/cli/cloud-router.ts
20019
20088
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
20089
+ import { normalizeStorageMode } from "@hasna/contracts/mode";
20020
20090
  import { resolve as resolvePath } from "path";
20021
20091
  function cleanMode(value) {
20022
20092
  const normalized = value?.trim().toLowerCase();
20023
20093
  return normalized || null;
20024
20094
  }
20025
- function modeRole(mode) {
20026
- return mode === "local" ? "local" : "remote";
20027
- }
20028
20095
  function resolveTodosCliStorageMode(env = process.env) {
20029
20096
  for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
20030
20097
  if (env[source] !== undefined && env[source].trim() === "") {
@@ -20037,22 +20104,25 @@ function resolveTodosCliStorageMode(env = process.env) {
20037
20104
  ["HASNA_TODOS_STORAGE_MODE", canonical],
20038
20105
  ["TODOS_STORAGE_MODE", fallback]
20039
20106
  ]) {
20040
- if (value && !VALID_STORAGE_MODES.has(value)) {
20041
- 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");
20107
+ if (value && !(value in TRANSPORT_TOKENS)) {
20108
+ 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");
20042
20109
  }
20043
20110
  }
20044
- if (canonical && fallback && modeRole(canonical) !== modeRole(fallback)) {
20111
+ const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
20112
+ const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
20113
+ if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
20045
20114
  throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
20046
20115
  }
20047
- const mode = canonical ?? fallback ?? "local";
20116
+ const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
20048
20117
  return {
20049
- mode,
20050
- selected: CLOUD_MODES.has(mode),
20118
+ mode: transport,
20119
+ transport,
20120
+ selected: transport === "http",
20051
20121
  source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
20052
20122
  };
20053
20123
  }
20054
- function requestedStorageMode(env) {
20055
- return resolveTodosCliStorageMode(env).mode;
20124
+ function requestedTransport(env) {
20125
+ return resolveTodosCliStorageMode(env).transport;
20056
20126
  }
20057
20127
  function normalizeRemoteAuthorityUrl(value) {
20058
20128
  const trimmed = value?.trim();
@@ -20105,7 +20175,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
20105
20175
  return {
20106
20176
  selected: false,
20107
20177
  ok: true,
20108
- mode: mode || "local",
20178
+ mode,
20109
20179
  api_url_configured: false,
20110
20180
  api_key_configured: false,
20111
20181
  v1_base_url: null,
@@ -20138,13 +20208,27 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
20138
20208
  local_fallback: false
20139
20209
  };
20140
20210
  }
20211
+ function serverStorageMode(normalize = normalizeStorageMode) {
20212
+ const useCache = normalize === normalizeStorageMode;
20213
+ if (useCache && cachedServerMode !== null)
20214
+ return cachedServerMode;
20215
+ for (const candidate of SERVER_MODE_CANDIDATES) {
20216
+ try {
20217
+ normalize(candidate);
20218
+ if (useCache)
20219
+ cachedServerMode = candidate;
20220
+ return candidate;
20221
+ } catch {}
20222
+ }
20223
+ throw new Error(`REMOTE_STORAGE_MODE_UNSUPPORTED: no known server storage mode is accepted by the installed ` + `@hasna/contracts (tried ${SERVER_MODE_CANDIDATES.join(", ")}); the storage-mode enum has changed. ` + `Add the new server token to SERVER_MODE_CANDIDATES in src/cli/cloud-router.ts; ` + `local SQLite fallback is disabled.`);
20224
+ }
20141
20225
  function requireTodosRemoteAuthorityEnv(env) {
20142
20226
  const status = getTodosRemoteAuthorityConfigStatus(env);
20143
20227
  if (!status.ok)
20144
20228
  throw new Error(status.issues[0]);
20145
20229
  return {
20146
20230
  ...env,
20147
- HASNA_TODOS_STORAGE_MODE: "cloud",
20231
+ HASNA_TODOS_STORAGE_MODE: serverStorageMode(),
20148
20232
  HASNA_TODOS_API_URL: status.v1_base_url.replace(/\/v1$/, ""),
20149
20233
  HASNA_TODOS_API_KEY: env.HASNA_TODOS_API_KEY.trim()
20150
20234
  };
@@ -20217,8 +20301,7 @@ async function requiredRemoteRoute(client, route, request) {
20217
20301
  }
20218
20302
  }
20219
20303
  function getTodosCloudClient(env = process.env) {
20220
- const mode = requestedStorageMode(env);
20221
- if (!CLOUD_MODES.has(mode))
20304
+ if (requestedTransport(env) !== "http")
20222
20305
  return null;
20223
20306
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
20224
20307
  fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
@@ -20253,13 +20336,38 @@ function toListQuery(filter = {}) {
20253
20336
  query["assigned_to"] = filter.assigned_to;
20254
20337
  if (filter.agent_id)
20255
20338
  query["agent_id"] = filter.agent_id;
20339
+ if (filter.tags?.length)
20340
+ query["tags"] = filter.tags.join(",");
20256
20341
  if (typeof filter.limit === "number")
20257
20342
  query["limit"] = filter.limit;
20258
20343
  if (typeof filter.offset === "number")
20259
20344
  query["offset"] = filter.offset;
20260
20345
  return query;
20261
20346
  }
20347
+ async function fetchListTagsCapability(client) {
20348
+ const document = await requiredRemoteRoute(client, "/v1/openapi.json", () => client.transport.get("/openapi.json"));
20349
+ if (!document || typeof document !== "object" || Array.isArray(document))
20350
+ return false;
20351
+ const paths = document["paths"];
20352
+ const tasksPath = paths && typeof paths === "object" && !Array.isArray(paths) ? paths["/v1/tasks"] : undefined;
20353
+ const get = tasksPath && typeof tasksPath === "object" && !Array.isArray(tasksPath) ? tasksPath["get"] : undefined;
20354
+ const parameters = get && typeof get === "object" && !Array.isArray(get) ? get["parameters"] : undefined;
20355
+ return Array.isArray(parameters) && parameters.some((parameter) => parameter && typeof parameter === "object" && parameter["name"] === "tags");
20356
+ }
20357
+ async function requireTagsFilterCapability(client) {
20358
+ const authority = remoteAuthorityBase(client);
20359
+ let capability = listTagsCapabilityCache.get(authority);
20360
+ if (!capability) {
20361
+ capability = fetchListTagsCapability(client);
20362
+ listTagsCapabilityCache.set(authority, capability);
20363
+ }
20364
+ if (!await capability) {
20365
+ 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");
20366
+ }
20367
+ }
20262
20368
  async function cloudListTasks(client, filter = {}) {
20369
+ if (filter.tags?.length)
20370
+ await requireTagsFilterCapability(client);
20263
20371
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
20264
20372
  const envelope = res.raw;
20265
20373
  return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
@@ -20367,6 +20475,8 @@ function redactComment(comment) {
20367
20475
  }
20368
20476
  async function cloudCountTasks(client, filter = {}) {
20369
20477
  const { limit: _drop, offset: _o, ...rest } = filter;
20478
+ if (rest.tags?.length)
20479
+ await requireTagsFilterCapability(client);
20370
20480
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: { ...toListQuery(rest), limit: 1 } }));
20371
20481
  const envelope = res.raw;
20372
20482
  if (typeof envelope?.total === "number")
@@ -20429,15 +20539,24 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
20429
20539
  }
20430
20540
  throw new Error(`Task list not found: "${input}"`);
20431
20541
  }
20432
- var UUID_RE, CLOUD_MODES, VALID_STORAGE_MODES, completionCapabilityCache;
20542
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, listTagsCapabilityCache;
20433
20543
  var init_cloud_router = __esm(() => {
20434
20544
  init_types();
20435
20545
  init_redaction();
20436
20546
  init_http_client();
20437
20547
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20438
- CLOUD_MODES = new Set(["self_hosted", "cloud", "remote", "hybrid"]);
20439
- VALID_STORAGE_MODES = new Set(["local", ...CLOUD_MODES]);
20548
+ TRANSPORT_TOKENS = {
20549
+ sqlite: "sqlite",
20550
+ http: "http",
20551
+ local: "sqlite",
20552
+ remote: "http",
20553
+ self_hosted: "http",
20554
+ cloud: "http",
20555
+ hybrid: "http"
20556
+ };
20440
20557
  completionCapabilityCache = new Map;
20558
+ SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
20559
+ listTagsCapabilityCache = new Map;
20441
20560
  });
20442
20561
 
20443
20562
  // src/mcp/tools/task-crud.ts
@@ -24023,12 +24142,11 @@ ${task.description || ""}`.split(/\r?\n/);
24023
24142
  function normalizeStackLocation(value) {
24024
24143
  return value.replace(/:\d+(?::\d+)?/g, "").replace(/^file:\/\//, "").toLowerCase().trim();
24025
24144
  }
24026
- function fingerprint(task) {
24145
+ function fingerprintContent(task) {
24027
24146
  const body = task.description || "";
24028
24147
  const text = `${task.title}
24029
24148
  ${body}`;
24030
24149
  return {
24031
- task,
24032
24150
  title: normalizeText(task.title),
24033
24151
  body: normalizeText(body),
24034
24152
  text: normalizeText(text),
@@ -24037,6 +24155,9 @@ ${body}`;
24037
24155
  stackKeys: stackKeysFor(task)
24038
24156
  };
24039
24157
  }
24158
+ function fingerprint(task) {
24159
+ return { task, ...fingerprintContent(task) };
24160
+ }
24040
24161
  function intersects(left, right) {
24041
24162
  for (const value of left)
24042
24163
  if (right.has(value))
@@ -25373,7 +25494,7 @@ function createReleaseCompatibilityReport(options = {}) {
25373
25494
  "todos --help",
25374
25495
  "todos-mcp --help",
25375
25496
  "todos-serve --help",
25376
- "todos doctor"
25497
+ "todos doctor --no-fail-on-findings"
25377
25498
  ],
25378
25499
  smoke_tests: [
25379
25500
  "command -v todos",
@@ -25381,7 +25502,7 @@ function createReleaseCompatibilityReport(options = {}) {
25381
25502
  "command -v todos-serve",
25382
25503
  "todos --version",
25383
25504
  "todos --help",
25384
- "todos doctor"
25505
+ "todos doctor --no-fail-on-findings"
25385
25506
  ],
25386
25507
  rollback: [
25387
25508
  "npm view @hasna/todos versions --json",
@@ -29027,6 +29148,211 @@ var init_auto_assign = __esm(() => {
29027
29148
  init_agents();
29028
29149
  });
29029
29150
 
29151
+ // src/lib/integrity.ts
29152
+ function resolveIntegritySeverity(spec, measurement) {
29153
+ if (spec.kind === "dangling")
29154
+ return "error";
29155
+ if (spec.escalate_when_open && (measurement.open_count ?? 0) > 0)
29156
+ return "error";
29157
+ return spec.base_severity;
29158
+ }
29159
+ function plural(entity, count) {
29160
+ const [one, many] = ENTITY_LABEL[entity];
29161
+ return count === 1 ? one : many;
29162
+ }
29163
+ function formatIntegrityMessage(spec, measurement) {
29164
+ const noun = plural(spec.entity, measurement.count);
29165
+ const verb = measurement.count === 1 ? ["has", "references"] : ["have", "reference"];
29166
+ const open = measurement.open_count === null || measurement.open_count === 0 ? "" : ` (${measurement.open_count} still open)`;
29167
+ 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}`;
29168
+ }
29169
+ function measuredCondition(spec, measurement, source) {
29170
+ return {
29171
+ id: spec.id,
29172
+ entity: spec.entity,
29173
+ field: spec.field,
29174
+ kind: spec.kind,
29175
+ count: measurement.count,
29176
+ open_count: measurement.open_count,
29177
+ severity: measurement.count > 0 ? resolveIntegritySeverity(spec, measurement) : null,
29178
+ verified: true,
29179
+ source,
29180
+ message: formatIntegrityMessage(spec, measurement),
29181
+ impact: spec.impact
29182
+ };
29183
+ }
29184
+ function unverifiedCondition(spec, reason) {
29185
+ return {
29186
+ id: spec.id,
29187
+ entity: spec.entity,
29188
+ field: spec.field,
29189
+ kind: spec.kind,
29190
+ count: null,
29191
+ open_count: null,
29192
+ severity: null,
29193
+ verified: false,
29194
+ source: "unverified",
29195
+ unverified_reason: reason,
29196
+ message: `${spec.id}: NOT CHECKED \u2014 ${reason}`,
29197
+ impact: spec.impact
29198
+ };
29199
+ }
29200
+ function summarizeIntegrity(conditions) {
29201
+ const measured = conditions.filter((condition) => condition.verified && condition.count !== null);
29202
+ const findings = measured.filter((condition) => (condition.count ?? 0) > 0);
29203
+ const unverified = conditions.length - measured.length;
29204
+ return {
29205
+ ok: unverified === 0 && findings.length === 0,
29206
+ findings: findings.length,
29207
+ rows: findings.reduce((total, condition) => total + (condition.count ?? 0), 0),
29208
+ errors: findings.filter((condition) => condition.severity === "error").length,
29209
+ warnings: findings.filter((condition) => condition.severity === "warn").length,
29210
+ unverified,
29211
+ complete: unverified === 0
29212
+ };
29213
+ }
29214
+ function buildIntegrityReport(conditions, generatedAt) {
29215
+ const measuredSources = [...new Set(conditions.map((condition) => condition.source))].filter((source) => source !== "unverified");
29216
+ return {
29217
+ schema_version: TODOS_INTEGRITY_SCHEMA_VERSION,
29218
+ generated_at: generatedAt,
29219
+ source: measuredSources.length === 0 ? "unverified" : measuredSources.length === 1 ? measuredSources[0] : "remote-derived",
29220
+ conditions,
29221
+ summary: summarizeIntegrity(conditions)
29222
+ };
29223
+ }
29224
+ function sqliteOpenStatusList() {
29225
+ return OPEN_TASK_STATUSES.map((status) => `'${status}'`).join(", ");
29226
+ }
29227
+ function buildSqliteIntegritySql(spec) {
29228
+ const table = SQLITE_TABLE[spec.entity];
29229
+ const target = SQLITE_TABLE[spec.target];
29230
+ const column = `t."${spec.field}"`;
29231
+ 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})`;
29232
+ const openExpr = spec.entity === "task" ? `SUM(CASE WHEN t."status" IN (${sqliteOpenStatusList()}) THEN 1 ELSE 0 END)` : "NULL";
29233
+ return `SELECT COUNT(*) AS count, ${openExpr} AS open_count FROM "${table}" t WHERE ${predicate}`;
29234
+ }
29235
+ function buildPostgresIntegritySql(spec, options) {
29236
+ const params = [options.service, RECORD_TYPE[spec.entity]];
29237
+ const p = (value) => {
29238
+ params.push(value);
29239
+ return `$${params.length}`;
29240
+ };
29241
+ const column = `t.payload->>'${spec.field}'`;
29242
+ 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})`;
29243
+ const openExpr = spec.entity === "task" ? `COUNT(*) FILTER (WHERE t.payload->>'status' IN (${OPEN_TASK_STATUSES.map((status) => p(status)).join(", ")}))::int` : "NULL::int";
29244
+ 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}`;
29245
+ return { sql, params };
29246
+ }
29247
+ var TODOS_INTEGRITY_SCHEMA_VERSION = "todos.integrity.v1", TERMINAL_TASK_STATUSES, OPEN_TASK_STATUSES, INTEGRITY_CONDITIONS, ENTITY_LABEL, SQLITE_TABLE, RECORD_TYPE;
29248
+ var init_integrity = __esm(() => {
29249
+ init_types();
29250
+ TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
29251
+ OPEN_TASK_STATUSES = TASK_STATUSES.filter((status) => !TERMINAL_TASK_STATUSES.includes(status));
29252
+ INTEGRITY_CONDITIONS = [
29253
+ {
29254
+ id: "tasks_without_project",
29255
+ entity: "task",
29256
+ field: "project_id",
29257
+ target: "project",
29258
+ kind: "missing",
29259
+ base_severity: "warn",
29260
+ escalate_when_open: true,
29261
+ impact: "invisible to every project-scoped read (list, status, next, claim)"
29262
+ },
29263
+ {
29264
+ id: "tasks_without_task_list",
29265
+ entity: "task",
29266
+ field: "task_list_id",
29267
+ target: "task_list",
29268
+ kind: "missing",
29269
+ base_severity: "warn",
29270
+ escalate_when_open: true,
29271
+ impact: "invisible to every task-list read \u2014 the list reports zero open work"
29272
+ },
29273
+ {
29274
+ id: "tasks_with_unregistered_project",
29275
+ entity: "task",
29276
+ field: "project_id",
29277
+ target: "project",
29278
+ kind: "dangling",
29279
+ base_severity: "error",
29280
+ escalate_when_open: false,
29281
+ impact: "points at a project id that does not exist; the reference can never resolve"
29282
+ },
29283
+ {
29284
+ id: "tasks_with_unregistered_task_list",
29285
+ entity: "task",
29286
+ field: "task_list_id",
29287
+ target: "task_list",
29288
+ kind: "dangling",
29289
+ base_severity: "error",
29290
+ escalate_when_open: false,
29291
+ impact: "points at a task-list id that does not exist; the reference can never resolve"
29292
+ },
29293
+ {
29294
+ id: "task_lists_without_project",
29295
+ entity: "task_list",
29296
+ field: "project_id",
29297
+ target: "project",
29298
+ kind: "missing",
29299
+ base_severity: "warn",
29300
+ escalate_when_open: false,
29301
+ impact: "unbound list \u2014 unreachable from any project, so its tasks are unroutable"
29302
+ },
29303
+ {
29304
+ id: "task_lists_with_unregistered_project",
29305
+ entity: "task_list",
29306
+ field: "project_id",
29307
+ target: "project",
29308
+ kind: "dangling",
29309
+ base_severity: "error",
29310
+ escalate_when_open: false,
29311
+ impact: "points at a project id that does not exist; the list can never be reached"
29312
+ }
29313
+ ];
29314
+ ENTITY_LABEL = {
29315
+ task: ["task", "tasks"],
29316
+ task_list: ["task list", "task lists"]
29317
+ };
29318
+ SQLITE_TABLE = {
29319
+ task: "tasks",
29320
+ task_list: "task_lists",
29321
+ project: "projects"
29322
+ };
29323
+ RECORD_TYPE = {
29324
+ task: "tasks",
29325
+ task_list: "task_lists",
29326
+ project: "projects"
29327
+ };
29328
+ });
29329
+
29330
+ // src/db/integrity.ts
29331
+ function tableExists(db, table) {
29332
+ return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
29333
+ }
29334
+ function scanSqliteIntegrity(db = getDatabase()) {
29335
+ const missing = Object.keys(REQUIRED_TABLES2).filter((table) => !tableExists(db, table));
29336
+ const conditions = INTEGRITY_CONDITIONS.map((spec) => {
29337
+ if (missing.length > 0) {
29338
+ return unverifiedCondition(spec, `local schema is missing table(s): ${missing.join(", ")}`);
29339
+ }
29340
+ try {
29341
+ const row = db.query(buildSqliteIntegritySql(spec)).get();
29342
+ return measuredCondition(spec, { count: Number(row?.count ?? 0), open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null }, "sqlite");
29343
+ } catch (error) {
29344
+ return unverifiedCondition(spec, error instanceof Error ? error.message : String(error));
29345
+ }
29346
+ });
29347
+ return buildIntegrityReport(conditions, now());
29348
+ }
29349
+ var REQUIRED_TABLES2;
29350
+ var init_integrity2 = __esm(() => {
29351
+ init_database();
29352
+ init_integrity();
29353
+ REQUIRED_TABLES2 = { tasks: true, task_lists: true, projects: true };
29354
+ });
29355
+
29030
29356
  // src/lib/doctor.ts
29031
29357
  var exports_doctor = {};
29032
29358
  __export(exports_doctor, {
@@ -29034,7 +29360,7 @@ __export(exports_doctor, {
29034
29360
  });
29035
29361
  import { chmodSync, copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync5, statSync as statSync5 } from "fs";
29036
29362
  import { basename as basename4, dirname as dirname6, join as join8 } from "path";
29037
- function tableExists(db, table) {
29363
+ function tableExists2(db, table) {
29038
29364
  return Boolean(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table));
29039
29365
  }
29040
29366
  function quoteIdent(identifier) {
@@ -29118,7 +29444,7 @@ function findDuplicateIndexes(db) {
29118
29444
  return duplicates;
29119
29445
  }
29120
29446
  function findMissingProjectRoots(db) {
29121
- if (!tableExists(db, "projects"))
29447
+ if (!tableExists2(db, "projects"))
29122
29448
  return 0;
29123
29449
  let missing = 0;
29124
29450
  const rows = db.query("SELECT path FROM projects WHERE path IS NOT NULL AND path != ''").all();
@@ -29133,7 +29459,7 @@ function findMissingProjectRoots(db) {
29133
29459
  return missing;
29134
29460
  }
29135
29461
  function addTaskStateChecks(db, checks) {
29136
- if (!tableExists(db, "tasks"))
29462
+ if (!tableExists2(db, "tasks"))
29137
29463
  return;
29138
29464
  const columns = getTableColumns(db, "tasks");
29139
29465
  const staleCutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
@@ -29205,15 +29531,42 @@ function createBackup(dbPath) {
29205
29531
  function pushRepair(repairs, type, message, applied, count) {
29206
29532
  repairs.push({ type, message, applied, count });
29207
29533
  }
29208
- function summarize2(checks, repairs) {
29534
+ function summarize2(checks, repairs, integrity) {
29209
29535
  return {
29210
29536
  errors: checks.filter((check) => check.severity === "error").length,
29211
29537
  warnings: checks.filter((check) => check.severity === "warn").length,
29212
29538
  infos: checks.filter((check) => check.severity === "info").length,
29213
29539
  repairable: checks.filter((check) => check.repairable).length,
29214
- applied: repairs.filter((repair) => repair.applied).length
29540
+ applied: repairs.filter((repair) => repair.applied).length,
29541
+ integrity_findings: integrity.summary.findings,
29542
+ integrity_rows: integrity.summary.rows,
29543
+ integrity_unverified: integrity.summary.unverified
29215
29544
  };
29216
29545
  }
29546
+ function addIntegrityChecks(integrity, checks) {
29547
+ for (const condition of integrity.conditions) {
29548
+ if (!condition.verified) {
29549
+ addCheck(checks, {
29550
+ severity: "warn",
29551
+ type: `${condition.id}_unverified`,
29552
+ message: condition.message,
29553
+ repairable: false,
29554
+ integrity: true
29555
+ });
29556
+ continue;
29557
+ }
29558
+ if ((condition.count ?? 0) === 0)
29559
+ continue;
29560
+ addCheck(checks, {
29561
+ severity: condition.severity ?? "warn",
29562
+ type: condition.id,
29563
+ message: `${condition.message} \u2014 ${condition.impact}`,
29564
+ count: condition.count ?? undefined,
29565
+ repairable: false,
29566
+ integrity: true
29567
+ });
29568
+ }
29569
+ }
29217
29570
  function deleteOrphans(db, table, where) {
29218
29571
  const before = countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
29219
29572
  if (before > 0)
@@ -29242,7 +29595,7 @@ function runTodosDoctor(options = {}) {
29242
29595
  message: `Schema at migration ${migrationCurrent}`
29243
29596
  });
29244
29597
  }
29245
- const missingTables = REQUIRED_TABLES2.filter((table) => !tableExists(db, table));
29598
+ const missingTables = REQUIRED_TABLES3.filter((table) => !tableExists2(db, table));
29246
29599
  if (missingTables.length > 0) {
29247
29600
  addCheck(checks, {
29248
29601
  severity: "error",
@@ -29252,7 +29605,7 @@ function runTodosDoctor(options = {}) {
29252
29605
  repairable: true
29253
29606
  });
29254
29607
  }
29255
- 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;
29608
+ 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;
29256
29609
  if (orphanedParents > 0) {
29257
29610
  addCheck(checks, {
29258
29611
  severity: "error",
@@ -29262,7 +29615,7 @@ function runTodosDoctor(options = {}) {
29262
29615
  repairable: true
29263
29616
  });
29264
29617
  }
29265
- 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;
29618
+ 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;
29266
29619
  if (orphanedDependencies > 0) {
29267
29620
  addCheck(checks, {
29268
29621
  severity: "error",
@@ -29281,7 +29634,7 @@ function runTodosDoctor(options = {}) {
29281
29634
  ];
29282
29635
  let orphanedRows = 0;
29283
29636
  for (const [table, where] of orphanTables) {
29284
- if (!tableExists(db, table))
29637
+ if (!tableExists2(db, table))
29285
29638
  continue;
29286
29639
  orphanedRows += countQuery(db, `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`);
29287
29640
  }
@@ -29295,6 +29648,8 @@ function runTodosDoctor(options = {}) {
29295
29648
  });
29296
29649
  }
29297
29650
  addTaskStateChecks(db, checks);
29651
+ const integrity = scanSqliteIntegrity(db);
29652
+ addIntegrityChecks(integrity, checks);
29298
29653
  const corruptJson = findCorruptJsonMetadata(db);
29299
29654
  if (corruptJson.length > 0) {
29300
29655
  addCheck(checks, {
@@ -29353,12 +29708,12 @@ function runTodosDoctor(options = {}) {
29353
29708
  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)");
29354
29709
  pushRepair(repairs, "orphaned_task_parents", "Cleared missing parent references", true, orphanedParents);
29355
29710
  }
29356
- if (orphanedDependencies > 0 && tableExists(db, "task_dependencies")) {
29711
+ if (orphanedDependencies > 0 && tableExists2(db, "task_dependencies")) {
29357
29712
  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)");
29358
29713
  pushRepair(repairs, "orphaned_task_dependencies", "Deleted dependency rows referencing missing tasks", true, count);
29359
29714
  }
29360
29715
  for (const [table, where] of orphanTables) {
29361
- if (!tableExists(db, table))
29716
+ if (!tableExists2(db, table))
29362
29717
  continue;
29363
29718
  const count = deleteOrphans(db, table, where);
29364
29719
  if (count > 0)
@@ -29387,26 +29742,30 @@ function runTodosDoctor(options = {}) {
29387
29742
  }
29388
29743
  }
29389
29744
  }
29390
- const finalChecks = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }).checks : checks;
29391
- const summary = summarize2(finalChecks, repairs);
29745
+ const reread = apply && hasRepairableIssue ? runTodosDoctor({ db, dbPath, apply: false }) : undefined;
29746
+ const finalChecks = reread?.checks ?? checks;
29747
+ const finalIntegrity = reread?.integrity ?? integrity;
29748
+ const summary = summarize2(finalChecks, repairs, finalIntegrity);
29392
29749
  return {
29393
- ok: !finalChecks.some((check) => check.severity === "error"),
29750
+ ok: !finalChecks.some((check) => check.severity === "error") && finalIntegrity.summary.ok,
29394
29751
  dry_run: !apply,
29395
29752
  database_path: dbPath,
29396
29753
  migration: { current: getMigrationLevel(db), expected: migrationExpected },
29397
29754
  backup,
29398
29755
  checks: finalChecks,
29399
29756
  repairs,
29757
+ integrity: finalIntegrity,
29400
29758
  summary
29401
29759
  };
29402
29760
  }
29403
- var REQUIRED_TABLES2;
29761
+ var REQUIRED_TABLES3;
29404
29762
  var init_doctor = __esm(() => {
29405
29763
  init_database();
29764
+ init_integrity2();
29406
29765
  init_migrations();
29407
29766
  init_schema();
29408
29767
  init_recurrence();
29409
- REQUIRED_TABLES2 = [
29768
+ REQUIRED_TABLES3 = [
29410
29769
  "_migrations",
29411
29770
  "projects",
29412
29771
  "tasks",
@@ -29750,7 +30109,7 @@ function limits(input) {
29750
30109
  verification_limit: clamp(input.verification_limit, DEFAULT_LIMITS.verification_limit, 100),
29751
30110
  run_limit: clamp(input.run_limit, DEFAULT_LIMITS.run_limit, 20),
29752
30111
  dependency_limit: clamp(input.dependency_limit, DEFAULT_LIMITS.dependency_limit, 100),
29753
- plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, 100),
30112
+ plan_task_limit: clamp(input.plan_task_limit, DEFAULT_LIMITS.plan_task_limit, MAX_CONTEXT_TASKS),
29754
30113
  max_text_chars: clamp(input.max_text_chars, DEFAULT_LIMITS.max_text_chars, 50000),
29755
30114
  summary_char_limit: clamp(input.summary_char_limit, DEFAULT_LIMITS.summary_char_limit, 4000),
29756
30115
  stale_after_hours: clamp(input.stale_after_hours, DEFAULT_LIMITS.stale_after_hours, 24 * 365)
@@ -29773,6 +30132,13 @@ function taskSummary(task) {
29773
30132
  priority: task.priority
29774
30133
  };
29775
30134
  }
30135
+ function latestPlanTasks(planId, limit, db) {
30136
+ const rows = db.query(`SELECT * FROM tasks
30137
+ WHERE plan_id = ? AND archived_at IS NULL
30138
+ ORDER BY updated_at DESC, created_at DESC, id ASC
30139
+ LIMIT ?`).all(planId, limit);
30140
+ return rows.map(rowToTask);
30141
+ }
29776
30142
  function acceptanceCriteria(task, maxText) {
29777
30143
  const metadata = task.metadata || {};
29778
30144
  const raw = metadata["acceptance_criteria"] ?? metadata["acceptanceCriteria"] ?? metadata["criteria"];
@@ -29870,7 +30236,7 @@ function summarizeSection(pack, section, maxChars) {
29870
30236
  return "No plan was attached.";
29871
30237
  return summarizeStrings([
29872
30238
  `Plan ${pack.plan.name} is ${pack.plan.status}`,
29873
- `${pack.plan.tasks.length} listed plan tasks`,
30239
+ `${pack.plan.tasks.length} of ${pack.plan.total_tasks} plan tasks listed; ${pack.plan.omitted_tasks} omitted`,
29874
30240
  ...pack.plan.tasks.slice(0, 4).map((task) => `${task.status} ${task.short_id || task.id.slice(0, 8)} ${task.title}`)
29875
30241
  ], maxChars);
29876
30242
  }
@@ -30020,7 +30386,8 @@ function createAgentContextPack(input, db) {
30020
30386
  const limit = limits(input);
30021
30387
  const project = task.project_id ? getProject(task.project_id, d) : null;
30022
30388
  const plan = task.plan_id ? getPlan(task.plan_id, d) : null;
30023
- const planTasks = task.plan_id ? listTasks({ plan_id: task.plan_id, limit: limit.plan_task_limit + 1 }, d) : [];
30389
+ const planTasks = task.plan_id ? latestPlanTasks(task.plan_id, limit.plan_task_limit, d) : [];
30390
+ const totalPlanTasks = task.plan_id ? countTasks({ plan_id: task.plan_id }, d) : 0;
30024
30391
  const upstream = getTaskDependencies(task.id, d).map((dep) => taskSummary(getTask(dep.depends_on, d))).filter((item) => Boolean(item));
30025
30392
  const downstream = getTaskDependents(task.id, d).map((dep) => taskSummary(getTask(dep.task_id, d))).filter((item) => Boolean(item));
30026
30393
  const comments = listComments(task.id, d);
@@ -30063,6 +30430,9 @@ function createAgentContextPack(input, db) {
30063
30430
  warnings.push(`${runs.length - selectedRuns.length} older runs omitted`);
30064
30431
  if (fileMap.size > relevantFiles.length)
30065
30432
  warnings.push(`${fileMap.size - relevantFiles.length} relevant files omitted`);
30433
+ if (totalPlanTasks > planTasks.length && plan) {
30434
+ 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>"`);
30435
+ }
30066
30436
  const contextTask = {
30067
30437
  id: task.id,
30068
30438
  short_id: task.short_id,
@@ -30094,8 +30464,9 @@ function createAgentContextPack(input, db) {
30094
30464
  description: truncate2(plan.description, limit.max_text_chars),
30095
30465
  status: plan.status,
30096
30466
  agent_id: plan.agent_id,
30097
- tasks: planTasks.slice(0, limit.plan_task_limit).map(taskSummary).filter((item) => Boolean(item)),
30098
- omitted_tasks: Math.max(0, planTasks.length - limit.plan_task_limit)
30467
+ tasks: planTasks.map(taskSummary).filter((item) => Boolean(item)),
30468
+ total_tasks: totalPlanTasks,
30469
+ omitted_tasks: Math.max(0, totalPlanTasks - planTasks.length)
30099
30470
  } : null,
30100
30471
  acceptance_criteria: acceptanceCriteria(task, limit.max_text_chars),
30101
30472
  dependencies: {
@@ -30205,6 +30576,8 @@ ${pack.task.description}` : null,
30205
30576
  "## Project And Plan",
30206
30577
  pack.project ? `- Project: ${pack.project.name} (${pack.project.path})` : "- Project: none",
30207
30578
  pack.plan ? `- Plan: ${pack.plan.name} (${pack.plan.status})` : "- Plan: none",
30579
+ pack.plan ? `- Plan tasks: ${pack.plan.tasks.length} shown of ${pack.plan.total_tasks} total (${pack.plan.omitted_tasks} omitted)` : null,
30580
+ 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,
30208
30581
  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,
30209
30582
  "",
30210
30583
  "## Acceptance Criteria",
@@ -30285,7 +30658,7 @@ function renderAgentContextPack(pack, format, compact2 = false) {
30285
30658
  return compact2 ? renderAgentContextPackCompactMarkdown(pack) : renderAgentContextPackMarkdown(pack);
30286
30659
  return compact2 ? JSON.stringify(pack) : JSON.stringify(pack, null, 2);
30287
30660
  }
30288
- var DEFAULT_LIMITS, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
30661
+ var DEFAULT_LIMITS, MAX_CONTEXT_TASKS = 24, ALL_CONTEXT_SECTIONS, BUDGET_TRIM_ORDER;
30289
30662
  var init_context_packs = __esm(() => {
30290
30663
  init_comments();
30291
30664
  init_database();
@@ -30304,7 +30677,7 @@ var init_context_packs = __esm(() => {
30304
30677
  verification_limit: 10,
30305
30678
  run_limit: 3,
30306
30679
  dependency_limit: 12,
30307
- plan_task_limit: 20,
30680
+ plan_task_limit: 24,
30308
30681
  max_text_chars: 6000,
30309
30682
  summary_char_limit: 480,
30310
30683
  stale_after_hours: 72
@@ -31353,7 +31726,7 @@ function parseObject2(value) {
31353
31726
  function normalizeKey(value) {
31354
31727
  return value.trim().toLowerCase().replace(/[^a-z0-9._:/-]+/g, "-").replace(/^-+|-+$/g, "");
31355
31728
  }
31356
- function normalizeFingerprint(value) {
31729
+ function normalizeTaskFindingFingerprint(value) {
31357
31730
  const normalized = normalizeKey(value);
31358
31731
  if (!normalized)
31359
31732
  throw new Error("finding fingerprint is required");
@@ -31462,7 +31835,7 @@ function assertTask(taskId, db) {
31462
31835
  throw new TaskNotFoundError(taskId);
31463
31836
  }
31464
31837
  function nextFinding(input, db) {
31465
- const fingerprint2 = normalizeFingerprint(input.fingerprint);
31838
+ const fingerprint2 = normalizeTaskFindingFingerprint(input.fingerprint);
31466
31839
  const title = redactOptional(input.title, 300);
31467
31840
  if (!title)
31468
31841
  throw new Error("finding title is required");
@@ -31610,7 +31983,7 @@ function resolveMissingTaskFindings(input, db) {
31610
31983
  const timestamp4 = input.resolved_at || now();
31611
31984
  const status = normalizeResolutionStatus(input.status);
31612
31985
  const runId = resolveRunForTask(input.run_id, input.task_id, d);
31613
- const present = new Set(input.fingerprints.map(normalizeFingerprint));
31986
+ const present = new Set(input.fingerprints.map(normalizeTaskFindingFingerprint));
31614
31987
  const warnings = [];
31615
31988
  const conditions = ["task_id = ?", "status = 'open'"];
31616
31989
  const params = [input.task_id];
@@ -34198,7 +34571,7 @@ var package_default;
34198
34571
  var init_package = __esm(() => {
34199
34572
  package_default = {
34200
34573
  name: "@hasna/todos",
34201
- version: "0.13.2",
34574
+ version: "0.13.4",
34202
34575
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
34203
34576
  type: "module",
34204
34577
  main: "dist/index.js",
@@ -34232,6 +34605,10 @@ var init_package = __esm(() => {
34232
34605
  "./storage": {
34233
34606
  types: "./dist/storage.d.ts",
34234
34607
  import: "./dist/storage.js"
34608
+ },
34609
+ "./testing": {
34610
+ types: "./dist/testing.d.ts",
34611
+ import: "./dist/testing.js"
34235
34612
  }
34236
34613
  },
34237
34614
  workspaces: [
@@ -34244,8 +34621,8 @@ var init_package = __esm(() => {
34244
34621
  "README.md"
34245
34622
  ],
34246
34623
  scripts: {
34247
- 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",
34248
- "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/*'",
34624
+ 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",
34625
+ "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/*'",
34249
34626
  migrate: "bun run src/server/index.ts migrate",
34250
34627
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
34251
34628
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -43951,15 +44328,15 @@ function resolveTaskRefLocal(db, ref) {
43951
44328
  return null;
43952
44329
  if (TASK_UUID_RE.test(raw))
43953
44330
  return getTask(raw, db);
43954
- const prefixRows = db.query("SELECT id FROM tasks WHERE LOWER(id) LIKE ? ESCAPE '\\' LIMIT 2").all(`${raw.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
44331
+ 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}`)}%`);
43955
44332
  if (prefixRows.length > 1) {
43956
- throw new Error(`Task reference is ambiguous: "${ref}"`);
44333
+ throw new TaskReferenceAmbiguousError(ref, prefixRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
43957
44334
  }
43958
44335
  if (prefixRows.length === 1)
43959
44336
  return getTask(prefixRows[0].id, db);
43960
- const shortIdRows = db.query("SELECT id FROM tasks WHERE LOWER(short_id) = ? LIMIT 2").all(raw);
44337
+ const shortIdRows = db.query("SELECT id, project_id FROM tasks WHERE LOWER(short_id) = ? ORDER BY project_id, id LIMIT 2").all(raw);
43961
44338
  if (shortIdRows.length > 1) {
43962
- throw new Error(`Task reference is ambiguous: "${ref}"`);
44339
+ throw new TaskReferenceAmbiguousError(ref, shortIdRows.map((row) => ({ task_id: row.id, project_id: row.project_id })));
43963
44340
  }
43964
44341
  if (shortIdRows.length === 1)
43965
44342
  return getTask(shortIdRows[0].id, db);
@@ -44108,6 +44485,9 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
44108
44485
  exportSnapshot: () => exportSqliteTodosStorageSnapshot(database()),
44109
44486
  importSnapshot: (snapshot) => importSqliteTodosStorageSnapshot(snapshot, database())
44110
44487
  },
44488
+ integrity: {
44489
+ report: () => scanSqliteIntegrity(database())
44490
+ },
44111
44491
  transaction: (fn) => {
44112
44492
  const tx = database().transaction(() => fn(adapter));
44113
44493
  return tx();
@@ -44117,6 +44497,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
44117
44497
  }
44118
44498
  var TASK_UUID_RE;
44119
44499
  var init_local_sqlite = __esm(() => {
44500
+ init_types();
44120
44501
  init_search();
44121
44502
  init_tasks();
44122
44503
  init_projects();
@@ -44127,6 +44508,7 @@ var init_local_sqlite = __esm(() => {
44127
44508
  init_audit();
44128
44509
  init_comments();
44129
44510
  init_database();
44511
+ init_integrity2();
44130
44512
  init_sqlite_snapshot();
44131
44513
  TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44132
44514
  });
@@ -44994,7 +45376,7 @@ function createPostgresTodosStorageAdapter(options) {
44994
45376
  agents: {
44995
45377
  register: (input, context) => registerAgent2(input, store, context),
44996
45378
  get: (id) => store.get("agents", id),
44997
- getByName: async (name) => (await store.list("agents")).find((agent) => agent.name === name) ?? null,
45379
+ getByName: async (name) => matchAgentByName(await store.list("agents"), name),
44998
45380
  list: async (options2) => (await store.list("agents")).filter((agent) => options2?.include_archived || agent.status !== "archived").sort((a, b) => a.name.localeCompare(b.name)),
44999
45381
  update: (id, input) => updateAgent2(id, input, store),
45000
45382
  heartbeat: (idOrName, context) => heartbeatAgent(idOrName, store, context),
@@ -45051,6 +45433,9 @@ function createPostgresTodosStorageAdapter(options) {
45051
45433
  exportSnapshot: () => exportSnapshot(store),
45052
45434
  importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
45053
45435
  },
45436
+ integrity: {
45437
+ report: () => store.integrityReport()
45438
+ },
45054
45439
  transaction: (fn) => fn(adapter)
45055
45440
  };
45056
45441
  return adapter;
@@ -45157,8 +45542,9 @@ class PostgresJsonRecordStore {
45157
45542
  conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
45158
45543
  if (filter.session_id !== undefined)
45159
45544
  conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
45160
- if (filter.tags?.length)
45161
- conds.push(`payload->'tags' @> ${p(filter.tags)}::jsonb`);
45545
+ if (filter.tags?.length) {
45546
+ 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)})`);
45547
+ }
45162
45548
  if (filter.has_recurrence !== undefined) {
45163
45549
  conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
45164
45550
  }
@@ -45219,7 +45605,8 @@ class PostgresJsonRecordStore {
45219
45605
  AND object_id COLLATE "C" >= $2 AND object_id COLLATE "C" < $3
45220
45606
  LIMIT 2`, [this.service, raw, upper]);
45221
45607
  if (prefixResult.rows.length > 1) {
45222
- throw new Error(`Task reference is ambiguous: "${ref}"`);
45608
+ const candidates = prefixResult.rows.map((row) => payloadRecord2(row.payload));
45609
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task2) => ({ task_id: task2.id, project_id: task2.project_id })));
45223
45610
  }
45224
45611
  if (prefixResult.rows.length === 1) {
45225
45612
  return payloadRecord2(prefixResult.rows[0].payload);
@@ -45230,7 +45617,8 @@ class PostgresJsonRecordStore {
45230
45617
  AND LOWER(payload->>'short_id') = $2
45231
45618
  LIMIT 2`, [this.service, raw]);
45232
45619
  if (shortIdResult.rows.length > 1) {
45233
- throw new Error(`Task reference is ambiguous: "${ref}"`);
45620
+ const candidates = shortIdResult.rows.map((row) => payloadRecord2(row.payload));
45621
+ throw new TaskReferenceAmbiguousError(ref, candidates.map((task2) => ({ task_id: task2.id, project_id: task2.project_id })));
45234
45622
  }
45235
45623
  if (shortIdResult.rows.length === 1) {
45236
45624
  return payloadRecord2(shortIdResult.rows[0].payload);
@@ -45244,6 +45632,24 @@ class PostgresJsonRecordStore {
45244
45632
  const result = await this.options.client.query(sql, params);
45245
45633
  return Number(result.rows[0]?.count ?? 0);
45246
45634
  }
45635
+ async integrityReport() {
45636
+ await this.ensureSchema();
45637
+ const conditions = [];
45638
+ for (const spec of INTEGRITY_CONDITIONS) {
45639
+ const { sql, params } = buildPostgresIntegritySql(spec, { table: this.tableName, service: this.service });
45640
+ try {
45641
+ const result = await this.options.client.query(sql, params);
45642
+ const row = result.rows[0];
45643
+ conditions.push(measuredCondition(spec, {
45644
+ count: Number(row?.count ?? 0),
45645
+ open_count: spec.entity === "task" ? Number(row?.open_count ?? 0) : null
45646
+ }, "postgres"));
45647
+ } catch (error) {
45648
+ conditions.push(unverifiedCondition(spec, error instanceof Error ? error.message : String(error)));
45649
+ }
45650
+ }
45651
+ return buildIntegrityReport(conditions, new Date().toISOString());
45652
+ }
45247
45653
  async listTombstones() {
45248
45654
  await this.ensureSchema();
45249
45655
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
@@ -45719,7 +46125,7 @@ async function startTask2(id, agentId, store) {
45719
46125
  if (task2.status !== "pending" && task2.status !== "in_progress") {
45720
46126
  throw new Error(`Task is ${task2.status} and cannot be started by ${agentId}`);
45721
46127
  }
45722
- return patchTask(task2, {
46128
+ const started = await patchTask(task2, {
45723
46129
  status: "in_progress",
45724
46130
  assigned_to: task2.assigned_to ?? agentId,
45725
46131
  agent_id: task2.agent_id ?? agentId,
@@ -45727,6 +46133,8 @@ async function startTask2(id, agentId, store) {
45727
46133
  locked_at: new Date().toISOString(),
45728
46134
  started_at: task2.started_at ?? new Date().toISOString()
45729
46135
  }, store);
46136
+ await logTaskChange2(task2.id, "start", "status", task2.status, "in_progress", agentId, store);
46137
+ return started;
45730
46138
  }
45731
46139
  async function completeTask2(id, agentId, options, store) {
45732
46140
  const task2 = await store.completeTask(id, agentId, options);
@@ -46029,15 +46437,27 @@ async function updatePlan2(id, input, store) {
46029
46437
  }
46030
46438
  return store.upsert("plans", { ...plan, ...patch, updated_at: new Date().toISOString() });
46031
46439
  }
46440
+ function matchAgentByName(agents, name, options) {
46441
+ const target = normalizeAgentNameInput(name);
46442
+ if (!target)
46443
+ return null;
46444
+ const matches = agents.filter((agent) => normalizeAgentNameInput(agent.name) === target && (options?.includeArchived !== false || agent.status !== "archived"));
46445
+ if (matches.length === 0)
46446
+ return null;
46447
+ return matches.reduce((freshest, candidate) => new Date(candidate.last_seen_at).getTime() > new Date(freshest.last_seen_at).getTime() ? candidate : freshest);
46448
+ }
46032
46449
  async function registerAgent2(input, store, context) {
46033
- const existing = (await store.list("agents")).find((agent2) => agent2.name === input.name && agent2.status !== "archived");
46450
+ const canonicalName = normalizeAgentNameInput(input.name);
46451
+ const existing = matchAgentByName(await store.list("agents"), canonicalName, {
46452
+ includeArchived: false
46453
+ });
46034
46454
  if (existing && !input.force && existing.session_id && existing.session_id !== input.session_id) {
46035
- return { conflict: true, message: `Agent name '${input.name}' is already active` };
46455
+ return { conflict: true, message: `Agent name '${canonicalName}' is already active` };
46036
46456
  }
46037
46457
  const timestamp4 = new Date().toISOString();
46038
46458
  const agent = {
46039
46459
  id: existing?.id ?? randomUUID3().slice(0, 8),
46040
- name: input.name,
46460
+ name: canonicalName,
46041
46461
  description: input.description ?? existing?.description ?? null,
46042
46462
  role: input.role ?? existing?.role ?? null,
46043
46463
  title: input.title ?? existing?.title ?? null,
@@ -46075,7 +46495,7 @@ async function resolveAgent3(idOrName, store) {
46075
46495
  const byId = await store.get("agents", idOrName);
46076
46496
  if (byId)
46077
46497
  return byId;
46078
- return (await store.list("agents")).find((agent) => agent.name === idOrName) ?? null;
46498
+ return matchAgentByName(await store.list("agents"), idOrName);
46079
46499
  }
46080
46500
  async function heartbeatAgent(idOrName, store, context) {
46081
46501
  const agent = await resolveAgent3(idOrName, store);
@@ -46393,6 +46813,7 @@ var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'prior
46393
46813
  var init_postgres_adapter = __esm(() => {
46394
46814
  init_types();
46395
46815
  init_postgres_sync();
46816
+ init_integrity();
46396
46817
  init_redaction();
46397
46818
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
46398
46819
  });
@@ -46981,6 +47402,7 @@ __export(exports_cloud, {
46981
47402
  resolveCloudDatabaseUrl: () => resolveCloudDatabaseUrl,
46982
47403
  pingCloud: () => pingCloud,
46983
47404
  normalizeCloudPayloads: () => normalizeCloudPayloads,
47405
+ isPostgresBackendConfigured: () => isPostgresBackendConfigured,
46984
47406
  isCloudModeEnabled: () => isCloudModeEnabled,
46985
47407
  getCloudVerifier: () => getCloudVerifier,
46986
47408
  getCloudStorageAdapter: () => getCloudStorageAdapter,
@@ -47003,9 +47425,12 @@ function resolveCloudDatabaseUrl(env = process.env) {
47003
47425
  function resolveSigningSecret(env = process.env) {
47004
47426
  return env.HASNA_TODOS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
47005
47427
  }
47006
- function isCloudModeEnabled(env = process.env) {
47428
+ function isPostgresBackendConfigured(env = process.env) {
47007
47429
  return Boolean(resolveCloudDatabaseUrl(env));
47008
47430
  }
47431
+ function isCloudModeEnabled(env = process.env) {
47432
+ return isPostgresBackendConfigured(env);
47433
+ }
47009
47434
  function getClient() {
47010
47435
  if (cachedClient)
47011
47436
  return cachedClient;
@@ -47185,7 +47610,7 @@ function authNotConfiguredMessage(host) {
47185
47610
  `);
47186
47611
  }
47187
47612
  function resolveAuthPosture(input) {
47188
- const hosted = input.hosted ?? isCloudModeEnabled();
47613
+ const hosted = input.hosted ?? isPostgresBackendConfigured();
47189
47614
  const hasCredentialSource = Boolean(input.apiKey) || input.hasGeneratedKeys;
47190
47615
  if (hasCredentialSource) {
47191
47616
  return {
@@ -49146,6 +49571,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49146
49571
  { name: "task_list_id", in: "query", schema: { type: "string" } },
49147
49572
  { name: "assigned_to", in: "query", schema: { type: "string" } },
49148
49573
  { name: "agent_id", in: "query", schema: { type: "string" } },
49574
+ { name: "tags", in: "query", schema: { type: "string" }, description: "Comma-separated tags; matches tasks carrying any of them" },
49149
49575
  { name: "limit", in: "query", schema: { type: "integer", minimum: 1 } },
49150
49576
  { name: "offset", in: "query", schema: { type: "integer", minimum: 0 } }
49151
49577
  ],
@@ -50350,6 +50776,9 @@ async function handleV1Request(req, url, dependencies = {}) {
50350
50776
  ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
50351
50777
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
50352
50778
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
50779
+ ...url.searchParams.get("tags") ? {
50780
+ tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
50781
+ } : {},
50353
50782
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
50354
50783
  ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
50355
50784
  };
@@ -50631,6 +51060,13 @@ async function handleV1Request(req, url, dependencies = {}) {
50631
51060
  try {
50632
51061
  task2 = await store.tasks.resolveRef(id);
50633
51062
  } catch (e) {
51063
+ if (e instanceof TaskReferenceAmbiguousError) {
51064
+ return error(409, e.message, {
51065
+ code: TaskReferenceAmbiguousError.code,
51066
+ candidate_project_ids: e.candidateProjectIds,
51067
+ candidate_task_ids: e.candidateTaskIds
51068
+ });
51069
+ }
50634
51070
  const msg = e.message || "";
50635
51071
  if (/ambiguous/i.test(msg))
50636
51072
  return error(409, msg);
@@ -50988,6 +51424,15 @@ async function handleV1Request(req, url, dependencies = {}) {
50988
51424
  ]);
50989
51425
  return json3({ tasks, tasks_all: tasksAll, subtasks: tasksAll - tasks, projects: projects.length });
50990
51426
  }
51427
+ if (resource === "integrity" && !id) {
51428
+ if (method !== "GET")
51429
+ return error(405, `method ${method} not allowed on /v1/integrity`);
51430
+ if (typeof store.integrity?.report !== "function") {
51431
+ return error(501, "referential-integrity reporting is not supported by this storage backend");
51432
+ }
51433
+ const integrity = await store.integrity.report();
51434
+ return json3({ integrity });
51435
+ }
50991
51436
  if (resource === "import") {
50992
51437
  if (method !== "POST")
50993
51438
  return error(405, `method ${method} not allowed on /v1/import`);
@@ -51007,6 +51452,13 @@ async function handleV1Request(req, url, dependencies = {}) {
51007
51452
  }
51008
51453
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
51009
51454
  } catch (e) {
51455
+ if (e instanceof TaskReferenceAmbiguousError) {
51456
+ return error(409, e.message, {
51457
+ code: TaskReferenceAmbiguousError.code,
51458
+ candidate_project_ids: e.candidateProjectIds,
51459
+ candidate_task_ids: e.candidateTaskIds
51460
+ });
51461
+ }
51010
51462
  if (e instanceof LockError)
51011
51463
  return error(409, e.message, { code: LockError.code });
51012
51464
  if (e instanceof ResourceConflictError)
@@ -51527,23 +51979,24 @@ Dashboard not found at: ${dashboardDir}`);
51527
51979
  }
51528
51980
  if ((path === "/health" || path === "/ready" || path === "/version") && method === "GET") {
51529
51981
  const { getPackageVersion: getPackageVersion2 } = await Promise.resolve().then(() => (init_package_version(), exports_package_version));
51530
- const { isCloudModeEnabled: isCloudModeEnabled2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
51531
- const mode = isCloudModeEnabled2() ? "remote" : "local";
51982
+ const { isPostgresBackendConfigured: isPostgresBackendConfigured2, pingCloud: pingCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
51983
+ const backend = isPostgresBackendConfigured2() ? "postgresql" : "sqlite";
51984
+ const mode = backend === "postgresql" ? "remote" : "local";
51532
51985
  const version = getPackageVersion2();
51533
51986
  if (path === "/version") {
51534
- return Response.json({ status: "ok", version, mode, name: "todos" });
51987
+ return Response.json({ status: "ok", version, mode, backend, name: "todos" });
51535
51988
  }
51536
51989
  if (path === "/ready") {
51537
- if (mode === "remote") {
51990
+ if (backend === "postgresql") {
51538
51991
  try {
51539
51992
  await pingCloud2();
51540
51993
  } catch (e) {
51541
- return Response.json({ status: "unavailable", version, mode, error: e.message }, { status: 503 });
51994
+ return Response.json({ status: "unavailable", version, mode, backend, error: e.message }, { status: 503 });
51542
51995
  }
51543
51996
  }
51544
- return Response.json({ status: "ready", version, mode });
51997
+ return Response.json({ status: "ready", version, mode, backend });
51545
51998
  }
51546
- return Response.json({ status: "ok", version, mode, name: "todos" });
51999
+ return Response.json({ status: "ok", version, mode, backend, name: "todos" });
51547
52000
  }
51548
52001
  if ((path === "/openapi.json" || path === "/v1/openapi.json") && method === "GET") {
51549
52002
  const { buildV1OpenApiDocument: buildV1OpenApiDocument2 } = await Promise.resolve().then(() => (init_openapi(), exports_openapi));
@@ -51899,6 +52352,15 @@ function formatError(error2) {
51899
52352
  if (error2 instanceof TaskNotFoundError) {
51900
52353
  return JSON.stringify({ code: TaskNotFoundError.code, message: error2.message, suggestion: TaskNotFoundError.suggestion });
51901
52354
  }
52355
+ if (error2 instanceof TaskReferenceAmbiguousError) {
52356
+ return JSON.stringify({
52357
+ code: TaskReferenceAmbiguousError.code,
52358
+ message: error2.message,
52359
+ candidate_project_ids: error2.candidateProjectIds,
52360
+ candidate_task_ids: error2.candidateTaskIds,
52361
+ suggestion: "Use a full task UUID."
52362
+ });
52363
+ }
51902
52364
  if (error2 instanceof ProjectNotFoundError) {
51903
52365
  return JSON.stringify({ code: ProjectNotFoundError.code, message: error2.message, suggestion: ProjectNotFoundError.suggestion });
51904
52366
  }