@hasna/todos 0.11.85 → 0.11.87

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 (39) hide show
  1. package/dist/cli/cloud-router.d.ts +55 -2
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/agent-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/task-commands.d.ts +2 -0
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +1106 -417
  7. package/dist/contracts.js +1 -1
  8. package/dist/db/comments.d.ts.map +1 -1
  9. package/dist/index.js +180 -5
  10. package/dist/mcp/index.js +483 -39
  11. package/dist/registry.js +1 -1
  12. package/dist/release-provenance.json +3 -3
  13. package/dist/sdk/index.d.ts +1 -1
  14. package/dist/sdk/index.d.ts.map +1 -1
  15. package/dist/sdk/index.js +21 -0
  16. package/dist/sdk/v1.generated.d.ts +54 -0
  17. package/dist/sdk/v1.generated.d.ts.map +1 -1
  18. package/dist/server/cloud.d.ts +13 -0
  19. package/dist/server/cloud.d.ts.map +1 -1
  20. package/dist/server/index.js +900 -384
  21. package/dist/server/openapi.d.ts +171 -0
  22. package/dist/server/openapi.d.ts.map +1 -1
  23. package/dist/server/v1.d.ts +7 -1
  24. package/dist/server/v1.d.ts.map +1 -1
  25. package/dist/storage/comment-redaction-backfill.d.ts +32 -0
  26. package/dist/storage/comment-redaction-backfill.d.ts.map +1 -0
  27. package/dist/storage/index.d.ts +4 -2
  28. package/dist/storage/index.d.ts.map +1 -1
  29. package/dist/storage/interfaces.d.ts +23 -0
  30. package/dist/storage/interfaces.d.ts.map +1 -1
  31. package/dist/storage/local-sqlite.d.ts.map +1 -1
  32. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  33. package/dist/storage/postgres-sync.d.ts +6 -0
  34. package/dist/storage/postgres-sync.d.ts.map +1 -1
  35. package/dist/storage.d.ts +3 -3
  36. package/dist/storage.d.ts.map +1 -1
  37. package/dist/storage.js +184 -5
  38. package/package.json +2 -1
  39. package/vendor/hasna-contracts-0.5.1.tgz +0 -0
package/dist/mcp/index.js CHANGED
@@ -14085,7 +14085,7 @@ function getComment(id, db) {
14085
14085
  }
14086
14086
  function listComments(taskId, db) {
14087
14087
  const d = db || getDatabase();
14088
- return d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(taskId);
14088
+ return d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at, rowid").all(taskId);
14089
14089
  }
14090
14090
  function updateComment(id, input, db) {
14091
14091
  const d = db || getDatabase();
@@ -22792,14 +22792,18 @@ function unwrapTask(raw) {
22792
22792
  }
22793
22793
  function toListQuery(filter = {}) {
22794
22794
  const query = {};
22795
- if (typeof filter.status === "string")
22796
- query["status"] = filter.status;
22797
- if (typeof filter.priority === "string")
22798
- query["priority"] = filter.priority;
22795
+ if (filter.status)
22796
+ query["status"] = Array.isArray(filter.status) ? filter.status.join(",") : filter.status;
22797
+ if (filter.priority)
22798
+ query["priority"] = Array.isArray(filter.priority) ? filter.priority.join(",") : filter.priority;
22799
22799
  if (filter.project_id)
22800
22800
  query["project_id"] = filter.project_id;
22801
+ if (filter.parent_id !== undefined)
22802
+ query["parent_id"] = filter.parent_id ?? "";
22801
22803
  if (filter.plan_id)
22802
22804
  query["plan_id"] = filter.plan_id;
22805
+ if (filter.task_list_id)
22806
+ query["task_list_id"] = filter.task_list_id;
22803
22807
  if (filter.assigned_to)
22804
22808
  query["assigned_to"] = filter.assigned_to;
22805
22809
  if (filter.agent_id)
@@ -22849,10 +22853,19 @@ async function cloudListProjects(client) {
22849
22853
  }
22850
22854
  async function cloudAddComment(client, taskId, input) {
22851
22855
  const raw = await client.transport.post(`/tasks/${encodeURIComponent(taskId)}/comments`, input);
22852
- if (raw && typeof raw === "object" && "comment" in raw) {
22853
- return raw.comment;
22854
- }
22855
- return raw;
22856
+ const comment = raw && typeof raw === "object" && "comment" in raw ? raw.comment : raw;
22857
+ if (!isTaskComment(comment))
22858
+ throw new Error("Invalid cloud comment response");
22859
+ return redactComment(comment);
22860
+ }
22861
+ function isTaskComment(value) {
22862
+ if (!value || typeof value !== "object" || Array.isArray(value))
22863
+ return false;
22864
+ const comment = value;
22865
+ return typeof comment["id"] === "string" && typeof comment["task_id"] === "string" && (comment["agent_id"] === null || typeof comment["agent_id"] === "string") && (comment["session_id"] === null || typeof comment["session_id"] === "string") && typeof comment["content"] === "string" && (comment["type"] === "comment" || comment["type"] === "progress" || comment["type"] === "note") && (comment["progress_pct"] === null || typeof comment["progress_pct"] === "number") && typeof comment["created_at"] === "string";
22866
+ }
22867
+ function redactComment(comment) {
22868
+ return { ...comment, content: redactEvidenceText(comment.content) };
22856
22869
  }
22857
22870
  async function cloudCountTasks(client, filter = {}) {
22858
22871
  const { limit: _drop, offset: _o, ...rest } = filter;
@@ -22885,6 +22898,7 @@ async function cloudReleaseAgent(client, idOrName, sessionId) {
22885
22898
  var _cache;
22886
22899
  var init_cloud_router = __esm(() => {
22887
22900
  init_storage();
22901
+ init_redaction();
22888
22902
  });
22889
22903
 
22890
22904
  // src/mcp/tools/task-crud.ts
@@ -46148,6 +46162,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
46148
46162
  list: (filter = {}) => listTasks(filter, database()),
46149
46163
  count: (filter = {}) => countTasks(filter, database()),
46150
46164
  update: (id, input) => updateTask(id, input, database()),
46165
+ unlock: (id, agentId) => {
46166
+ unlockTask(id, agentId, database());
46167
+ return true;
46168
+ },
46151
46169
  delete: (id) => deleteTask(id, database()),
46152
46170
  start: (id, agentId) => startTask(id, agentId, database()),
46153
46171
  complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
@@ -46199,6 +46217,20 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
46199
46217
  logTaskChange: (taskId, action, field, oldValue, newValue, agentId) => logTaskChange(taskId, action, field, oldValue, newValue, agentId, database()),
46200
46218
  addComment: (input) => addComment(input, database()),
46201
46219
  getComments: (taskId) => listComments(taskId, database()),
46220
+ getCommentsPage: (taskId, options2) => {
46221
+ if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
46222
+ throw new Error("Comment limit must be an integer between 1 and 1001");
46223
+ }
46224
+ let comments = listComments(taskId, database());
46225
+ comments = comments.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id));
46226
+ if (options2?.before) {
46227
+ const before = options2.before;
46228
+ comments = comments.filter((comment) => comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id);
46229
+ }
46230
+ if (options2?.limit !== undefined)
46231
+ comments = comments.slice(-options2.limit);
46232
+ return comments;
46233
+ },
46202
46234
  getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
46203
46235
  getRecentActivity: (limit) => getRecentActivity(limit, database())
46204
46236
  },
@@ -46256,6 +46288,12 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
46256
46288
  )`
46257
46289
  ];
46258
46290
  }
46291
+ function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
46292
+ assertSafeIdentifier(tableName);
46293
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
46294
+ ON ${tableName} (service, (payload->>'task_id'), (payload->>'created_at'), object_id)
46295
+ WHERE object_type = 'comments' AND deleted_at IS NULL`;
46296
+ }
46259
46297
 
46260
46298
  class PostgresTodosSyncStore {
46261
46299
  client;
@@ -48171,7 +48209,8 @@ function createPostgresTodosStorageAdapter(options) {
48171
48209
  getActiveWork: (filters) => getActiveWork2(filters, store),
48172
48210
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
48173
48211
  lock: (id, agentId) => lockTask2(id, agentId, store),
48174
- unlock: (id, agentId) => unlockTask2(id, agentId, store)
48212
+ unlock: (id, agentId) => unlockTask2(id, agentId, store),
48213
+ getByFingerprint: (fingerprint3) => store.getTaskByFingerprint(fingerprint3)
48175
48214
  },
48176
48215
  dependencies: {
48177
48216
  add: (taskId, dependsOn, context) => addDependency2(taskId, dependsOn, store, context),
@@ -48239,7 +48278,24 @@ function createPostgresTodosStorageAdapter(options) {
48239
48278
  audit: {
48240
48279
  logTaskChange: (taskId, action, field, oldValue, newValue, agentId, context) => logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context),
48241
48280
  addComment: (input, context) => addComment2(input, store, context),
48242
- getComments: async (taskId) => (await store.list("comments")).filter((comment) => comment.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
48281
+ getComments: async (taskId) => {
48282
+ const pages = [];
48283
+ let before;
48284
+ while (true) {
48285
+ const page = await store.listComments(taskId, { limit: 1000, ...before ? { before } : {} });
48286
+ if (page.length === 0)
48287
+ break;
48288
+ pages.unshift(page);
48289
+ if (page.length < 1000)
48290
+ break;
48291
+ const oldest = page[0];
48292
+ before = { created_at: oldest.created_at, id: oldest.id };
48293
+ }
48294
+ return pages.flat().map(redactComment2).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
48295
+ },
48296
+ getCommentsPage: async (taskId, options2) => {
48297
+ return (await store.listComments(taskId, options2)).map(redactComment2).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
48298
+ },
48243
48299
  getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
48244
48300
  getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
48245
48301
  },
@@ -48289,6 +48345,27 @@ class PostgresJsonRecordStore {
48289
48345
  async list(type) {
48290
48346
  return (await this.listRecords(type)).map((record) => record.payload);
48291
48347
  }
48348
+ async listComments(taskId, options = {}) {
48349
+ await this.ensureSchema();
48350
+ const limit = options.limit ?? 100;
48351
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
48352
+ throw new Error("Postgres comment limit must be an integer between 1 and 1001");
48353
+ }
48354
+ const params = [this.service, taskId];
48355
+ let cursorPredicate = "";
48356
+ if (options.before) {
48357
+ params.push(options.before.created_at, options.before.id);
48358
+ cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
48359
+ }
48360
+ params.push(limit);
48361
+ const result = await this.options.client.query(`/* todos:list-comments */ SELECT payload FROM ${this.tableName}
48362
+ WHERE service = $1 AND object_type = 'comments' AND deleted_at IS NULL
48363
+ AND payload->>'task_id' = $2
48364
+ ${cursorPredicate}
48365
+ ORDER BY payload->>'created_at' DESC, object_id DESC
48366
+ LIMIT $${params.length}`, params);
48367
+ return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
48368
+ }
48292
48369
  async listRecords(type) {
48293
48370
  await this.ensureSchema();
48294
48371
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
@@ -48360,6 +48437,17 @@ class PostgresJsonRecordStore {
48360
48437
  const result = await this.options.client.query(sql, params);
48361
48438
  return result.rows.map((row) => payloadRecord2(row.payload));
48362
48439
  }
48440
+ async getTaskByFingerprint(fingerprint3) {
48441
+ await this.ensureSchema();
48442
+ const sql = `/* todos:task-by-fingerprint */ SELECT payload FROM ${this.tableName}
48443
+ WHERE service = $1 AND object_type = $2 AND deleted_at IS NULL
48444
+ AND payload->'metadata'->>'fingerprint' = $3
48445
+ ORDER BY payload->>'created_at' ASC
48446
+ LIMIT 1`;
48447
+ const result = await this.options.client.query(sql, [this.service, "tasks", fingerprint3]);
48448
+ const row = result.rows[0];
48449
+ return row ? payloadRecord2(row.payload) : null;
48450
+ }
48363
48451
  async countTasks(filter) {
48364
48452
  await this.ensureSchema();
48365
48453
  const { where, params } = this.buildTaskFilterSql(filter);
@@ -48671,7 +48759,7 @@ async function lockTask2(id, agentId, store) {
48671
48759
  async function unlockTask2(id, agentId, store) {
48672
48760
  const task2 = await requireRecord("tasks", id, store);
48673
48761
  if (agentId && task2.locked_by && task2.locked_by !== agentId) {
48674
- throw new Error(`Task ${id} is locked by ${task2.locked_by}, not ${agentId}`);
48762
+ throw new LockError(id, task2.locked_by);
48675
48763
  }
48676
48764
  await patchTask(task2, { locked_by: null, locked_at: null }, store);
48677
48765
  return true;
@@ -49036,13 +49124,16 @@ async function addComment2(input, store, context) {
49036
49124
  task_id: input.task_id,
49037
49125
  agent_id: input.agent_id ?? context?.agentId ?? null,
49038
49126
  session_id: input.session_id ?? context?.sessionId ?? null,
49039
- content: input.content,
49127
+ content: redactEvidenceText(input.content),
49040
49128
  type: input.type ?? "comment",
49041
49129
  progress_pct: input.progress_pct ?? null,
49042
49130
  created_at: new Date().toISOString()
49043
49131
  };
49044
49132
  return store.upsert("comments", comment, context);
49045
49133
  }
49134
+ function redactComment2(comment) {
49135
+ return { ...comment, content: redactEvidenceText(comment.content) };
49136
+ }
49046
49137
  async function exportSnapshot(store) {
49047
49138
  return {
49048
49139
  exportedAt: new Date().toISOString(),
@@ -49188,7 +49279,106 @@ function numberValue3(value) {
49188
49279
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
49189
49280
  }
49190
49281
  var CLOUD_LOCK_EXPIRY_MINUTES = 30, TASK_ORDER_BY = "ORDER BY 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";
49191
- var init_postgres_adapter = () => {};
49282
+ var init_postgres_adapter = __esm(() => {
49283
+ init_types();
49284
+ init_redaction();
49285
+ });
49286
+
49287
+ // src/storage/comment-redaction-backfill.ts
49288
+ function assertSafeIdentifier2(value) {
49289
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
49290
+ throw new Error(`Unsafe Postgres identifier: ${value}`);
49291
+ }
49292
+ }
49293
+ function payloadObject(value) {
49294
+ if (value && typeof value === "object" && !Array.isArray(value))
49295
+ return value;
49296
+ if (typeof value !== "string")
49297
+ return null;
49298
+ try {
49299
+ const parsed = JSON.parse(value);
49300
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
49301
+ } catch {
49302
+ return null;
49303
+ }
49304
+ }
49305
+ async function backfillPostgresCommentRedaction(client, options = {}) {
49306
+ const apply = options.apply === true;
49307
+ if (apply && options.confirmation !== COMMENT_REDACTION_BACKFILL_CONFIRMATION) {
49308
+ throw new Error(`Applying the comment redaction backfill requires confirmation ${COMMENT_REDACTION_BACKFILL_CONFIRMATION}`);
49309
+ }
49310
+ const tableName = options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE;
49311
+ assertSafeIdentifier2(tableName);
49312
+ const service = options.service ?? "todos";
49313
+ const batchSize = options.batchSize ?? 100;
49314
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 500) {
49315
+ throw new Error("Comment redaction backfill batchSize must be an integer between 1 and 500");
49316
+ }
49317
+ const result = {
49318
+ dry_run: !apply,
49319
+ scanned: 0,
49320
+ candidates: 0,
49321
+ updated: 0,
49322
+ conflicts: 0,
49323
+ batches: 0,
49324
+ remaining_candidates: 0
49325
+ };
49326
+ let afterId = "";
49327
+ while (true) {
49328
+ const page = await client.query(`/* todos:comment-redaction-backfill-scan */
49329
+ SELECT object_id, payload
49330
+ FROM ${tableName}
49331
+ WHERE service = $1 AND object_type = 'comments'
49332
+ AND object_id > $2
49333
+ ORDER BY object_id ASC
49334
+ LIMIT $3`, [service, afterId, batchSize]);
49335
+ if (page.rows.length === 0)
49336
+ break;
49337
+ result.batches += 1;
49338
+ for (const row of page.rows) {
49339
+ afterId = row.object_id;
49340
+ result.scanned += 1;
49341
+ const payload = payloadObject(row.payload);
49342
+ const original = payload?.["content"];
49343
+ if (typeof original !== "string")
49344
+ continue;
49345
+ const redacted = redactEvidenceText(original);
49346
+ if (redacted === original)
49347
+ continue;
49348
+ result.candidates += 1;
49349
+ if (!apply)
49350
+ continue;
49351
+ const nextPayload = { ...payload, content: redacted };
49352
+ const update = await client.query(`/* todos:comment-redaction-backfill-apply */
49353
+ UPDATE ${tableName}
49354
+ SET payload = $3::jsonb
49355
+ WHERE service = $1 AND object_type = 'comments' AND object_id = $2
49356
+ AND payload = $4::jsonb
49357
+ RETURNING object_id`, [service, row.object_id, nextPayload, row.payload]);
49358
+ if (update.rows.length === 1)
49359
+ result.updated += 1;
49360
+ else
49361
+ result.conflicts += 1;
49362
+ }
49363
+ if (page.rows.length < batchSize)
49364
+ break;
49365
+ }
49366
+ if (!apply) {
49367
+ result.remaining_candidates = result.candidates;
49368
+ return result;
49369
+ }
49370
+ const verification = await backfillPostgresCommentRedaction(client, {
49371
+ ...options,
49372
+ apply: false,
49373
+ confirmation: undefined
49374
+ });
49375
+ result.remaining_candidates = verification.candidates;
49376
+ return result;
49377
+ }
49378
+ var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
49379
+ var init_comment_redaction_backfill = __esm(() => {
49380
+ init_redaction();
49381
+ });
49192
49382
 
49193
49383
  // src/server/cloud.ts
49194
49384
  var exports_cloud = {};
@@ -49202,7 +49392,9 @@ __export(exports_cloud, {
49202
49392
  getCloudStorageAdapter: () => getCloudStorageAdapter,
49203
49393
  getApiKeyStore: () => getApiKeyStore,
49204
49394
  ensureCloudSchema: () => ensureCloudSchema,
49395
+ ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
49205
49396
  closeCloud: () => closeCloud,
49397
+ backfillCloudCommentRedaction: () => backfillCloudCommentRedaction,
49206
49398
  TODOS_APP_SLUG: () => TODOS_APP_SLUG
49207
49399
  });
49208
49400
  function resolveCloudDatabaseUrl(env = process.env) {
@@ -49280,6 +49472,9 @@ async function ensureCloudSchema() {
49280
49472
  })();
49281
49473
  return schemaEnsured;
49282
49474
  }
49475
+ async function ensureCloudCommentCursorIndex() {
49476
+ await getClient().query(postgresTodosCommentCursorIndexSql());
49477
+ }
49283
49478
  async function normalizeCloudPayloads() {
49284
49479
  const client = getClient();
49285
49480
  const res = await client.query(`UPDATE todos_sync_records
@@ -49288,6 +49483,9 @@ async function normalizeCloudPayloads() {
49288
49483
  RETURNING object_id AS id`);
49289
49484
  return res.rows.length;
49290
49485
  }
49486
+ function backfillCloudCommentRedaction(options = {}) {
49487
+ return backfillPostgresCommentRedaction(getClient(), { ...options, service: TODOS_APP_SLUG });
49488
+ }
49291
49489
  async function pingCloud() {
49292
49490
  const client = getClient();
49293
49491
  const res = await client.query("select 1 as ok");
@@ -49308,6 +49506,7 @@ var init_cloud = __esm(() => {
49308
49506
  init_auth();
49309
49507
  init_cloud_client();
49310
49508
  init_postgres_adapter();
49509
+ init_comment_redaction_backfill();
49311
49510
  });
49312
49511
 
49313
49512
  // src/server/openapi.ts
@@ -49331,6 +49530,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49331
49530
  schemas: {
49332
49531
  Task: taskSchema,
49333
49532
  Project: projectSchema,
49533
+ TaskComment: taskCommentSchema,
49334
49534
  CreateTaskInput: {
49335
49535
  type: "object",
49336
49536
  required: ["title"],
@@ -49365,6 +49565,17 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49365
49565
  description: { type: "string" },
49366
49566
  task_prefix: { type: "string" }
49367
49567
  }
49568
+ },
49569
+ CreateTaskCommentInput: {
49570
+ type: "object",
49571
+ required: ["content"],
49572
+ properties: {
49573
+ content: { type: "string", minLength: 1 },
49574
+ agent_id: { type: "string" },
49575
+ session_id: { type: "string" },
49576
+ type: { type: "string", enum: ["comment", "progress", "note"] },
49577
+ progress_pct: { type: "number" }
49578
+ }
49368
49579
  }
49369
49580
  }
49370
49581
  },
@@ -49463,6 +49674,61 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49463
49674
  }
49464
49675
  }
49465
49676
  },
49677
+ "/v1/tasks/{id}/comments": {
49678
+ get: {
49679
+ operationId: "listTaskComments",
49680
+ summary: "List a bounded page of task comments",
49681
+ description: "Returns the newest page in oldest-to-newest display order. Use next_cursor to request older pages; count is the page size, not a total. Pagination-aware clients must send limit during the mixed-version rollout.",
49682
+ parameters: [
49683
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
49684
+ { name: "limit", in: "query", required: true, schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
49685
+ { name: "cursor", in: "query", schema: { type: "string" } }
49686
+ ],
49687
+ responses: {
49688
+ "200": {
49689
+ content: {
49690
+ "application/json": {
49691
+ schema: {
49692
+ type: "object",
49693
+ required: ["comments", "count", "has_more", "next_cursor"],
49694
+ properties: {
49695
+ comments: { type: "array", maxItems: 500, items: { $ref: "#/components/schemas/TaskComment" } },
49696
+ count: { type: "integer", minimum: 0, maximum: 500 },
49697
+ has_more: { type: "boolean" },
49698
+ next_cursor: { type: "string", nullable: true }
49699
+ }
49700
+ }
49701
+ }
49702
+ }
49703
+ },
49704
+ "426": {
49705
+ description: "Upgrade required: a predecessor client omitted limit and the complete legacy history exceeds 500 comments, or the configured storage adapter lacks cursor pagination support."
49706
+ }
49707
+ }
49708
+ },
49709
+ post: {
49710
+ operationId: "createTaskComment",
49711
+ summary: "Create a task comment",
49712
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
49713
+ requestBody: {
49714
+ required: true,
49715
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskCommentInput" } } }
49716
+ },
49717
+ responses: {
49718
+ "201": {
49719
+ content: {
49720
+ "application/json": {
49721
+ schema: {
49722
+ type: "object",
49723
+ required: ["comment"],
49724
+ properties: { comment: { $ref: "#/components/schemas/TaskComment" } }
49725
+ }
49726
+ }
49727
+ }
49728
+ }
49729
+ }
49730
+ }
49731
+ },
49466
49732
  "/v1/tasks/{id}/start": {
49467
49733
  post: {
49468
49734
  operationId: "startTask",
@@ -49581,7 +49847,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
49581
49847
  }
49582
49848
  };
49583
49849
  }
49584
- var taskSchema, projectSchema;
49850
+ var taskSchema, projectSchema, taskCommentSchema;
49585
49851
  var init_openapi = __esm(() => {
49586
49852
  init_package_version();
49587
49853
  taskSchema = {
@@ -49612,6 +49878,20 @@ var init_openapi = __esm(() => {
49612
49878
  updated_at: { type: "string" }
49613
49879
  }
49614
49880
  };
49881
+ taskCommentSchema = {
49882
+ type: "object",
49883
+ required: ["id", "task_id", "agent_id", "session_id", "content", "type", "progress_pct", "created_at"],
49884
+ properties: {
49885
+ id: { type: "string" },
49886
+ task_id: { type: "string" },
49887
+ agent_id: { type: "string", nullable: true },
49888
+ session_id: { type: "string", nullable: true },
49889
+ content: { type: "string" },
49890
+ type: { type: "string", enum: ["comment", "progress", "note"] },
49891
+ progress_pct: { type: "number", nullable: true },
49892
+ created_at: { type: "string", format: "date-time" }
49893
+ }
49894
+ };
49615
49895
  });
49616
49896
 
49617
49897
  // src/server/v1.ts
@@ -49641,6 +49921,29 @@ function contextFromPrincipal(principal, body) {
49641
49921
  const agentId = body?.agent_id || principal.agent || undefined;
49642
49922
  return agentId ? { agentId } : {};
49643
49923
  }
49924
+ function redactComment3(comment) {
49925
+ return { ...comment, content: redactEvidenceText(comment.content) };
49926
+ }
49927
+ function encodeCommentCursor(comment) {
49928
+ return Buffer.from(JSON.stringify({ created_at: comment.created_at, id: comment.id }), "utf8").toString("base64url");
49929
+ }
49930
+ function decodeCommentCursor(value) {
49931
+ if (value.length > 1024)
49932
+ throw new Error("invalid comment cursor");
49933
+ let parsed;
49934
+ try {
49935
+ parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
49936
+ } catch {
49937
+ throw new Error("invalid comment cursor");
49938
+ }
49939
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
49940
+ throw new Error("invalid comment cursor");
49941
+ const cursor = parsed;
49942
+ if (typeof cursor["created_at"] !== "string" || cursor["created_at"].length > 64 || !Number.isFinite(Date.parse(cursor["created_at"])) || typeof cursor["id"] !== "string" || !cursor["id"] || cursor["id"].length > 256) {
49943
+ throw new Error("invalid comment cursor");
49944
+ }
49945
+ return { created_at: cursor["created_at"], id: cursor["id"] };
49946
+ }
49644
49947
  function normalizeImportSnapshot(raw) {
49645
49948
  const body = raw && typeof raw === "object" ? raw : {};
49646
49949
  const arr = (v) => Array.isArray(v) ? v : [];
@@ -49661,7 +49964,7 @@ function normalizeImportSnapshot(raw) {
49661
49964
  function countSnapshotRecords(s) {
49662
49965
  return s.tasks.length + s.projects.length + (s.projectMachinePaths?.length ?? 0) + s.plans.length + s.agents.length + s.taskLists.length + s.templates.length + s.auditHistory.length + (s.tombstones?.length ?? 0);
49663
49966
  }
49664
- async function handleV1Request(req, url) {
49967
+ async function handleV1Request(req, url, dependencies = {}) {
49665
49968
  const path = url.pathname;
49666
49969
  if (path !== "/v1" && !path.startsWith("/v1/"))
49667
49970
  return null;
@@ -49670,7 +49973,7 @@ async function handleV1Request(req, url) {
49670
49973
  const requiredScopes = [isWrite ? "todos:write" : "todos:read"];
49671
49974
  let verifier;
49672
49975
  try {
49673
- verifier = getCloudVerifier();
49976
+ verifier = (dependencies.getVerifier ?? getCloudVerifier)();
49674
49977
  } catch (e) {
49675
49978
  return error(503, e.message);
49676
49979
  }
@@ -49679,8 +49982,8 @@ async function handleV1Request(req, url) {
49679
49982
  return error(decision.status, decision.message, { reason: decision.reason });
49680
49983
  }
49681
49984
  const principal = decision.principal;
49682
- await ensureCloudSchema();
49683
- const store = getCloudStorageAdapter();
49985
+ await (dependencies.ensureSchema ?? ensureCloudSchema)();
49986
+ const store = (dependencies.getStorageAdapter ?? getCloudStorageAdapter)();
49684
49987
  const segments = path.split("/").filter(Boolean);
49685
49988
  const resource = segments[1];
49686
49989
  const id = segments[2];
@@ -49708,13 +50011,74 @@ async function handleV1Request(req, url) {
49708
50011
  missing
49709
50012
  });
49710
50013
  }
50014
+ if (id === "upsert" && !action) {
50015
+ if (method !== "POST")
50016
+ return error(405, `method ${method} not allowed on /v1/tasks/upsert`);
50017
+ if (typeof store.tasks.getByFingerprint !== "function") {
50018
+ return error(501, "fingerprint upsert is not supported by this storage backend");
50019
+ }
50020
+ const body = await readJson(req) ?? {};
50021
+ const fingerprint3 = typeof body.fingerprint === "string" ? body.fingerprint.trim() : "";
50022
+ if (!fingerprint3)
50023
+ return error(400, "fingerprint is required");
50024
+ if (typeof body.title !== "string" || !body.title.trim())
50025
+ return error(400, "title is required");
50026
+ const existing = await store.tasks.getByFingerprint(fingerprint3);
50027
+ const metadata = {
50028
+ ...existing?.metadata ?? {},
50029
+ ...body.metadata ?? {},
50030
+ fingerprint: fingerprint3
50031
+ };
50032
+ const fields = { metadata };
50033
+ for (const key of [
50034
+ "title",
50035
+ "description",
50036
+ "priority",
50037
+ "status",
50038
+ "project_id",
50039
+ "assigned_to",
50040
+ "working_dir",
50041
+ "plan_id",
50042
+ "task_list_id",
50043
+ "tags",
50044
+ "due_at",
50045
+ "estimated_minutes",
50046
+ "sla_minutes",
50047
+ "requires_approval",
50048
+ "recurrence_rule",
50049
+ "task_type"
50050
+ ]) {
50051
+ const bag = body;
50052
+ if (bag[key] !== undefined)
50053
+ fields[key] = bag[key];
50054
+ }
50055
+ if (!existing) {
50056
+ const task2 = await store.tasks.create({ ...fields, title: body.title }, contextFromPrincipal(principal, body));
50057
+ return json2({ task: task2, created: true }, 201);
50058
+ }
50059
+ try {
50060
+ const task2 = await store.tasks.update(existing.id, { ...fields, version: existing.version }, contextFromPrincipal(principal, body));
50061
+ return json2({ task: task2, created: false });
50062
+ } catch (e) {
50063
+ const msg = e.message || "";
50064
+ if (msg.includes("version conflict"))
50065
+ return error(409, msg);
50066
+ throw e;
50067
+ }
50068
+ }
49711
50069
  if (!id) {
49712
50070
  if (method === "GET") {
49713
50071
  const filter = {
49714
- ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
49715
- ...url.searchParams.get("priority") ? { priority: url.searchParams.get("priority") } : {},
50072
+ ...url.searchParams.get("status") ? {
50073
+ status: url.searchParams.get("status").includes(",") ? url.searchParams.get("status").split(",") : url.searchParams.get("status")
50074
+ } : {},
50075
+ ...url.searchParams.get("priority") ? {
50076
+ priority: url.searchParams.get("priority").includes(",") ? url.searchParams.get("priority").split(",") : url.searchParams.get("priority")
50077
+ } : {},
49716
50078
  ...url.searchParams.get("project_id") ? { project_id: url.searchParams.get("project_id") } : {},
50079
+ ...url.searchParams.has("parent_id") ? { parent_id: url.searchParams.get("parent_id") || null, include_subtasks: true } : {},
49717
50080
  ...url.searchParams.get("plan_id") ? { plan_id: url.searchParams.get("plan_id") } : {},
50081
+ ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
49718
50082
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
49719
50083
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
49720
50084
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
@@ -49738,8 +50102,47 @@ async function handleV1Request(req, url) {
49738
50102
  if (action) {
49739
50103
  if (action === "comments") {
49740
50104
  if (method === "GET") {
49741
- const comments = await store.audit.getComments(id);
49742
- return json2({ comments, count: comments.length });
50105
+ if (!await store.tasks.get(id))
50106
+ return error(404, "task not found");
50107
+ const rawLimit = url.searchParams.get("limit");
50108
+ const cursor = url.searchParams.get("cursor");
50109
+ if (rawLimit === null && cursor === null) {
50110
+ const storageContext = contextFromPrincipal(principal);
50111
+ const legacyPage = (await (store.audit.getCommentsPage ? store.audit.getCommentsPage(id, { limit: LEGACY_COMMENT_RESPONSE_LIMIT + 1 }, storageContext) : store.audit.getComments(id, storageContext))).map(redactComment3);
50112
+ if (legacyPage.length > LEGACY_COMMENT_RESPONSE_LIMIT) {
50113
+ return error(426, "task has too many comments for this client; upgrade @hasna/todos to use cursor pagination");
50114
+ }
50115
+ return json2({
50116
+ comments: legacyPage,
50117
+ count: legacyPage.length,
50118
+ has_more: false,
50119
+ next_cursor: null
50120
+ });
50121
+ }
50122
+ const limit = rawLimit === null ? DEFAULT_COMMENT_PAGE_SIZE : Number(rawLimit);
50123
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_COMMENT_PAGE_SIZE) {
50124
+ return error(400, `limit must be an integer between 1 and ${MAX_COMMENT_PAGE_SIZE}`);
50125
+ }
50126
+ let before;
50127
+ if (cursor) {
50128
+ try {
50129
+ before = decodeCommentCursor(cursor);
50130
+ } catch {
50131
+ return error(400, "invalid comment cursor");
50132
+ }
50133
+ }
50134
+ if (!store.audit.getCommentsPage) {
50135
+ return error(426, "storage adapter must be upgraded to support cursor-paginated comments");
50136
+ }
50137
+ const page = (await store.audit.getCommentsPage(id, { limit: limit + 1, ...before ? { before } : {} }, contextFromPrincipal(principal))).map(redactComment3);
50138
+ const hasMore = page.length > limit;
50139
+ const comments = hasMore ? page.slice(1) : page;
50140
+ return json2({
50141
+ comments,
50142
+ count: comments.length,
50143
+ has_more: hasMore,
50144
+ next_cursor: hasMore && comments[0] ? encodeCommentCursor(comments[0]) : null
50145
+ });
49743
50146
  }
49744
50147
  if (method === "POST") {
49745
50148
  const body2 = await readJson(req) ?? {};
@@ -49757,24 +50160,45 @@ async function handleV1Request(req, url) {
49757
50160
  type: body2.type,
49758
50161
  progress_pct: body2.progress_pct
49759
50162
  }, contextFromPrincipal(principal, body2));
49760
- return json2({ comment }, 201);
50163
+ return json2({ comment: redactComment3(comment) }, 201);
49761
50164
  }
49762
50165
  return error(405, `method ${method} not allowed on /v1/tasks/:id/comments`);
49763
50166
  }
50167
+ if (action === "history") {
50168
+ if (method !== "GET")
50169
+ return error(405, `method ${method} not allowed on /v1/tasks/:id/history`);
50170
+ if (!await store.tasks.get(id))
50171
+ return error(404, "task not found");
50172
+ const history = await store.audit.getTaskHistory(id);
50173
+ return json2({ history, count: history.length });
50174
+ }
49764
50175
  if (action === "lock" || action === "unlock") {
49765
50176
  if (method !== "POST")
49766
50177
  return error(405, `method ${method} not allowed on /v1/tasks/:id/${action}`);
49767
- if (typeof store.tasks.lock !== "function" || typeof store.tasks.unlock !== "function") {
49768
- return error(501, "task locking is not supported by this storage backend");
49769
- }
49770
50178
  const body2 = await readJson(req) ?? {};
49771
50179
  if (!await store.tasks.get(id))
49772
50180
  return error(404, "task not found");
49773
50181
  if (action === "lock") {
49774
- const agentId2 = body2.agent_id || principal.agent || "todos-serve";
49775
- return json2({ result: await store.tasks.lock(id, agentId2) });
50182
+ if (typeof store.tasks.lock !== "function")
50183
+ return error(501, "task locking is not supported by this storage backend");
50184
+ const agentId3 = body2.agent_id || principal.agent || "todos-serve";
50185
+ return json2({ result: await store.tasks.lock(id, agentId3) });
49776
50186
  }
49777
- const released = await store.tasks.unlock(id, body2.agent_id || principal.agent || undefined);
50187
+ if (typeof store.tasks.unlock !== "function")
50188
+ return error(501, "task unlocking is not supported by this storage backend");
50189
+ if (body2.force === true) {
50190
+ if (!principal.scopes.includes("todos:*"))
50191
+ return error(403, "force unlock requires todos:* scope");
50192
+ const released2 = await store.tasks.unlock(id);
50193
+ return json2({ success: released2 });
50194
+ }
50195
+ if (body2.agent_id && principal.agent && body2.agent_id !== principal.agent && !principal.scopes.includes("todos:*")) {
50196
+ return error(403, "unlock agent_id must match the authenticated agent");
50197
+ }
50198
+ const agentId2 = principal.agent || body2.agent_id;
50199
+ if (!agentId2)
50200
+ return error(403, "unlock requires an agent-bound key or force=true");
50201
+ const released = await store.tasks.unlock(id, agentId2);
49778
50202
  return json2({ success: released });
49779
50203
  }
49780
50204
  if (action === "dependencies") {
@@ -50057,12 +50481,28 @@ async function handleV1Request(req, url) {
50057
50481
  const activity = await store.audit.getRecentActivity(limit);
50058
50482
  return json2({ activity, count: activity.length });
50059
50483
  }
50060
- if (resource === "task-lists" && !id) {
50061
- if (method !== "GET")
50062
- return error(405, `method ${method} not allowed on /v1/task-lists`);
50063
- const projectId = url.searchParams.get("project_id") ?? undefined;
50064
- const taskLists = await store.taskLists.list(projectId);
50065
- return json2({ task_lists: taskLists, count: taskLists.length });
50484
+ if (resource === "task-lists") {
50485
+ if (!id && method === "GET") {
50486
+ const projectId = url.searchParams.get("project_id") ?? undefined;
50487
+ const taskLists = await store.taskLists.list(projectId);
50488
+ return json2({ task_lists: taskLists, count: taskLists.length });
50489
+ }
50490
+ if (!id && method === "POST") {
50491
+ const body = await readJson(req);
50492
+ if (!body || typeof body.name !== "string" || !body.name.trim())
50493
+ return error(400, "name is required");
50494
+ const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
50495
+ return json2({ task_list: taskList }, 201);
50496
+ }
50497
+ if (id && method === "GET") {
50498
+ const taskList = await store.taskLists.get(id);
50499
+ return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
50500
+ }
50501
+ if (id && method === "DELETE") {
50502
+ const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
50503
+ return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
50504
+ }
50505
+ return error(405, `method ${method} not allowed on /v1/task-lists${id ? "/:id" : ""}`);
50066
50506
  }
50067
50507
  if (resource === "dependencies" && !id) {
50068
50508
  if (method !== "GET")
@@ -50070,8 +50510,8 @@ async function handleV1Request(req, url) {
50070
50510
  if (typeof store.dependencies?.listAll !== "function") {
50071
50511
  return error(501, "dependency edge listing is not supported by this storage backend");
50072
50512
  }
50073
- const dependencies = await store.dependencies.listAll();
50074
- return json2({ dependencies, count: dependencies.length });
50513
+ const dependencies2 = await store.dependencies.listAll();
50514
+ return json2({ dependencies: dependencies2, count: dependencies2.length });
50075
50515
  }
50076
50516
  if (resource === "commits" && id) {
50077
50517
  if (method !== "GET")
@@ -50128,12 +50568,16 @@ async function handleV1Request(req, url) {
50128
50568
  }
50129
50569
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
50130
50570
  } catch (e) {
50571
+ if (e instanceof LockError)
50572
+ return error(409, e.message, { code: LockError.code });
50131
50573
  return error(500, e.message || "internal error");
50132
50574
  }
50133
50575
  }
50134
- var JSON_HEADERS;
50576
+ var JSON_HEADERS, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
50135
50577
  var init_v1 = __esm(() => {
50578
+ init_types();
50136
50579
  init_cloud();
50580
+ init_redaction();
50137
50581
  JSON_HEADERS = { "Content-Type": "application/json" };
50138
50582
  });
50139
50583