@hasna/todos 0.15.47 → 0.15.49

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 (38) hide show
  1. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  2. package/dist/cli/index.js +296 -81
  3. package/dist/contracts.js +56 -5
  4. package/dist/db/task-crud.d.ts +6 -0
  5. package/dist/db/task-crud.d.ts.map +1 -1
  6. package/dist/db/tasks.d.ts +1 -1
  7. package/dist/db/tasks.d.ts.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +187 -70
  11. package/dist/lib/dedupe-projection.d.ts +48 -0
  12. package/dist/lib/dedupe-projection.d.ts.map +1 -0
  13. package/dist/lib/redaction.d.ts.map +1 -1
  14. package/dist/lib/task-dedupe.d.ts +5 -9
  15. package/dist/lib/task-dedupe.d.ts.map +1 -1
  16. package/dist/mcp/index.js +246 -80
  17. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  18. package/dist/mcp.js +3 -2
  19. package/dist/project-registration.js +128 -68
  20. package/dist/registry.js +56 -5
  21. package/dist/release-provenance.json +5 -5
  22. package/dist/server/cloud.d.ts.map +1 -1
  23. package/dist/server/index.js +248 -82
  24. package/dist/server/routes.d.ts.map +1 -1
  25. package/dist/server/v1.d.ts.map +1 -1
  26. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  27. package/dist/storage/postgres-errors.d.ts +15 -0
  28. package/dist/storage/postgres-errors.d.ts.map +1 -0
  29. package/dist/storage/postgres-sync.d.ts +7 -0
  30. package/dist/storage/postgres-sync.d.ts.map +1 -1
  31. package/dist/storage/shadow-outbox.d.ts.map +1 -1
  32. package/dist/storage/shadow.d.ts.map +1 -1
  33. package/dist/storage.js +139 -66
  34. package/dist/task-manifest.js +118 -47
  35. package/dist/task-subtree-transfer.js +326 -44
  36. package/dist/types/index.d.ts +2 -2
  37. package/dist/types/index.d.ts.map +1 -1
  38. package/package.json +3 -2
@@ -1 +1 @@
1
- {"version":3,"file":"query-commands.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/query-commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAiPzC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,QA6sGrD"}
1
+ {"version":3,"file":"query-commands.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/query-commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAmPzC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,QAkwGrD"}
package/dist/cli/index.js CHANGED
@@ -2123,7 +2123,7 @@ var package_default;
2123
2123
  var init_package = __esm(() => {
2124
2124
  package_default = {
2125
2125
  name: "@hasna/todos",
2126
- version: "0.15.47",
2126
+ version: "0.15.49",
2127
2127
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
2128
2128
  type: "module",
2129
2129
  main: "dist/index.js",
@@ -2187,6 +2187,7 @@ var init_package = __esm(() => {
2187
2187
  ],
2188
2188
  scripts: {
2189
2189
  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 && 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 src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
2190
+ "build:js": "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 && 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 src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
2190
2191
  "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 && 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 src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
2191
2192
  migrate: "bun run src/server/index.ts migrate",
2192
2193
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
@@ -2241,7 +2242,7 @@ var init_package = __esm(() => {
2241
2242
  author: "Andrei Hasna <andrei@hasna.com>",
2242
2243
  license: "Apache-2.0",
2243
2244
  dependencies: {
2244
- "@hasna/contracts": "0.13.4",
2245
+ "@hasna/contracts": "0.14.0",
2245
2246
  "@hasna/events": "^0.1.11",
2246
2247
  "@modelcontextprotocol/sdk": "^1.12.1",
2247
2248
  chalk: "^5.4.1",
@@ -2851,13 +2852,15 @@ function upsertSecretSafetyConfig(input) {
2851
2852
  saveConfig({ ...config, secret_safety: next });
2852
2853
  return next;
2853
2854
  }
2854
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
2855
+ var XAI_PREFIX, DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
2855
2856
  var init_redaction = __esm(() => {
2856
2857
  init_config();
2858
+ XAI_PREFIX = ["x", "ai", "-"].join("");
2857
2859
  DEFAULT_SECRET_PATTERNS = [
2858
2860
  { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
2859
2861
  { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
2860
2862
  { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
2863
+ { name: `${XAI_PREFIX}token`, regex: /\bxai[-][A-Za-z0-9]{20,80}\b/g, replacement: "[REDACTED_TOKEN]" },
2861
2864
  { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
2862
2865
  { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
2863
2866
  { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
@@ -18314,6 +18317,12 @@ function listTasks(filter = {}, db) {
18314
18317
  const d = db || getDatabase();
18315
18318
  const { clearExpiredLocks: clearExpiredLocks2 } = (init_database(), __toCommonJS(exports_database));
18316
18319
  clearExpiredLocks2(d);
18320
+ if (filter.limit !== undefined) {
18321
+ const limit = filter.limit;
18322
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_TASK_LIST_LIMIT) {
18323
+ throw new TypeError(`listTasks limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT}; got ${String(limit)}`);
18324
+ }
18325
+ }
18317
18326
  const conditions = [];
18318
18327
  const params = [];
18319
18328
  if (filter.project_id) {
@@ -18847,6 +18856,7 @@ function deleteTask(id, db) {
18847
18856
  }
18848
18857
  return result.changes > 0;
18849
18858
  }
18859
+ var MAX_TASK_LIST_LIMIT = 1e5;
18850
18860
  var init_task_crud = __esm(() => {
18851
18861
  init_types();
18852
18862
  init_database();
@@ -21798,7 +21808,8 @@ __export(exports_tasks, {
21798
21808
  buildTaskBoardSnapshot: () => buildTaskBoardSnapshot,
21799
21809
  archiveTasks: () => archiveTasks,
21800
21810
  archiveCompletedTasks: () => archiveCompletedTasks,
21801
- addDependency: () => addDependency
21811
+ addDependency: () => addDependency,
21812
+ MAX_TASK_LIST_LIMIT: () => MAX_TASK_LIST_LIMIT
21802
21813
  });
21803
21814
  var init_tasks = __esm(() => {
21804
21815
  init_task_crud();
@@ -32238,6 +32249,27 @@ var init_project_commands = __esm(() => {
32238
32249
  };
32239
32250
  });
32240
32251
 
32252
+ // src/storage/postgres-errors.ts
32253
+ function isPostgresUniqueViolation(error) {
32254
+ if (typeof error !== "object" || error === null)
32255
+ return false;
32256
+ const candidate = error;
32257
+ const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
32258
+ if (typeof candidate.cause === "object" && candidate.cause !== null) {
32259
+ const cause = candidate.cause;
32260
+ states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
32261
+ }
32262
+ return states.some((state) => String(state) === "23505");
32263
+ }
32264
+ function postgresConstraintName(error) {
32265
+ if (typeof error !== "object" || error === null)
32266
+ return "";
32267
+ const candidate = error;
32268
+ const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
32269
+ const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
32270
+ return typeof constraint === "string" ? constraint : "";
32271
+ }
32272
+
32241
32273
  // src/storage/postgres-sync.ts
32242
32274
  function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
32243
32275
  assertSafeIdentifier(tableName);
@@ -32432,53 +32464,98 @@ class PostgresTodosSyncStore {
32432
32464
  if (routingErrors.length > 0) {
32433
32465
  throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
32434
32466
  }
32435
- const existing = await this.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
32436
- FROM ${this.tableName}
32437
- WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
32438
- const existingProjects = [];
32439
- const existingTaskLists = [];
32440
- for (const row of existing.rows) {
32441
- const payload = payloadRecord(row.payload);
32442
- if (row.object_type === "projects")
32443
- existingProjects.push(payload);
32444
- if (row.object_type === "task_lists")
32445
- existingTaskLists.push(payload);
32446
- }
32447
- const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
32448
- if (destinationErrors.length > 0) {
32449
- throw new Error(`Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
32450
- }
32451
- const result = { records: 0, objectTypes: {} };
32452
- const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
32453
- for (const entry of snapshotEntries(snapshot)) {
32454
- if (entry.deletedAt === null)
32455
- assertCanonicalScopedSlugEntry(entry);
32456
- await this.client.query(`INSERT INTO ${this.tableName} (
32457
- service, object_type, object_id, payload, updated_at,
32458
- deleted_at, source_machine_id, version
32459
- ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
32460
- ON CONFLICT (service, object_type, object_id) DO UPDATE SET
32461
- payload = EXCLUDED.payload,
32462
- updated_at = EXCLUDED.updated_at,
32463
- deleted_at = EXCLUDED.deleted_at,
32464
- source_machine_id = EXCLUDED.source_machine_id,
32465
- version = EXCLUDED.version
32466
- WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
32467
- OR (${this.tableName}.updated_at = EXCLUDED.updated_at
32468
- AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
32469
- this.service,
32470
- entry.type,
32471
- entry.id,
32472
- entry.payload,
32473
- entry.updatedAt,
32474
- entry.deletedAt,
32475
- sourceMachineId,
32476
- entry.version
32477
- ]);
32478
- result.records += 1;
32479
- result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
32467
+ const push = async (client) => {
32468
+ const existing = await client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
32469
+ FROM ${this.tableName}
32470
+ WHERE service = $1 AND object_type IN ($2, $3) AND deleted_at IS NULL`, [this.service, "projects", "task_lists"]);
32471
+ const existingProjects = [];
32472
+ const existingTaskLists = [];
32473
+ for (const row of existing.rows) {
32474
+ const payload = payloadRecord(row.payload);
32475
+ if (row.object_type === "projects")
32476
+ existingProjects.push(payload);
32477
+ if (row.object_type === "task_lists")
32478
+ existingTaskLists.push(payload);
32479
+ }
32480
+ const destinationErrors = validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists);
32481
+ if (destinationErrors.length > 0) {
32482
+ throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
32483
+ }
32484
+ const result = { records: 0, objectTypes: {} };
32485
+ const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
32486
+ for (const entry of snapshotEntries(snapshot)) {
32487
+ if (entry.deletedAt === null)
32488
+ assertCanonicalScopedSlugEntry(entry);
32489
+ try {
32490
+ await client.query(`INSERT INTO ${this.tableName} (
32491
+ service, object_type, object_id, payload, updated_at,
32492
+ deleted_at, source_machine_id, version
32493
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6::timestamptz, $7, $8)
32494
+ ON CONFLICT (service, object_type, object_id) DO UPDATE SET
32495
+ payload = EXCLUDED.payload,
32496
+ updated_at = EXCLUDED.updated_at,
32497
+ deleted_at = EXCLUDED.deleted_at,
32498
+ source_machine_id = EXCLUDED.source_machine_id,
32499
+ version = EXCLUDED.version
32500
+ WHERE ${this.tableName}.updated_at < EXCLUDED.updated_at
32501
+ OR (${this.tableName}.updated_at = EXCLUDED.updated_at
32502
+ AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))`, [
32503
+ this.service,
32504
+ entry.type,
32505
+ entry.id,
32506
+ entry.payload,
32507
+ entry.updatedAt,
32508
+ entry.deletedAt,
32509
+ sourceMachineId,
32510
+ entry.version
32511
+ ]);
32512
+ } catch (error) {
32513
+ await this.classifySyncInsertConflict(error, entry);
32514
+ }
32515
+ result.records += 1;
32516
+ result.objectTypes[entry.type] = (result.objectTypes[entry.type] ?? 0) + 1;
32517
+ }
32518
+ return result;
32519
+ };
32520
+ if (typeof this.client.transaction === "function") {
32521
+ return this.client.transaction((client) => push(client));
32480
32522
  }
32481
- return result;
32523
+ return push(this.client);
32524
+ }
32525
+ async classifySyncInsertConflict(error, entry) {
32526
+ if (!isPostgresUniqueViolation(error))
32527
+ throw error;
32528
+ const payload = entry.payload;
32529
+ const taskListSlug = String(payload["slug"] ?? "");
32530
+ const projectSlug = String(payload["task_list_id"] ?? "");
32531
+ const constraintName = postgresConstraintName(error);
32532
+ if (entry.type === "projects" && constraintName.includes("project_task_list_slug_uidx")) {
32533
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
32534
+ }
32535
+ if (entry.type === "task_lists" && constraintName.includes("task_list_scope_slug_uidx")) {
32536
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
32537
+ }
32538
+ if (entry.type === "task_lists") {
32539
+ const scope = typeof payload["project_id"] === "string" ? payload["project_id"] : "";
32540
+ const conflict = await this.client.query(`/* todos:classify-sync-task-list-conflict */ SELECT EXISTS (
32541
+ SELECT 1 FROM ${this.tableName}
32542
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id <> $2
32543
+ AND deleted_at IS NULL AND COALESCE(payload->>'project_id','') = $3 AND payload->>'slug' = $4
32544
+ ) AS conflict`, [this.service, entry.id, scope, taskListSlug]);
32545
+ if (conflict.rows[0]?.conflict) {
32546
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${taskListSlug}" already exists in this scope`);
32547
+ }
32548
+ } else if (entry.type === "projects") {
32549
+ const conflict = await this.client.query(`/* todos:classify-sync-project-conflict */ SELECT EXISTS (
32550
+ SELECT 1 FROM ${this.tableName}
32551
+ WHERE service = $1 AND object_type = 'projects' AND object_id <> $2
32552
+ AND deleted_at IS NULL AND payload->>'task_list_id' = $3
32553
+ ) AS conflict`, [this.service, entry.id, projectSlug]);
32554
+ if (conflict.rows[0]?.conflict) {
32555
+ throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${projectSlug}" already exists`);
32556
+ }
32557
+ }
32558
+ throw error;
32482
32559
  }
32483
32560
  async pullSnapshot(options = {}) {
32484
32561
  const params = [this.service];
@@ -32635,6 +32712,7 @@ function assertSafeIdentifier(value) {
32635
32712
  }
32636
32713
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors", PostgresScopedSlugMigrationConflictError, PostgresScopedSlugIndexBuildError;
32637
32714
  var init_postgres_sync = __esm(() => {
32715
+ init_types();
32638
32716
  PostgresScopedSlugMigrationConflictError = class PostgresScopedSlugMigrationConflictError extends Error {
32639
32717
  conflicts;
32640
32718
  constructor(conflicts) {
@@ -35199,25 +35277,6 @@ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
35199
35277
  }
35200
35278
  throw lastError;
35201
35279
  }
35202
- function isPostgresUniqueViolation(error) {
35203
- if (typeof error !== "object" || error === null)
35204
- return false;
35205
- const candidate = error;
35206
- const states = [candidate.code, candidate.errno, candidate.sqlState, candidate.sqlstate];
35207
- if (typeof candidate.cause === "object" && candidate.cause !== null) {
35208
- const cause = candidate.cause;
35209
- states.push(cause.code, cause.errno, cause.sqlState, cause.sqlstate);
35210
- }
35211
- return states.some((state) => String(state) === "23505");
35212
- }
35213
- function postgresConstraintName(error) {
35214
- if (typeof error !== "object" || error === null)
35215
- return "";
35216
- const candidate = error;
35217
- const cause = typeof candidate.cause === "object" && candidate.cause !== null ? candidate.cause : undefined;
35218
- const constraint = candidate.constraint ?? candidate.constraint_name ?? cause?.constraint ?? cause?.constraint_name;
35219
- return typeof constraint === "string" ? constraint : "";
35220
- }
35221
35280
  var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_TIEBREAK = "CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC", TASK_ORDER_BY, TRANSIENT_POSTGRES_SQLSTATES, TRANSIENT_POSTGRES_MESSAGE_MARKERS;
35222
35281
  var init_postgres_adapter = __esm(() => {
35223
35282
  init_types();
@@ -52194,6 +52253,13 @@ function resolveSigningSecret(env = process.env) {
52194
52253
  function isPostgresBackendConfigured(env = process.env) {
52195
52254
  return Boolean(resolveCloudDatabaseUrl(env));
52196
52255
  }
52256
+ function schemaRetryMinIntervalMs(env = process.env) {
52257
+ const raw = env.HASNA_TODOS_SCHEMA_RETRY_MIN_MS;
52258
+ if (!raw)
52259
+ return DEFAULT_SCHEMA_RETRY_MIN_MS;
52260
+ const parsed = Number(raw);
52261
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_SCHEMA_RETRY_MIN_MS;
52262
+ }
52197
52263
  function getCloudTenantId() {
52198
52264
  return process.env.HASNA_TODOS_TENANT_ID ?? "default";
52199
52265
  }
@@ -52289,6 +52355,10 @@ function getCloudVerifier() {
52289
52355
  async function ensureCloudSchema() {
52290
52356
  if (schemaEnsured)
52291
52357
  return schemaEnsured;
52358
+ if (lastSchemaFailure !== null && Date.now() - lastSchemaAttemptAtMs < schemaRetryMinIntervalMs()) {
52359
+ throw lastSchemaFailure;
52360
+ }
52361
+ lastSchemaAttemptAtMs = Date.now();
52292
52362
  schemaEnsured = (async () => {
52293
52363
  const client = getClient();
52294
52364
  for (const sql of postgresTodosSyncSchemaSql()) {
@@ -52309,6 +52379,7 @@ async function ensureCloudSchema() {
52309
52379
  await getApiKeyStore().ensureSchema();
52310
52380
  })().catch((error) => {
52311
52381
  schemaEnsured = null;
52382
+ lastSchemaFailure = error;
52312
52383
  throw error;
52313
52384
  });
52314
52385
  return schemaEnsured;
@@ -52356,8 +52427,10 @@ async function closeCloud() {
52356
52427
  cachedTaskManifestAuthority = null;
52357
52428
  cachedTaskSubtreeTransferAuthority = null;
52358
52429
  schemaEnsured = null;
52430
+ lastSchemaAttemptAtMs = 0;
52431
+ lastSchemaFailure = null;
52359
52432
  }
52360
- var TODOS_APP_SLUG = "todos", cachedClient = null, cachedAdapter = null, cachedStore = null, cachedVerifier = null, cachedPrGroupLedger = null, cachedProjectRegistrationAuthority = null, cachedTaskManifestAuthority = null, cachedTaskSubtreeTransferAuthority = null, schemaEnsured = null;
52433
+ var TODOS_APP_SLUG = "todos", cachedClient = null, cachedAdapter = null, cachedStore = null, cachedVerifier = null, cachedPrGroupLedger = null, cachedProjectRegistrationAuthority = null, cachedTaskManifestAuthority = null, cachedTaskSubtreeTransferAuthority = null, schemaEnsured = null, lastSchemaAttemptAtMs = 0, lastSchemaFailure = null, DEFAULT_SCHEMA_RETRY_MIN_MS = 1e4;
52361
52434
  var init_cloud = __esm(() => {
52362
52435
  init_cloud_client();
52363
52436
  init_postgres_adapter();
@@ -53223,6 +53296,19 @@ function taskStatusQueryParam(url) {
53223
53296
  return { ok: false, message: result.message };
53224
53297
  return { ok: true, value: collapseEnumValues(result.values) };
53225
53298
  }
53299
+ function parsePaginationQueryParam(url, name) {
53300
+ const raw = url.searchParams.get(name);
53301
+ if (raw === null)
53302
+ return { ok: true, value: undefined };
53303
+ const min = name === "limit" ? 1 : 0;
53304
+ const message = name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer";
53305
+ if (!/^\d+$/.test(raw))
53306
+ return { ok: false, message };
53307
+ const value = Number(raw);
53308
+ if (!Number.isSafeInteger(value) || value < min)
53309
+ return { ok: false, message };
53310
+ return { ok: true, value };
53311
+ }
53226
53312
  async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
53227
53313
  const statusParam = taskStatusQueryParam(url);
53228
53314
  if (!statusParam.ok)
@@ -53230,16 +53316,20 @@ async function handleListTasks(_req, url, _ctx, json5, taskToSummary2) {
53230
53316
  const projectId = url.searchParams.get("project_id") || undefined;
53231
53317
  const sessionId = url.searchParams.get("session_id") || undefined;
53232
53318
  const agentId = url.searchParams.get("agent_id") || undefined;
53233
- const limitParam = url.searchParams.get("limit");
53234
- const offsetParam = url.searchParams.get("offset");
53319
+ const limitParam = parsePaginationQueryParam(url, "limit");
53320
+ if (!limitParam.ok)
53321
+ return json5({ error: limitParam.message }, 400);
53322
+ const offsetParam = parsePaginationQueryParam(url, "offset");
53323
+ if (!offsetParam.ok)
53324
+ return json5({ error: offsetParam.message }, 400);
53235
53325
  const fields = parseFieldsParam(url);
53236
53326
  const tasks = listTasks({
53237
53327
  status: statusParam.value,
53238
53328
  project_id: projectId,
53239
53329
  session_id: sessionId,
53240
53330
  agent_id: agentId,
53241
- limit: limitParam ? parseInt(limitParam, 10) : undefined,
53242
- offset: offsetParam ? parseInt(offsetParam, 10) : undefined
53331
+ limit: limitParam.value,
53332
+ offset: offsetParam.value
53243
53333
  });
53244
53334
  return json5(tasks.map((t) => taskToSummary2(t, fields)));
53245
53335
  }
@@ -54031,6 +54121,12 @@ class TodosShadowOutbox {
54031
54121
  this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
54032
54122
  } catch (error) {
54033
54123
  const message = error instanceof Error ? error.message : String(error);
54124
+ if (error instanceof ResourceConflictError) {
54125
+ this.metrics.lastError = message;
54126
+ this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [row.attempts + 1, message, row.seq, row.revision]);
54127
+ this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
54128
+ return;
54129
+ }
54034
54130
  this.metrics.retries += 1;
54035
54131
  this.metrics.lastError = message;
54036
54132
  const attempts = row.attempts + 1;
@@ -54158,6 +54254,7 @@ function createTodosShadowOutbox(options) {
54158
54254
  }
54159
54255
  var MAX_BACKOFF_MS;
54160
54256
  var init_shadow_outbox = __esm(() => {
54257
+ init_types();
54161
54258
  init_local_sqlite();
54162
54259
  init_postgres_sync();
54163
54260
  init_shadow_outbox_schema();
@@ -58132,6 +58229,26 @@ function parseSinceCursor(raw) {
58132
58229
  }
58133
58230
  return { ok: true, value: new Date(parsed).toISOString() };
58134
58231
  }
58232
+ function paginationQueryParam(url, name) {
58233
+ const raw = url.searchParams.get(name);
58234
+ if (raw === null)
58235
+ return { ok: true, value: undefined };
58236
+ const min = name === "limit" ? 1 : 0;
58237
+ if (!/^\d+$/.test(raw)) {
58238
+ return {
58239
+ ok: false,
58240
+ response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
58241
+ };
58242
+ }
58243
+ const value = Number(raw);
58244
+ if (!Number.isSafeInteger(value) || value < min) {
58245
+ return {
58246
+ ok: false,
58247
+ response: error(400, name === "limit" ? "limit must be a positive integer" : "offset must be a non-negative integer")
58248
+ };
58249
+ }
58250
+ return { ok: true, value };
58251
+ }
58135
58252
  function validateTaskCompletion(value) {
58136
58253
  if (!value || typeof value !== "object" || Array.isArray(value))
58137
58254
  return { ok: false, message: "completion body must be an object" };
@@ -58660,6 +58777,12 @@ async function handleV1Request(req, url, dependencies = {}) {
58660
58777
  if (updatedAfter !== null && !updatedAfter.ok) {
58661
58778
  return error(400, updatedAfter.message);
58662
58779
  }
58780
+ const limitParam = paginationQueryParam(url, "limit");
58781
+ if (!limitParam.ok)
58782
+ return limitParam.response;
58783
+ const offsetParam = paginationQueryParam(url, "offset");
58784
+ if (!offsetParam.ok)
58785
+ return offsetParam.response;
58663
58786
  const filter = {
58664
58787
  ...updatedAfter !== null && updatedAfter.ok ? { updated_after: updatedAfter.value } : {},
58665
58788
  ...url.searchParams.get("q") ? { query: url.searchParams.get("q") } : {},
@@ -58676,8 +58799,8 @@ async function handleV1Request(req, url, dependencies = {}) {
58676
58799
  ...url.searchParams.get("tags") ? {
58677
58800
  tags: url.searchParams.get("tags").split(",").map((tag) => tag.trim()).filter(Boolean)
58678
58801
  } : {},
58679
- ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
58680
- ...url.searchParams.get("offset") ? { offset: Number(url.searchParams.get("offset")) } : {}
58802
+ ...limitParam.value !== undefined ? { limit: limitParam.value } : {},
58803
+ ...offsetParam.value !== undefined ? { offset: offsetParam.value } : {}
58681
58804
  };
58682
58805
  const tasks = await store.tasks.list(filter);
58683
58806
  const { limit: _l, offset: _o, ...countFilter } = filter;
@@ -60437,8 +60560,8 @@ function registerTaskCrudTools(server, ctx) {
60437
60560
  tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (AND logic)"),
60438
60561
  created_after: exports_external.string().optional().describe("ISO date \u2014 tasks created after this date"),
60439
60562
  created_before: exports_external.string().optional().describe("ISO date \u2014 tasks created before this date"),
60440
- limit: exports_external.number().optional().describe("Max results (default: 50, max 500)"),
60441
- offset: exports_external.number().optional().describe("Pagination offset"),
60563
+ limit: exports_external.number().int().min(1).optional().describe("Max results (default: 50, max 500)"),
60564
+ offset: exports_external.number().int().min(0).optional().describe("Pagination offset"),
60442
60565
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Exact top-level metadata filters")
60443
60566
  }, async (params) => {
60444
60567
  try {
@@ -62400,6 +62523,48 @@ var init_task_relationships = __esm(() => {
62400
62523
  ];
62401
62524
  });
62402
62525
 
62526
+ // src/lib/dedupe-projection.ts
62527
+ function projectTasksForDedupe(tasks) {
62528
+ return tasks.map((task2) => {
62529
+ const metadata = {};
62530
+ for (const key2 of DEDUPE_SOURCE_KEY_ALLOWLIST) {
62531
+ if (key2 in task2.metadata) {
62532
+ metadata[key2] = redactValue(task2.metadata[key2]);
62533
+ }
62534
+ }
62535
+ return {
62536
+ id: task2.id,
62537
+ short_id: task2.short_id,
62538
+ title: redactEvidenceText(task2.title),
62539
+ description: task2.description === null || task2.description === undefined ? null : redactEvidenceText(task2.description),
62540
+ status: task2.status,
62541
+ created_at: task2.created_at,
62542
+ updated_at: task2.updated_at,
62543
+ project_id: task2.project_id,
62544
+ task_list_id: task2.task_list_id,
62545
+ assigned_to: task2.assigned_to,
62546
+ priority: task2.priority,
62547
+ metadata
62548
+ };
62549
+ });
62550
+ }
62551
+ var DEDUPE_SOURCE_KEY_ALLOWLIST;
62552
+ var init_dedupe_projection = __esm(() => {
62553
+ init_redaction();
62554
+ DEDUPE_SOURCE_KEY_ALLOWLIST = [
62555
+ "github_url",
62556
+ "github_issue_url",
62557
+ "github_pr_url",
62558
+ "source_url",
62559
+ "url",
62560
+ "external_url",
62561
+ "issue_url",
62562
+ "github_owner",
62563
+ "github_repo",
62564
+ "github_number"
62565
+ ];
62566
+ });
62567
+
62403
62568
  // src/lib/task-dedupe.ts
62404
62569
  function asObject(value) {
62405
62570
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -62564,10 +62729,10 @@ function olderFirst(left, right) {
62564
62729
  function findDuplicateTasks(options = {}, db) {
62565
62730
  const d = db || getDatabase();
62566
62731
  const threshold = options.threshold ?? DEFAULT_THRESHOLD;
62567
- const fingerprints = listTasks({
62732
+ const fingerprints = projectTasksForDedupe(listTasks({
62568
62733
  include_archived: Boolean(options.include_archived),
62569
62734
  limit: options.limit ?? 1000
62570
- }, d).map(fingerprint);
62735
+ }, d)).map(fingerprint);
62571
62736
  const candidates = [];
62572
62737
  for (let i = 0;i < fingerprints.length; i++) {
62573
62738
  for (let j = i + 1;j < fingerprints.length; j++) {
@@ -62802,6 +62967,7 @@ var init_task_dedupe = __esm(() => {
62802
62967
  init_database();
62803
62968
  init_task_relationships();
62804
62969
  init_tasks();
62970
+ init_dedupe_projection();
62805
62971
  STOP_WORDS = new Set([
62806
62972
  "a",
62807
62973
  "an",
@@ -87273,13 +87439,25 @@ Findings`));
87273
87439
  handleError(e);
87274
87440
  }
87275
87441
  });
87442
+ function parseDedupeLimit(raw) {
87443
+ const value = Number(raw);
87444
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TASK_LIST_LIMIT) {
87445
+ return null;
87446
+ }
87447
+ return value;
87448
+ }
87276
87449
  const dedupe2 = program2.command("dedupe").description("Find and merge likely duplicate local tasks");
87277
87450
  dedupe2.command("scan").description("Scan local tasks for likely duplicates").option("--threshold <n>", "Minimum duplicate score from 0 to 1", "0.74").option("--limit <n>", "Maximum tasks to compare", "1000").option("--include-archived", "Include archived tasks").option("-j, --json", "Output as JSON").action((opts) => {
87278
87451
  const globalOpts = program2.opts();
87279
87452
  try {
87453
+ const limit = parseDedupeLimit(opts.limit);
87454
+ if (limit === null) {
87455
+ handleError(new Error(`--limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT} (got: ${opts.limit})`));
87456
+ return;
87457
+ }
87280
87458
  const candidates = findDuplicateTasks({
87281
87459
  threshold: Number(opts.threshold),
87282
- limit: Number(opts.limit),
87460
+ limit,
87283
87461
  include_archived: Boolean(opts.includeArchived)
87284
87462
  });
87285
87463
  if (opts.json || globalOpts.json) {
@@ -87299,6 +87477,35 @@ Findings`));
87299
87477
  handleError(e);
87300
87478
  }
87301
87479
  });
87480
+ dedupe2.command("project <ref>").description("Project a project's tasks into the bounded dedup projection (safe to emit: no free-form metadata, tags, comments, or run fields)").option("--limit <n>", "Maximum tasks to project", "1000").option("--include-archived", "Include archived tasks").option("-j, --json", "Output as JSON").action((ref, opts) => {
87481
+ const globalOpts = program2.opts();
87482
+ try {
87483
+ const d = getDatabase();
87484
+ const projectId = resolvePartialId(d, "projects", ref) || d.query("SELECT id FROM projects WHERE path = ? OR name = ? OR task_list_id = ?").get(ref, ref, ref)?.id;
87485
+ if (!projectId) {
87486
+ handleError(new Error(`Could not resolve project: ${ref}`));
87487
+ return;
87488
+ }
87489
+ const limit = parseDedupeLimit(opts.limit);
87490
+ if (limit === null) {
87491
+ handleError(new Error(`--limit must be a positive integer between 1 and ${MAX_TASK_LIST_LIMIT} (got: ${opts.limit})`));
87492
+ return;
87493
+ }
87494
+ const tasks = listTasks({
87495
+ project_id: projectId,
87496
+ include_archived: Boolean(opts.includeArchived),
87497
+ limit
87498
+ }, d);
87499
+ const projected = projectTasksForDedupe(tasks);
87500
+ if (opts.json || globalOpts.json) {
87501
+ output({ project_id: projectId, count: projected.length, tasks: projected }, true);
87502
+ return;
87503
+ }
87504
+ console.log(chalk9.dim(`${projected.length} task(s) projected for dedupe (bounded \u2014 no free-form metadata, tags, comments, or run fields).`));
87505
+ } catch (e) {
87506
+ handleError(e);
87507
+ }
87508
+ });
87302
87509
  dedupe2.command("merge <primary-task-id> <duplicate-task-id>").description("Merge a duplicate task into a primary task and archive the duplicate").option("--agent <agent>", "Agent ID recording the merge").option("--reason <reason>", "Human-readable merge reason").option("-j, --json", "Output as JSON").action((primaryTaskId, duplicateTaskId, opts) => {
87303
87510
  const globalOpts = program2.opts();
87304
87511
  try {
@@ -87603,6 +87810,7 @@ var init_query_commands = __esm(() => {
87603
87810
  init_audit();
87604
87811
  init_handoffs();
87605
87812
  init_task_dedupe();
87813
+ init_dedupe_projection();
87606
87814
  init_local_fields();
87607
87815
  init_workflow_states();
87608
87816
  init_local_reports();
@@ -95508,6 +95716,12 @@ class TodosShadowMirror {
95508
95716
  const message = error2 instanceof Error ? error2.message : String(error2);
95509
95717
  const objectType2 = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
95510
95718
  const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
95719
+ if (error2 instanceof ResourceConflictError) {
95720
+ this.metrics.failed += 1;
95721
+ this.metrics.lastError = message;
95722
+ this.onEvent?.({ type: "dropped", objectType: objectType2, id, error: message });
95723
+ return;
95724
+ }
95511
95725
  this.metrics.retries += 1;
95512
95726
  this.metrics.lastError = message;
95513
95727
  op.attempts += 1;
@@ -95755,6 +95969,7 @@ function isAgent(value) {
95755
95969
  }
95756
95970
  var SNAPSHOT_TO_OBJECT_TYPE;
95757
95971
  var init_shadow = __esm(() => {
95972
+ init_types();
95758
95973
  init_local_sqlite();
95759
95974
  init_postgres_sync();
95760
95975
  SNAPSHOT_TO_OBJECT_TYPE = {