@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
@@ -701,6 +701,128 @@ var init_cloud_client = __esm(() => {
701
701
  init_config();
702
702
  });
703
703
 
704
+ // src/types/index.ts
705
+ var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
706
+ var init_types = __esm(() => {
707
+ TASK_STATUSES = [
708
+ "pending",
709
+ "in_progress",
710
+ "completed",
711
+ "failed",
712
+ "cancelled"
713
+ ];
714
+ VersionConflictError = class VersionConflictError extends Error {
715
+ taskId;
716
+ expectedVersion;
717
+ actualVersion;
718
+ static code = "VERSION_CONFLICT";
719
+ static suggestion = "Fetch the task with get_task to get the current version before updating.";
720
+ constructor(taskId, expectedVersion, actualVersion) {
721
+ super(`Version conflict for task ${taskId}: expected ${expectedVersion}, got ${actualVersion}`);
722
+ this.taskId = taskId;
723
+ this.expectedVersion = expectedVersion;
724
+ this.actualVersion = actualVersion;
725
+ this.name = "VersionConflictError";
726
+ }
727
+ };
728
+ TaskNotFoundError = class TaskNotFoundError extends Error {
729
+ taskId;
730
+ static code = "TASK_NOT_FOUND";
731
+ static suggestion = "Verify the task ID. Use list_tasks or search_tasks to find the correct ID.";
732
+ constructor(taskId) {
733
+ super(`Task not found: ${taskId}`);
734
+ this.taskId = taskId;
735
+ this.name = "TaskNotFoundError";
736
+ }
737
+ };
738
+ ProjectNotFoundError = class ProjectNotFoundError extends Error {
739
+ projectId;
740
+ static code = "PROJECT_NOT_FOUND";
741
+ static suggestion = "Use list_projects to see available projects.";
742
+ constructor(projectId) {
743
+ super(`Project not found: ${projectId}`);
744
+ this.projectId = projectId;
745
+ this.name = "ProjectNotFoundError";
746
+ }
747
+ };
748
+ PlanNotFoundError = class PlanNotFoundError extends Error {
749
+ planId;
750
+ static code = "PLAN_NOT_FOUND";
751
+ static suggestion = "Use list_plans to see available plans.";
752
+ constructor(planId) {
753
+ super(`Plan not found: ${planId}`);
754
+ this.planId = planId;
755
+ this.name = "PlanNotFoundError";
756
+ }
757
+ };
758
+ LockError = class LockError extends Error {
759
+ taskId;
760
+ lockedBy;
761
+ static code = "LOCK_ERROR";
762
+ static suggestion = "Wait for the lock to expire (30 min) or contact the lock holder.";
763
+ constructor(taskId, lockedBy) {
764
+ super(`Task ${taskId} is locked by ${lockedBy}`);
765
+ this.taskId = taskId;
766
+ this.lockedBy = lockedBy;
767
+ this.name = "LockError";
768
+ }
769
+ };
770
+ AgentNotFoundError = class AgentNotFoundError extends Error {
771
+ agentId;
772
+ static code = "AGENT_NOT_FOUND";
773
+ static suggestion = "Use register_agent to create the agent first, or list_agents to find existing ones.";
774
+ constructor(agentId) {
775
+ super(`Agent not found: ${agentId}`);
776
+ this.agentId = agentId;
777
+ this.name = "AgentNotFoundError";
778
+ }
779
+ };
780
+ TaskListNotFoundError = class TaskListNotFoundError extends Error {
781
+ taskListId;
782
+ static code = "TASK_LIST_NOT_FOUND";
783
+ static suggestion = "Use list_task_lists to see available lists.";
784
+ constructor(taskListId) {
785
+ super(`Task list not found: ${taskListId}`);
786
+ this.taskListId = taskListId;
787
+ this.name = "TaskListNotFoundError";
788
+ }
789
+ };
790
+ DependencyCycleError = class DependencyCycleError extends Error {
791
+ taskId;
792
+ dependsOn;
793
+ static code = "DEPENDENCY_CYCLE";
794
+ static suggestion = "Check the dependency chain with get_task to avoid circular references.";
795
+ constructor(taskId, dependsOn) {
796
+ super(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
797
+ this.taskId = taskId;
798
+ this.dependsOn = dependsOn;
799
+ this.name = "DependencyCycleError";
800
+ }
801
+ };
802
+ CompletionGuardError = class CompletionGuardError extends Error {
803
+ reason;
804
+ retryAfterSeconds;
805
+ static code = "COMPLETION_BLOCKED";
806
+ static suggestion = "Wait for the cooldown period, then retry.";
807
+ constructor(reason, retryAfterSeconds) {
808
+ super(reason);
809
+ this.reason = reason;
810
+ this.retryAfterSeconds = retryAfterSeconds;
811
+ this.name = "CompletionGuardError";
812
+ }
813
+ };
814
+ DispatchNotFoundError = class DispatchNotFoundError extends Error {
815
+ dispatchId;
816
+ static code = "DISPATCH_NOT_FOUND";
817
+ static suggestion = "Check the dispatch ID with list_dispatches.";
818
+ constructor(dispatchId) {
819
+ super(`Dispatch not found: ${dispatchId}`);
820
+ this.dispatchId = dispatchId;
821
+ this.name = "DispatchNotFoundError";
822
+ }
823
+ };
824
+ });
825
+
704
826
  // src/storage/postgres-sync.ts
705
827
  function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE, cursorTableName = DEFAULT_TODOS_POSTGRES_CURSOR_TABLE) {
706
828
  assertSafeIdentifier(tableName);
@@ -730,6 +852,12 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
730
852
  )`
731
853
  ];
732
854
  }
855
+ function postgresTodosCommentCursorIndexSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABLE) {
856
+ assertSafeIdentifier(tableName);
857
+ return `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${tableName}_comment_task_created_idx
858
+ ON ${tableName} (service, (payload->>'task_id'), (payload->>'created_at'), object_id)
859
+ WHERE object_type = 'comments' AND deleted_at IS NULL`;
860
+ }
733
861
 
734
862
  class PostgresTodosSyncStore {
735
863
  client;
@@ -917,6 +1045,203 @@ function assertSafeIdentifier(value) {
917
1045
  }
918
1046
  var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records", DEFAULT_TODOS_POSTGRES_CURSOR_TABLE = "todos_sync_cursors";
919
1047
 
1048
+ // src/lib/sync-utils.ts
1049
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync } from "fs";
1050
+ import { join as join2 } from "path";
1051
+ function getHomeDir() {
1052
+ return process.env["HOME"] || process.env["USERPROFILE"] || "~";
1053
+ }
1054
+ function getTodosGlobalDir() {
1055
+ return join2(getHomeDir(), ".hasna", "todos");
1056
+ }
1057
+ function ensureDir(dir) {
1058
+ if (!existsSync2(dir))
1059
+ mkdirSync(dir, { recursive: true });
1060
+ }
1061
+ function readJsonFile(path) {
1062
+ try {
1063
+ return JSON.parse(readFileSync2(path, "utf-8"));
1064
+ } catch {
1065
+ return null;
1066
+ }
1067
+ }
1068
+ function writeJsonFile(path, data) {
1069
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
1070
+ `);
1071
+ }
1072
+ function appendSyncConflict(metadata, conflict, limit = 5) {
1073
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
1074
+ const next = [conflict, ...current].slice(0, limit);
1075
+ return { ...metadata, sync_conflicts: next };
1076
+ }
1077
+ var HOME;
1078
+ var init_sync_utils = __esm(() => {
1079
+ HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
1080
+ });
1081
+
1082
+ // src/lib/config.ts
1083
+ import { existsSync as existsSync3 } from "fs";
1084
+ import { dirname as dirname2, join as join3 } from "path";
1085
+ function getConfigPath() {
1086
+ return join3(getTodosGlobalDir(), "config.json");
1087
+ }
1088
+ function loadConfig() {
1089
+ if (cached)
1090
+ return cached;
1091
+ if (!existsSync3(getConfigPath())) {
1092
+ cached = {};
1093
+ return cached;
1094
+ }
1095
+ const config = readJsonFile(getConfigPath()) || {};
1096
+ if (typeof config.sync_agents === "string") {
1097
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1098
+ }
1099
+ cached = config;
1100
+ return cached;
1101
+ }
1102
+ function saveConfig(config) {
1103
+ const configPath = getConfigPath();
1104
+ ensureDir(dirname2(configPath));
1105
+ writeJsonFile(configPath, config);
1106
+ cached = config;
1107
+ return config;
1108
+ }
1109
+ function getAgentPoolForProject(workingDir) {
1110
+ const config = loadConfig();
1111
+ if (workingDir && config.project_pools) {
1112
+ let bestKey = null;
1113
+ let bestLen = 0;
1114
+ for (const key of Object.keys(config.project_pools)) {
1115
+ if (workingDir.startsWith(key) && key.length > bestLen) {
1116
+ bestKey = key;
1117
+ bestLen = key.length;
1118
+ }
1119
+ }
1120
+ if (bestKey && config.project_pools[bestKey]) {
1121
+ return config.project_pools[bestKey];
1122
+ }
1123
+ }
1124
+ return config.agent_pool || null;
1125
+ }
1126
+ function getCompletionGuardConfig(projectPath) {
1127
+ const config = loadConfig();
1128
+ const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
1129
+ if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
1130
+ return { ...global, ...config.project_overrides[projectPath].completion_guard };
1131
+ }
1132
+ return global;
1133
+ }
1134
+ var cached = null, GUARD_DEFAULTS;
1135
+ var init_config2 = __esm(() => {
1136
+ init_sync_utils();
1137
+ GUARD_DEFAULTS = {
1138
+ enabled: false,
1139
+ min_work_seconds: 30,
1140
+ max_completions_per_window: 5,
1141
+ window_minutes: 10,
1142
+ cooldown_seconds: 60
1143
+ };
1144
+ });
1145
+
1146
+ // src/lib/redaction.ts
1147
+ function unique(values) {
1148
+ return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
1149
+ }
1150
+ function cloneRegex(regex) {
1151
+ return new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
1152
+ }
1153
+ function customPatterns() {
1154
+ return unique(loadConfig().secret_safety?.redaction_patterns).flatMap((pattern) => {
1155
+ try {
1156
+ return [{ name: `custom:${pattern}`, regex: new RegExp(pattern, "g") }];
1157
+ } catch {
1158
+ return [];
1159
+ }
1160
+ });
1161
+ }
1162
+ function secretPatterns() {
1163
+ return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
1164
+ }
1165
+ function isSecretKey(key) {
1166
+ if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
1167
+ return false;
1168
+ if (DEFAULT_SECRET_KEY_PATTERN.test(key))
1169
+ return true;
1170
+ return unique(loadConfig().secret_safety?.redaction_keys).some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()));
1171
+ }
1172
+ function redactEvidenceText(value) {
1173
+ let redacted = value;
1174
+ for (const pattern of secretPatterns()) {
1175
+ const regex = cloneRegex(pattern.regex);
1176
+ const replacement = pattern.replacement ?? "[REDACTED]";
1177
+ redacted = typeof replacement === "string" ? redacted.replace(regex, replacement) : redacted.replace(regex, replacement);
1178
+ }
1179
+ return redacted;
1180
+ }
1181
+ function redactValue(value) {
1182
+ if (typeof value === "string")
1183
+ return redactEvidenceText(value);
1184
+ if (Array.isArray(value))
1185
+ return value.map(redactValue);
1186
+ if (value && typeof value === "object") {
1187
+ const redacted = {};
1188
+ for (const [key, child] of Object.entries(value)) {
1189
+ if (isSecretKey(key)) {
1190
+ redacted[key] = "[REDACTED]";
1191
+ } else {
1192
+ redacted[key] = redactValue(child);
1193
+ }
1194
+ }
1195
+ return redacted;
1196
+ }
1197
+ return value;
1198
+ }
1199
+ function listSecretFindings(value) {
1200
+ const findings = [];
1201
+ for (const pattern of secretPatterns()) {
1202
+ const matches = value.match(cloneRegex(pattern.regex));
1203
+ if (matches?.length)
1204
+ findings.push({ pattern: pattern.name, count: matches.length });
1205
+ }
1206
+ return findings;
1207
+ }
1208
+ function getSecretSafetyConfig() {
1209
+ return {
1210
+ redaction_patterns: unique(loadConfig().secret_safety?.redaction_patterns),
1211
+ redaction_keys: unique(loadConfig().secret_safety?.redaction_keys)
1212
+ };
1213
+ }
1214
+ function upsertSecretSafetyConfig(input) {
1215
+ const config = loadConfig();
1216
+ const next = {
1217
+ redaction_patterns: unique([...config.secret_safety?.redaction_patterns || [], ...input.redaction_patterns || []]),
1218
+ redaction_keys: unique([...config.secret_safety?.redaction_keys || [], ...input.redaction_keys || []])
1219
+ };
1220
+ saveConfig({ ...config, secret_safety: next });
1221
+ return next;
1222
+ }
1223
+ var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS;
1224
+ var init_redaction = __esm(() => {
1225
+ init_config2();
1226
+ DEFAULT_SECRET_PATTERNS = [
1227
+ { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
1228
+ { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
1229
+ { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
1230
+ { name: "env-secret-assignment", regex: /\b([A-Za-z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*['"]?[^'"\s]{8,}/gi, replacement: "$1=[REDACTED]" },
1231
+ { name: "bearer-token", regex: /\b(bearer)\s+[A-Za-z0-9._~+/=-]{12,}/gi, replacement: "$1 [REDACTED]" }
1232
+ ];
1233
+ DEFAULT_SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password/i;
1234
+ NON_SECRET_USAGE_KEYS = new Set([
1235
+ "tokens",
1236
+ "total_tokens",
1237
+ "token_count",
1238
+ "input_tokens",
1239
+ "output_tokens",
1240
+ "prompt_tokens",
1241
+ "completion_tokens"
1242
+ ]);
1243
+ });
1244
+
920
1245
  // src/storage/postgres-adapter.ts
921
1246
  import { randomUUID } from "crypto";
922
1247
  function createPostgresTodosStorageAdapter(options) {
@@ -945,7 +1270,8 @@ function createPostgresTodosStorageAdapter(options) {
945
1270
  getActiveWork: (filters) => getActiveWork(filters, store),
946
1271
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
947
1272
  lock: (id, agentId) => lockTask(id, agentId, store),
948
- unlock: (id, agentId) => unlockTask(id, agentId, store)
1273
+ unlock: (id, agentId) => unlockTask(id, agentId, store),
1274
+ getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
949
1275
  },
950
1276
  dependencies: {
951
1277
  add: (taskId, dependsOn, context) => addDependency(taskId, dependsOn, store, context),
@@ -1013,7 +1339,24 @@ function createPostgresTodosStorageAdapter(options) {
1013
1339
  audit: {
1014
1340
  logTaskChange: (taskId, action, field, oldValue, newValue, agentId, context) => logTaskChange(taskId, action, field, oldValue, newValue, agentId, store, context),
1015
1341
  addComment: (input, context) => addComment(input, store, context),
1016
- getComments: async (taskId) => (await store.list("comments")).filter((comment) => comment.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
1342
+ getComments: async (taskId) => {
1343
+ const pages = [];
1344
+ let before;
1345
+ while (true) {
1346
+ const page = await store.listComments(taskId, { limit: 1000, ...before ? { before } : {} });
1347
+ if (page.length === 0)
1348
+ break;
1349
+ pages.unshift(page);
1350
+ if (page.length < 1000)
1351
+ break;
1352
+ const oldest = page[0];
1353
+ before = { created_at: oldest.created_at, id: oldest.id };
1354
+ }
1355
+ return pages.flat().map(redactComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
1356
+ },
1357
+ getCommentsPage: async (taskId, options2) => {
1358
+ return (await store.listComments(taskId, options2)).map(redactComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
1359
+ },
1017
1360
  getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
1018
1361
  getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
1019
1362
  },
@@ -1063,6 +1406,27 @@ class PostgresJsonRecordStore {
1063
1406
  async list(type) {
1064
1407
  return (await this.listRecords(type)).map((record) => record.payload);
1065
1408
  }
1409
+ async listComments(taskId, options = {}) {
1410
+ await this.ensureSchema();
1411
+ const limit = options.limit ?? 100;
1412
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
1413
+ throw new Error("Postgres comment limit must be an integer between 1 and 1001");
1414
+ }
1415
+ const params = [this.service, taskId];
1416
+ let cursorPredicate = "";
1417
+ if (options.before) {
1418
+ params.push(options.before.created_at, options.before.id);
1419
+ cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
1420
+ }
1421
+ params.push(limit);
1422
+ const result = await this.options.client.query(`/* todos:list-comments */ SELECT payload FROM ${this.tableName}
1423
+ WHERE service = $1 AND object_type = 'comments' AND deleted_at IS NULL
1424
+ AND payload->>'task_id' = $2
1425
+ ${cursorPredicate}
1426
+ ORDER BY payload->>'created_at' DESC, object_id DESC
1427
+ LIMIT $${params.length}`, params);
1428
+ return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
1429
+ }
1066
1430
  async listRecords(type) {
1067
1431
  await this.ensureSchema();
1068
1432
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
@@ -1134,6 +1498,17 @@ class PostgresJsonRecordStore {
1134
1498
  const result = await this.options.client.query(sql, params);
1135
1499
  return result.rows.map((row) => payloadRecord2(row.payload));
1136
1500
  }
1501
+ async getTaskByFingerprint(fingerprint) {
1502
+ await this.ensureSchema();
1503
+ const sql = `/* todos:task-by-fingerprint */ SELECT payload FROM ${this.tableName}
1504
+ WHERE service = $1 AND object_type = $2 AND deleted_at IS NULL
1505
+ AND payload->'metadata'->>'fingerprint' = $3
1506
+ ORDER BY payload->>'created_at' ASC
1507
+ LIMIT 1`;
1508
+ const result = await this.options.client.query(sql, [this.service, "tasks", fingerprint]);
1509
+ const row = result.rows[0];
1510
+ return row ? payloadRecord2(row.payload) : null;
1511
+ }
1137
1512
  async countTasks(filter) {
1138
1513
  await this.ensureSchema();
1139
1514
  const { where, params } = this.buildTaskFilterSql(filter);
@@ -1445,7 +1820,7 @@ async function lockTask(id, agentId, store) {
1445
1820
  async function unlockTask(id, agentId, store) {
1446
1821
  const task = await requireRecord("tasks", id, store);
1447
1822
  if (agentId && task.locked_by && task.locked_by !== agentId) {
1448
- throw new Error(`Task ${id} is locked by ${task.locked_by}, not ${agentId}`);
1823
+ throw new LockError(id, task.locked_by);
1449
1824
  }
1450
1825
  await patchTask(task, { locked_by: null, locked_at: null }, store);
1451
1826
  return true;
@@ -1810,13 +2185,16 @@ async function addComment(input, store, context) {
1810
2185
  task_id: input.task_id,
1811
2186
  agent_id: input.agent_id ?? context?.agentId ?? null,
1812
2187
  session_id: input.session_id ?? context?.sessionId ?? null,
1813
- content: input.content,
2188
+ content: redactEvidenceText(input.content),
1814
2189
  type: input.type ?? "comment",
1815
2190
  progress_pct: input.progress_pct ?? null,
1816
2191
  created_at: new Date().toISOString()
1817
2192
  };
1818
2193
  return store.upsert("comments", comment, context);
1819
2194
  }
2195
+ function redactComment(comment) {
2196
+ return { ...comment, content: redactEvidenceText(comment.content) };
2197
+ }
1820
2198
  async function exportSnapshot(store) {
1821
2199
  return {
1822
2200
  exportedAt: new Date().toISOString(),
@@ -1962,7 +2340,115 @@ function numberValue2(value) {
1962
2340
  return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
1963
2341
  }
1964
2342
  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";
1965
- var init_postgres_adapter = () => {};
2343
+ var init_postgres_adapter = __esm(() => {
2344
+ init_types();
2345
+ init_redaction();
2346
+ });
2347
+
2348
+ // src/storage/comment-redaction-backfill.ts
2349
+ var exports_comment_redaction_backfill = {};
2350
+ __export(exports_comment_redaction_backfill, {
2351
+ isCommentRedactionBackfillComplete: () => isCommentRedactionBackfillComplete,
2352
+ backfillPostgresCommentRedaction: () => backfillPostgresCommentRedaction,
2353
+ COMMENT_REDACTION_BACKFILL_CONFIRMATION: () => COMMENT_REDACTION_BACKFILL_CONFIRMATION
2354
+ });
2355
+ function assertSafeIdentifier2(value) {
2356
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
2357
+ throw new Error(`Unsafe Postgres identifier: ${value}`);
2358
+ }
2359
+ }
2360
+ function payloadObject(value) {
2361
+ if (value && typeof value === "object" && !Array.isArray(value))
2362
+ return value;
2363
+ if (typeof value !== "string")
2364
+ return null;
2365
+ try {
2366
+ const parsed = JSON.parse(value);
2367
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
2368
+ } catch {
2369
+ return null;
2370
+ }
2371
+ }
2372
+ async function backfillPostgresCommentRedaction(client, options = {}) {
2373
+ const apply = options.apply === true;
2374
+ if (apply && options.confirmation !== COMMENT_REDACTION_BACKFILL_CONFIRMATION) {
2375
+ throw new Error(`Applying the comment redaction backfill requires confirmation ${COMMENT_REDACTION_BACKFILL_CONFIRMATION}`);
2376
+ }
2377
+ const tableName = options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE;
2378
+ assertSafeIdentifier2(tableName);
2379
+ const service = options.service ?? "todos";
2380
+ const batchSize = options.batchSize ?? 100;
2381
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 500) {
2382
+ throw new Error("Comment redaction backfill batchSize must be an integer between 1 and 500");
2383
+ }
2384
+ const result = {
2385
+ dry_run: !apply,
2386
+ scanned: 0,
2387
+ candidates: 0,
2388
+ updated: 0,
2389
+ conflicts: 0,
2390
+ batches: 0,
2391
+ remaining_candidates: 0
2392
+ };
2393
+ let afterId = "";
2394
+ while (true) {
2395
+ const page = await client.query(`/* todos:comment-redaction-backfill-scan */
2396
+ SELECT object_id, payload
2397
+ FROM ${tableName}
2398
+ WHERE service = $1 AND object_type = 'comments'
2399
+ AND object_id > $2
2400
+ ORDER BY object_id ASC
2401
+ LIMIT $3`, [service, afterId, batchSize]);
2402
+ if (page.rows.length === 0)
2403
+ break;
2404
+ result.batches += 1;
2405
+ for (const row of page.rows) {
2406
+ afterId = row.object_id;
2407
+ result.scanned += 1;
2408
+ const payload = payloadObject(row.payload);
2409
+ const original = payload?.["content"];
2410
+ if (typeof original !== "string")
2411
+ continue;
2412
+ const redacted = redactEvidenceText(original);
2413
+ if (redacted === original)
2414
+ continue;
2415
+ result.candidates += 1;
2416
+ if (!apply)
2417
+ continue;
2418
+ const nextPayload = { ...payload, content: redacted };
2419
+ const update = await client.query(`/* todos:comment-redaction-backfill-apply */
2420
+ UPDATE ${tableName}
2421
+ SET payload = $3::jsonb
2422
+ WHERE service = $1 AND object_type = 'comments' AND object_id = $2
2423
+ AND payload = $4::jsonb
2424
+ RETURNING object_id`, [service, row.object_id, nextPayload, row.payload]);
2425
+ if (update.rows.length === 1)
2426
+ result.updated += 1;
2427
+ else
2428
+ result.conflicts += 1;
2429
+ }
2430
+ if (page.rows.length < batchSize)
2431
+ break;
2432
+ }
2433
+ if (!apply) {
2434
+ result.remaining_candidates = result.candidates;
2435
+ return result;
2436
+ }
2437
+ const verification = await backfillPostgresCommentRedaction(client, {
2438
+ ...options,
2439
+ apply: false,
2440
+ confirmation: undefined
2441
+ });
2442
+ result.remaining_candidates = verification.candidates;
2443
+ return result;
2444
+ }
2445
+ function isCommentRedactionBackfillComplete(result) {
2446
+ return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
2447
+ }
2448
+ var COMMENT_REDACTION_BACKFILL_CONFIRMATION = "REDACT_STORED_TODOS_COMMENTS";
2449
+ var init_comment_redaction_backfill = __esm(() => {
2450
+ init_redaction();
2451
+ });
1966
2452
 
1967
2453
  // src/server/cloud.ts
1968
2454
  var exports_cloud = {};
@@ -1976,7 +2462,9 @@ __export(exports_cloud, {
1976
2462
  getCloudStorageAdapter: () => getCloudStorageAdapter,
1977
2463
  getApiKeyStore: () => getApiKeyStore,
1978
2464
  ensureCloudSchema: () => ensureCloudSchema,
2465
+ ensureCloudCommentCursorIndex: () => ensureCloudCommentCursorIndex,
1979
2466
  closeCloud: () => closeCloud,
2467
+ backfillCloudCommentRedaction: () => backfillCloudCommentRedaction,
1980
2468
  TODOS_APP_SLUG: () => TODOS_APP_SLUG
1981
2469
  });
1982
2470
  function resolveCloudDatabaseUrl(env = process.env) {
@@ -2054,6 +2542,9 @@ async function ensureCloudSchema() {
2054
2542
  })();
2055
2543
  return schemaEnsured;
2056
2544
  }
2545
+ async function ensureCloudCommentCursorIndex() {
2546
+ await getClient().query(postgresTodosCommentCursorIndexSql());
2547
+ }
2057
2548
  async function normalizeCloudPayloads() {
2058
2549
  const client = getClient();
2059
2550
  const res = await client.query(`UPDATE todos_sync_records
@@ -2062,6 +2553,9 @@ async function normalizeCloudPayloads() {
2062
2553
  RETURNING object_id AS id`);
2063
2554
  return res.rows.length;
2064
2555
  }
2556
+ function backfillCloudCommentRedaction(options = {}) {
2557
+ return backfillPostgresCommentRedaction(getClient(), { ...options, service: TODOS_APP_SLUG });
2558
+ }
2065
2559
  async function pingCloud() {
2066
2560
  const client = getClient();
2067
2561
  const res = await client.query("select 1 as ok");
@@ -2082,6 +2576,7 @@ var init_cloud = __esm(() => {
2082
2576
  init_auth();
2083
2577
  init_cloud_client();
2084
2578
  init_postgres_adapter();
2579
+ init_comment_redaction_backfill();
2085
2580
  });
2086
2581
 
2087
2582
  // src/db/migrations.ts
@@ -4383,7 +4878,7 @@ var init_schema = __esm(() => {
4383
4878
  });
4384
4879
 
4385
4880
  // src/db/machines.ts
4386
- import { existsSync as existsSync2 } from "fs";
4881
+ import { existsSync as existsSync4 } from "fs";
4387
4882
  import { hostname as osHostname, platform as osPlatform, arch as osArch } from "os";
4388
4883
  import { resolve } from "path";
4389
4884
  import { spawnSync } from "child_process";
@@ -4590,7 +5085,7 @@ function getMachineTopologyDiagnostics(opts = {}, db, at = new Date) {
4590
5085
  message: `${project.name} has ${distinctPaths.length} different machine-local paths`
4591
5086
  });
4592
5087
  }
4593
- if (localRow && !existsSync2(localRow.path)) {
5088
+ if (localRow && !existsSync4(localRow.path)) {
4594
5089
  pathIssues.push({
4595
5090
  type: "path_missing",
4596
5091
  project_id: project.id,
@@ -4601,7 +5096,7 @@ function getMachineTopologyDiagnostics(opts = {}, db, at = new Date) {
4601
5096
  message: `Local path does not exist on this machine: ${localRow.path}`
4602
5097
  });
4603
5098
  }
4604
- if (!localRow && project.path && machineById.has(localMachine.id) && !existsSync2(project.path)) {
5099
+ if (!localRow && project.path && machineById.has(localMachine.id) && !existsSync4(project.path)) {
4605
5100
  pathIssues.push({
4606
5101
  type: "path_missing",
4607
5102
  project_id: project.id,
@@ -4800,8 +5295,8 @@ __export(exports_database, {
4800
5295
  LOCK_EXPIRY_MINUTES: () => LOCK_EXPIRY_MINUTES
4801
5296
  });
4802
5297
  import { Database } from "bun:sqlite";
4803
- import { existsSync as existsSync3, mkdirSync } from "fs";
4804
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
5298
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
5299
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "path";
4805
5300
  function isInMemoryDb(path) {
4806
5301
  return path === ":memory:" || path.startsWith("file::memory:");
4807
5302
  }
@@ -4810,12 +5305,12 @@ function findNearestProjectDb(startDir) {
4810
5305
  const stopAt = gitRoot ? resolve2(gitRoot) : resolve2(startDir);
4811
5306
  let dir = resolve2(startDir);
4812
5307
  while (true) {
4813
- const candidate = join2(dir, ".hasna", "todos", "todos.db");
4814
- if (existsSync3(candidate))
5308
+ const candidate = join4(dir, ".hasna", "todos", "todos.db");
5309
+ if (existsSync5(candidate))
4815
5310
  return candidate;
4816
5311
  if (dir === stopAt)
4817
5312
  break;
4818
- const parent = dirname2(dir);
5313
+ const parent = dirname3(dir);
4819
5314
  if (parent === dir)
4820
5315
  break;
4821
5316
  dir = parent;
@@ -4825,9 +5320,9 @@ function findNearestProjectDb(startDir) {
4825
5320
  function findGitRoot(startDir) {
4826
5321
  let dir = resolve2(startDir);
4827
5322
  while (true) {
4828
- if (existsSync3(join2(dir, ".git")))
5323
+ if (existsSync5(join4(dir, ".git")))
4829
5324
  return dir;
4830
- const parent = dirname2(dir);
5325
+ const parent = dirname3(dir);
4831
5326
  if (parent === dir)
4832
5327
  break;
4833
5328
  dir = parent;
@@ -4848,25 +5343,25 @@ function getDbPath() {
4848
5343
  if (process.env["TODOS_DB_SCOPE"] === "project") {
4849
5344
  const gitRoot = findGitRoot(cwd);
4850
5345
  if (gitRoot) {
4851
- return join2(gitRoot, ".hasna", "todos", "todos.db");
5346
+ return join4(gitRoot, ".hasna", "todos", "todos.db");
4852
5347
  }
4853
5348
  }
4854
5349
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
4855
- return join2(home, ".hasna", "todos", "todos.db");
5350
+ return join4(home, ".hasna", "todos", "todos.db");
4856
5351
  }
4857
5352
  function getDatabasePath() {
4858
5353
  return getDbPath();
4859
5354
  }
4860
- function ensureDir(filePath) {
5355
+ function ensureDir2(filePath) {
4861
5356
  if (isInMemoryDb(filePath))
4862
5357
  return;
4863
- const dir = dirname2(resolve2(filePath));
4864
- if (!existsSync3(dir)) {
4865
- mkdirSync(dir, { recursive: true });
5358
+ const dir = dirname3(resolve2(filePath));
5359
+ if (!existsSync5(dir)) {
5360
+ mkdirSync2(dir, { recursive: true });
4866
5361
  }
4867
5362
  }
4868
5363
  function openDatabase(path) {
4869
- ensureDir(path);
5364
+ ensureDir2(path);
4870
5365
  const db = new Database(path);
4871
5366
  db.run("PRAGMA journal_mode = WAL");
4872
5367
  db.run("PRAGMA busy_timeout = 5000");
@@ -5041,226 +5536,6 @@ var init_api_keys = __esm(() => {
5041
5536
  init_database();
5042
5537
  });
5043
5538
 
5044
- // src/types/index.ts
5045
- var TASK_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
5046
- var init_types = __esm(() => {
5047
- TASK_STATUSES = [
5048
- "pending",
5049
- "in_progress",
5050
- "completed",
5051
- "failed",
5052
- "cancelled"
5053
- ];
5054
- VersionConflictError = class VersionConflictError extends Error {
5055
- taskId;
5056
- expectedVersion;
5057
- actualVersion;
5058
- static code = "VERSION_CONFLICT";
5059
- static suggestion = "Fetch the task with get_task to get the current version before updating.";
5060
- constructor(taskId, expectedVersion, actualVersion) {
5061
- super(`Version conflict for task ${taskId}: expected ${expectedVersion}, got ${actualVersion}`);
5062
- this.taskId = taskId;
5063
- this.expectedVersion = expectedVersion;
5064
- this.actualVersion = actualVersion;
5065
- this.name = "VersionConflictError";
5066
- }
5067
- };
5068
- TaskNotFoundError = class TaskNotFoundError extends Error {
5069
- taskId;
5070
- static code = "TASK_NOT_FOUND";
5071
- static suggestion = "Verify the task ID. Use list_tasks or search_tasks to find the correct ID.";
5072
- constructor(taskId) {
5073
- super(`Task not found: ${taskId}`);
5074
- this.taskId = taskId;
5075
- this.name = "TaskNotFoundError";
5076
- }
5077
- };
5078
- ProjectNotFoundError = class ProjectNotFoundError extends Error {
5079
- projectId;
5080
- static code = "PROJECT_NOT_FOUND";
5081
- static suggestion = "Use list_projects to see available projects.";
5082
- constructor(projectId) {
5083
- super(`Project not found: ${projectId}`);
5084
- this.projectId = projectId;
5085
- this.name = "ProjectNotFoundError";
5086
- }
5087
- };
5088
- PlanNotFoundError = class PlanNotFoundError extends Error {
5089
- planId;
5090
- static code = "PLAN_NOT_FOUND";
5091
- static suggestion = "Use list_plans to see available plans.";
5092
- constructor(planId) {
5093
- super(`Plan not found: ${planId}`);
5094
- this.planId = planId;
5095
- this.name = "PlanNotFoundError";
5096
- }
5097
- };
5098
- LockError = class LockError extends Error {
5099
- taskId;
5100
- lockedBy;
5101
- static code = "LOCK_ERROR";
5102
- static suggestion = "Wait for the lock to expire (30 min) or contact the lock holder.";
5103
- constructor(taskId, lockedBy) {
5104
- super(`Task ${taskId} is locked by ${lockedBy}`);
5105
- this.taskId = taskId;
5106
- this.lockedBy = lockedBy;
5107
- this.name = "LockError";
5108
- }
5109
- };
5110
- AgentNotFoundError = class AgentNotFoundError extends Error {
5111
- agentId;
5112
- static code = "AGENT_NOT_FOUND";
5113
- static suggestion = "Use register_agent to create the agent first, or list_agents to find existing ones.";
5114
- constructor(agentId) {
5115
- super(`Agent not found: ${agentId}`);
5116
- this.agentId = agentId;
5117
- this.name = "AgentNotFoundError";
5118
- }
5119
- };
5120
- TaskListNotFoundError = class TaskListNotFoundError extends Error {
5121
- taskListId;
5122
- static code = "TASK_LIST_NOT_FOUND";
5123
- static suggestion = "Use list_task_lists to see available lists.";
5124
- constructor(taskListId) {
5125
- super(`Task list not found: ${taskListId}`);
5126
- this.taskListId = taskListId;
5127
- this.name = "TaskListNotFoundError";
5128
- }
5129
- };
5130
- DependencyCycleError = class DependencyCycleError extends Error {
5131
- taskId;
5132
- dependsOn;
5133
- static code = "DEPENDENCY_CYCLE";
5134
- static suggestion = "Check the dependency chain with get_task to avoid circular references.";
5135
- constructor(taskId, dependsOn) {
5136
- super(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
5137
- this.taskId = taskId;
5138
- this.dependsOn = dependsOn;
5139
- this.name = "DependencyCycleError";
5140
- }
5141
- };
5142
- CompletionGuardError = class CompletionGuardError extends Error {
5143
- reason;
5144
- retryAfterSeconds;
5145
- static code = "COMPLETION_BLOCKED";
5146
- static suggestion = "Wait for the cooldown period, then retry.";
5147
- constructor(reason, retryAfterSeconds) {
5148
- super(reason);
5149
- this.reason = reason;
5150
- this.retryAfterSeconds = retryAfterSeconds;
5151
- this.name = "CompletionGuardError";
5152
- }
5153
- };
5154
- DispatchNotFoundError = class DispatchNotFoundError extends Error {
5155
- dispatchId;
5156
- static code = "DISPATCH_NOT_FOUND";
5157
- static suggestion = "Check the dispatch ID with list_dispatches.";
5158
- constructor(dispatchId) {
5159
- super(`Dispatch not found: ${dispatchId}`);
5160
- this.dispatchId = dispatchId;
5161
- this.name = "DispatchNotFoundError";
5162
- }
5163
- };
5164
- });
5165
-
5166
- // src/lib/sync-utils.ts
5167
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync } from "fs";
5168
- import { join as join3 } from "path";
5169
- function getHomeDir() {
5170
- return process.env["HOME"] || process.env["USERPROFILE"] || "~";
5171
- }
5172
- function getTodosGlobalDir() {
5173
- return join3(getHomeDir(), ".hasna", "todos");
5174
- }
5175
- function ensureDir2(dir) {
5176
- if (!existsSync4(dir))
5177
- mkdirSync2(dir, { recursive: true });
5178
- }
5179
- function readJsonFile(path) {
5180
- try {
5181
- return JSON.parse(readFileSync2(path, "utf-8"));
5182
- } catch {
5183
- return null;
5184
- }
5185
- }
5186
- function writeJsonFile(path, data) {
5187
- writeFileSync(path, JSON.stringify(data, null, 2) + `
5188
- `);
5189
- }
5190
- function appendSyncConflict(metadata, conflict, limit = 5) {
5191
- const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
5192
- const next = [conflict, ...current].slice(0, limit);
5193
- return { ...metadata, sync_conflicts: next };
5194
- }
5195
- var HOME;
5196
- var init_sync_utils = __esm(() => {
5197
- HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
5198
- });
5199
-
5200
- // src/lib/config.ts
5201
- import { existsSync as existsSync5 } from "fs";
5202
- import { dirname as dirname3, join as join4 } from "path";
5203
- function getConfigPath() {
5204
- return join4(getTodosGlobalDir(), "config.json");
5205
- }
5206
- function loadConfig() {
5207
- if (cached)
5208
- return cached;
5209
- if (!existsSync5(getConfigPath())) {
5210
- cached = {};
5211
- return cached;
5212
- }
5213
- const config = readJsonFile(getConfigPath()) || {};
5214
- if (typeof config.sync_agents === "string") {
5215
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
5216
- }
5217
- cached = config;
5218
- return cached;
5219
- }
5220
- function saveConfig(config) {
5221
- const configPath = getConfigPath();
5222
- ensureDir2(dirname3(configPath));
5223
- writeJsonFile(configPath, config);
5224
- cached = config;
5225
- return config;
5226
- }
5227
- function getAgentPoolForProject(workingDir) {
5228
- const config = loadConfig();
5229
- if (workingDir && config.project_pools) {
5230
- let bestKey = null;
5231
- let bestLen = 0;
5232
- for (const key of Object.keys(config.project_pools)) {
5233
- if (workingDir.startsWith(key) && key.length > bestLen) {
5234
- bestKey = key;
5235
- bestLen = key.length;
5236
- }
5237
- }
5238
- if (bestKey && config.project_pools[bestKey]) {
5239
- return config.project_pools[bestKey];
5240
- }
5241
- }
5242
- return config.agent_pool || null;
5243
- }
5244
- function getCompletionGuardConfig(projectPath) {
5245
- const config = loadConfig();
5246
- const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
5247
- if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
5248
- return { ...global, ...config.project_overrides[projectPath].completion_guard };
5249
- }
5250
- return global;
5251
- }
5252
- var cached = null, GUARD_DEFAULTS;
5253
- var init_config2 = __esm(() => {
5254
- init_sync_utils();
5255
- GUARD_DEFAULTS = {
5256
- enabled: false,
5257
- min_work_seconds: 30,
5258
- max_completions_per_window: 5,
5259
- window_minutes: 10,
5260
- cooldown_seconds: 60
5261
- };
5262
- });
5263
-
5264
5539
  // src/db/storage-tombstones.ts
5265
5540
  function recordStorageTombstone(input, db) {
5266
5541
  const d = db ?? getDatabase();
@@ -5697,105 +5972,6 @@ var init_event_emission_safety = __esm(() => {
5697
5972
  init_sync_utils();
5698
5973
  });
5699
5974
 
5700
- // src/lib/redaction.ts
5701
- function unique(values) {
5702
- return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
5703
- }
5704
- function cloneRegex(regex) {
5705
- return new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
5706
- }
5707
- function customPatterns() {
5708
- return unique(loadConfig().secret_safety?.redaction_patterns).flatMap((pattern) => {
5709
- try {
5710
- return [{ name: `custom:${pattern}`, regex: new RegExp(pattern, "g") }];
5711
- } catch {
5712
- return [];
5713
- }
5714
- });
5715
- }
5716
- function secretPatterns() {
5717
- return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
5718
- }
5719
- function isSecretKey(key) {
5720
- if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
5721
- return false;
5722
- if (DEFAULT_SECRET_KEY_PATTERN.test(key))
5723
- return true;
5724
- return unique(loadConfig().secret_safety?.redaction_keys).some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()));
5725
- }
5726
- function redactEvidenceText(value) {
5727
- let redacted = value;
5728
- for (const pattern of secretPatterns()) {
5729
- const regex = cloneRegex(pattern.regex);
5730
- const replacement = pattern.replacement ?? "[REDACTED]";
5731
- redacted = typeof replacement === "string" ? redacted.replace(regex, replacement) : redacted.replace(regex, replacement);
5732
- }
5733
- return redacted;
5734
- }
5735
- function redactValue(value) {
5736
- if (typeof value === "string")
5737
- return redactEvidenceText(value);
5738
- if (Array.isArray(value))
5739
- return value.map(redactValue);
5740
- if (value && typeof value === "object") {
5741
- const redacted = {};
5742
- for (const [key, child] of Object.entries(value)) {
5743
- if (isSecretKey(key)) {
5744
- redacted[key] = "[REDACTED]";
5745
- } else {
5746
- redacted[key] = redactValue(child);
5747
- }
5748
- }
5749
- return redacted;
5750
- }
5751
- return value;
5752
- }
5753
- function listSecretFindings(value) {
5754
- const findings = [];
5755
- for (const pattern of secretPatterns()) {
5756
- const matches = value.match(cloneRegex(pattern.regex));
5757
- if (matches?.length)
5758
- findings.push({ pattern: pattern.name, count: matches.length });
5759
- }
5760
- return findings;
5761
- }
5762
- function getSecretSafetyConfig() {
5763
- return {
5764
- redaction_patterns: unique(loadConfig().secret_safety?.redaction_patterns),
5765
- redaction_keys: unique(loadConfig().secret_safety?.redaction_keys)
5766
- };
5767
- }
5768
- function upsertSecretSafetyConfig(input) {
5769
- const config = loadConfig();
5770
- const next = {
5771
- redaction_patterns: unique([...config.secret_safety?.redaction_patterns || [], ...input.redaction_patterns || []]),
5772
- redaction_keys: unique([...config.secret_safety?.redaction_keys || [], ...input.redaction_keys || []])
5773
- };
5774
- saveConfig({ ...config, secret_safety: next });
5775
- return next;
5776
- }
5777
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS;
5778
- var init_redaction = __esm(() => {
5779
- init_config2();
5780
- DEFAULT_SECRET_PATTERNS = [
5781
- { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
5782
- { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
5783
- { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
5784
- { name: "env-secret-assignment", regex: /\b([A-Za-z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*['"]?[^'"\s]{8,}/gi, replacement: "$1=[REDACTED]" },
5785
- { name: "bearer-token", regex: /\b(bearer)\s+[A-Za-z0-9._~+/=-]{12,}/gi, replacement: "$1 [REDACTED]" }
5786
- ];
5787
- DEFAULT_SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password/i;
5788
- NON_SECRET_USAGE_KEYS = new Set([
5789
- "tokens",
5790
- "total_tokens",
5791
- "token_count",
5792
- "input_tokens",
5793
- "output_tokens",
5794
- "prompt_tokens",
5795
- "completion_tokens"
5796
- ]);
5797
- });
5798
-
5799
5975
  // src/lib/workspace-trust.ts
5800
5976
  import { relative, resolve as resolve4 } from "path";
5801
5977
  function normalizePath(path) {
@@ -11232,7 +11408,7 @@ function getComment(id, db) {
11232
11408
  }
11233
11409
  function listComments(taskId, db) {
11234
11410
  const d = db || getDatabase();
11235
- return d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at").all(taskId);
11411
+ return d.query("SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at, rowid").all(taskId);
11236
11412
  }
11237
11413
  function updateComment(id, input, db) {
11238
11414
  const d = db || getDatabase();
@@ -14966,6 +15142,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14966
15142
  list: (filter = {}) => listTasks2(filter, database()),
14967
15143
  count: (filter = {}) => countTasks(filter, database()),
14968
15144
  update: (id, input) => updateTask2(id, input, database()),
15145
+ unlock: (id, agentId) => {
15146
+ unlockTask2(id, agentId, database());
15147
+ return true;
15148
+ },
14969
15149
  delete: (id) => deleteTask(id, database()),
14970
15150
  start: (id, agentId) => startTask2(id, agentId, database()),
14971
15151
  complete: (id, agentId, options2) => completeTask2(id, agentId, database(), options2),
@@ -15017,6 +15197,20 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
15017
15197
  logTaskChange: (taskId, action, field, oldValue, newValue, agentId) => logTaskChange2(taskId, action, field, oldValue, newValue, agentId, database()),
15018
15198
  addComment: (input) => addComment2(input, database()),
15019
15199
  getComments: (taskId) => listComments(taskId, database()),
15200
+ getCommentsPage: (taskId, options2) => {
15201
+ if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
15202
+ throw new Error("Comment limit must be an integer between 1 and 1001");
15203
+ }
15204
+ let comments = listComments(taskId, database());
15205
+ comments = comments.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id));
15206
+ if (options2?.before) {
15207
+ const before = options2.before;
15208
+ comments = comments.filter((comment) => comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id);
15209
+ }
15210
+ if (options2?.limit !== undefined)
15211
+ comments = comments.slice(-options2.limit);
15212
+ return comments;
15213
+ },
15020
15214
  getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
15021
15215
  getRecentActivity: (limit) => getRecentActivity(limit, database())
15022
15216
  },
@@ -15404,6 +15598,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15404
15598
  schemas: {
15405
15599
  Task: taskSchema,
15406
15600
  Project: projectSchema,
15601
+ TaskComment: taskCommentSchema,
15407
15602
  CreateTaskInput: {
15408
15603
  type: "object",
15409
15604
  required: ["title"],
@@ -15438,6 +15633,17 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15438
15633
  description: { type: "string" },
15439
15634
  task_prefix: { type: "string" }
15440
15635
  }
15636
+ },
15637
+ CreateTaskCommentInput: {
15638
+ type: "object",
15639
+ required: ["content"],
15640
+ properties: {
15641
+ content: { type: "string", minLength: 1 },
15642
+ agent_id: { type: "string" },
15643
+ session_id: { type: "string" },
15644
+ type: { type: "string", enum: ["comment", "progress", "note"] },
15645
+ progress_pct: { type: "number" }
15646
+ }
15441
15647
  }
15442
15648
  }
15443
15649
  },
@@ -15536,6 +15742,61 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15536
15742
  }
15537
15743
  }
15538
15744
  },
15745
+ "/v1/tasks/{id}/comments": {
15746
+ get: {
15747
+ operationId: "listTaskComments",
15748
+ summary: "List a bounded page of task comments",
15749
+ 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.",
15750
+ parameters: [
15751
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
15752
+ { name: "limit", in: "query", required: true, schema: { type: "integer", minimum: 1, maximum: 500, default: 100 } },
15753
+ { name: "cursor", in: "query", schema: { type: "string" } }
15754
+ ],
15755
+ responses: {
15756
+ "200": {
15757
+ content: {
15758
+ "application/json": {
15759
+ schema: {
15760
+ type: "object",
15761
+ required: ["comments", "count", "has_more", "next_cursor"],
15762
+ properties: {
15763
+ comments: { type: "array", maxItems: 500, items: { $ref: "#/components/schemas/TaskComment" } },
15764
+ count: { type: "integer", minimum: 0, maximum: 500 },
15765
+ has_more: { type: "boolean" },
15766
+ next_cursor: { type: "string", nullable: true }
15767
+ }
15768
+ }
15769
+ }
15770
+ }
15771
+ },
15772
+ "426": {
15773
+ 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."
15774
+ }
15775
+ }
15776
+ },
15777
+ post: {
15778
+ operationId: "createTaskComment",
15779
+ summary: "Create a task comment",
15780
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
15781
+ requestBody: {
15782
+ required: true,
15783
+ content: { "application/json": { schema: { $ref: "#/components/schemas/CreateTaskCommentInput" } } }
15784
+ },
15785
+ responses: {
15786
+ "201": {
15787
+ content: {
15788
+ "application/json": {
15789
+ schema: {
15790
+ type: "object",
15791
+ required: ["comment"],
15792
+ properties: { comment: { $ref: "#/components/schemas/TaskComment" } }
15793
+ }
15794
+ }
15795
+ }
15796
+ }
15797
+ }
15798
+ }
15799
+ },
15539
15800
  "/v1/tasks/{id}/start": {
15540
15801
  post: {
15541
15802
  operationId: "startTask",
@@ -15654,7 +15915,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
15654
15915
  }
15655
15916
  };
15656
15917
  }
15657
- var taskSchema, projectSchema;
15918
+ var taskSchema, projectSchema, taskCommentSchema;
15658
15919
  var init_openapi = __esm(() => {
15659
15920
  init_package_version();
15660
15921
  taskSchema = {
@@ -15685,6 +15946,20 @@ var init_openapi = __esm(() => {
15685
15946
  updated_at: { type: "string" }
15686
15947
  }
15687
15948
  };
15949
+ taskCommentSchema = {
15950
+ type: "object",
15951
+ required: ["id", "task_id", "agent_id", "session_id", "content", "type", "progress_pct", "created_at"],
15952
+ properties: {
15953
+ id: { type: "string" },
15954
+ task_id: { type: "string" },
15955
+ agent_id: { type: "string", nullable: true },
15956
+ session_id: { type: "string", nullable: true },
15957
+ content: { type: "string" },
15958
+ type: { type: "string", enum: ["comment", "progress", "note"] },
15959
+ progress_pct: { type: "number", nullable: true },
15960
+ created_at: { type: "string", format: "date-time" }
15961
+ }
15962
+ };
15688
15963
  });
15689
15964
 
15690
15965
  // src/server/v1.ts
@@ -15714,6 +15989,29 @@ function contextFromPrincipal(principal, body) {
15714
15989
  const agentId = body?.agent_id || principal.agent || undefined;
15715
15990
  return agentId ? { agentId } : {};
15716
15991
  }
15992
+ function redactComment2(comment) {
15993
+ return { ...comment, content: redactEvidenceText(comment.content) };
15994
+ }
15995
+ function encodeCommentCursor(comment) {
15996
+ return Buffer.from(JSON.stringify({ created_at: comment.created_at, id: comment.id }), "utf8").toString("base64url");
15997
+ }
15998
+ function decodeCommentCursor(value) {
15999
+ if (value.length > 1024)
16000
+ throw new Error("invalid comment cursor");
16001
+ let parsed;
16002
+ try {
16003
+ parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
16004
+ } catch {
16005
+ throw new Error("invalid comment cursor");
16006
+ }
16007
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
16008
+ throw new Error("invalid comment cursor");
16009
+ const cursor = parsed;
16010
+ 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) {
16011
+ throw new Error("invalid comment cursor");
16012
+ }
16013
+ return { created_at: cursor["created_at"], id: cursor["id"] };
16014
+ }
15717
16015
  function normalizeImportSnapshot(raw) {
15718
16016
  const body = raw && typeof raw === "object" ? raw : {};
15719
16017
  const arr = (v) => Array.isArray(v) ? v : [];
@@ -15734,7 +16032,7 @@ function normalizeImportSnapshot(raw) {
15734
16032
  function countSnapshotRecords(s) {
15735
16033
  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);
15736
16034
  }
15737
- async function handleV1Request(req, url) {
16035
+ async function handleV1Request(req, url, dependencies = {}) {
15738
16036
  const path = url.pathname;
15739
16037
  if (path !== "/v1" && !path.startsWith("/v1/"))
15740
16038
  return null;
@@ -15743,7 +16041,7 @@ async function handleV1Request(req, url) {
15743
16041
  const requiredScopes = [isWrite ? "todos:write" : "todos:read"];
15744
16042
  let verifier;
15745
16043
  try {
15746
- verifier = getCloudVerifier();
16044
+ verifier = (dependencies.getVerifier ?? getCloudVerifier)();
15747
16045
  } catch (e) {
15748
16046
  return error(503, e.message);
15749
16047
  }
@@ -15752,8 +16050,8 @@ async function handleV1Request(req, url) {
15752
16050
  return error(decision.status, decision.message, { reason: decision.reason });
15753
16051
  }
15754
16052
  const principal = decision.principal;
15755
- await ensureCloudSchema();
15756
- const store = getCloudStorageAdapter();
16053
+ await (dependencies.ensureSchema ?? ensureCloudSchema)();
16054
+ const store = (dependencies.getStorageAdapter ?? getCloudStorageAdapter)();
15757
16055
  const segments = path.split("/").filter(Boolean);
15758
16056
  const resource = segments[1];
15759
16057
  const id = segments[2];
@@ -15781,13 +16079,74 @@ async function handleV1Request(req, url) {
15781
16079
  missing
15782
16080
  });
15783
16081
  }
16082
+ if (id === "upsert" && !action) {
16083
+ if (method !== "POST")
16084
+ return error(405, `method ${method} not allowed on /v1/tasks/upsert`);
16085
+ if (typeof store.tasks.getByFingerprint !== "function") {
16086
+ return error(501, "fingerprint upsert is not supported by this storage backend");
16087
+ }
16088
+ const body = await readJson(req) ?? {};
16089
+ const fingerprint = typeof body.fingerprint === "string" ? body.fingerprint.trim() : "";
16090
+ if (!fingerprint)
16091
+ return error(400, "fingerprint is required");
16092
+ if (typeof body.title !== "string" || !body.title.trim())
16093
+ return error(400, "title is required");
16094
+ const existing = await store.tasks.getByFingerprint(fingerprint);
16095
+ const metadata = {
16096
+ ...existing?.metadata ?? {},
16097
+ ...body.metadata ?? {},
16098
+ fingerprint
16099
+ };
16100
+ const fields = { metadata };
16101
+ for (const key of [
16102
+ "title",
16103
+ "description",
16104
+ "priority",
16105
+ "status",
16106
+ "project_id",
16107
+ "assigned_to",
16108
+ "working_dir",
16109
+ "plan_id",
16110
+ "task_list_id",
16111
+ "tags",
16112
+ "due_at",
16113
+ "estimated_minutes",
16114
+ "sla_minutes",
16115
+ "requires_approval",
16116
+ "recurrence_rule",
16117
+ "task_type"
16118
+ ]) {
16119
+ const bag = body;
16120
+ if (bag[key] !== undefined)
16121
+ fields[key] = bag[key];
16122
+ }
16123
+ if (!existing) {
16124
+ const task = await store.tasks.create({ ...fields, title: body.title }, contextFromPrincipal(principal, body));
16125
+ return json2({ task, created: true }, 201);
16126
+ }
16127
+ try {
16128
+ const task = await store.tasks.update(existing.id, { ...fields, version: existing.version }, contextFromPrincipal(principal, body));
16129
+ return json2({ task, created: false });
16130
+ } catch (e) {
16131
+ const msg = e.message || "";
16132
+ if (msg.includes("version conflict"))
16133
+ return error(409, msg);
16134
+ throw e;
16135
+ }
16136
+ }
15784
16137
  if (!id) {
15785
16138
  if (method === "GET") {
15786
16139
  const filter = {
15787
- ...url.searchParams.get("status") ? { status: url.searchParams.get("status") } : {},
15788
- ...url.searchParams.get("priority") ? { priority: url.searchParams.get("priority") } : {},
16140
+ ...url.searchParams.get("status") ? {
16141
+ status: url.searchParams.get("status").includes(",") ? url.searchParams.get("status").split(",") : url.searchParams.get("status")
16142
+ } : {},
16143
+ ...url.searchParams.get("priority") ? {
16144
+ priority: url.searchParams.get("priority").includes(",") ? url.searchParams.get("priority").split(",") : url.searchParams.get("priority")
16145
+ } : {},
15789
16146
  ...url.searchParams.get("project_id") ? { project_id: url.searchParams.get("project_id") } : {},
16147
+ ...url.searchParams.has("parent_id") ? { parent_id: url.searchParams.get("parent_id") || null, include_subtasks: true } : {},
15790
16148
  ...url.searchParams.get("plan_id") ? { plan_id: url.searchParams.get("plan_id") } : {},
16149
+ ...url.searchParams.get("task_list_id") ? { task_list_id: url.searchParams.get("task_list_id") } : {},
15791
16150
  ...url.searchParams.get("assigned_to") ? { assigned_to: url.searchParams.get("assigned_to") } : {},
15792
16151
  ...url.searchParams.get("agent_id") ? { agent_id: url.searchParams.get("agent_id") } : {},
15793
16152
  ...url.searchParams.get("limit") ? { limit: Number(url.searchParams.get("limit")) } : {},
@@ -15811,8 +16170,47 @@ async function handleV1Request(req, url) {
15811
16170
  if (action) {
15812
16171
  if (action === "comments") {
15813
16172
  if (method === "GET") {
15814
- const comments = await store.audit.getComments(id);
15815
- return json2({ comments, count: comments.length });
16173
+ if (!await store.tasks.get(id))
16174
+ return error(404, "task not found");
16175
+ const rawLimit = url.searchParams.get("limit");
16176
+ const cursor = url.searchParams.get("cursor");
16177
+ if (rawLimit === null && cursor === null) {
16178
+ const storageContext = contextFromPrincipal(principal);
16179
+ const legacyPage = (await (store.audit.getCommentsPage ? store.audit.getCommentsPage(id, { limit: LEGACY_COMMENT_RESPONSE_LIMIT + 1 }, storageContext) : store.audit.getComments(id, storageContext))).map(redactComment2);
16180
+ if (legacyPage.length > LEGACY_COMMENT_RESPONSE_LIMIT) {
16181
+ return error(426, "task has too many comments for this client; upgrade @hasna/todos to use cursor pagination");
16182
+ }
16183
+ return json2({
16184
+ comments: legacyPage,
16185
+ count: legacyPage.length,
16186
+ has_more: false,
16187
+ next_cursor: null
16188
+ });
16189
+ }
16190
+ const limit = rawLimit === null ? DEFAULT_COMMENT_PAGE_SIZE : Number(rawLimit);
16191
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_COMMENT_PAGE_SIZE) {
16192
+ return error(400, `limit must be an integer between 1 and ${MAX_COMMENT_PAGE_SIZE}`);
16193
+ }
16194
+ let before;
16195
+ if (cursor) {
16196
+ try {
16197
+ before = decodeCommentCursor(cursor);
16198
+ } catch {
16199
+ return error(400, "invalid comment cursor");
16200
+ }
16201
+ }
16202
+ if (!store.audit.getCommentsPage) {
16203
+ return error(426, "storage adapter must be upgraded to support cursor-paginated comments");
16204
+ }
16205
+ const page = (await store.audit.getCommentsPage(id, { limit: limit + 1, ...before ? { before } : {} }, contextFromPrincipal(principal))).map(redactComment2);
16206
+ const hasMore = page.length > limit;
16207
+ const comments = hasMore ? page.slice(1) : page;
16208
+ return json2({
16209
+ comments,
16210
+ count: comments.length,
16211
+ has_more: hasMore,
16212
+ next_cursor: hasMore && comments[0] ? encodeCommentCursor(comments[0]) : null
16213
+ });
15816
16214
  }
15817
16215
  if (method === "POST") {
15818
16216
  const body2 = await readJson(req) ?? {};
@@ -15830,24 +16228,45 @@ async function handleV1Request(req, url) {
15830
16228
  type: body2.type,
15831
16229
  progress_pct: body2.progress_pct
15832
16230
  }, contextFromPrincipal(principal, body2));
15833
- return json2({ comment }, 201);
16231
+ return json2({ comment: redactComment2(comment) }, 201);
15834
16232
  }
15835
16233
  return error(405, `method ${method} not allowed on /v1/tasks/:id/comments`);
15836
16234
  }
16235
+ if (action === "history") {
16236
+ if (method !== "GET")
16237
+ return error(405, `method ${method} not allowed on /v1/tasks/:id/history`);
16238
+ if (!await store.tasks.get(id))
16239
+ return error(404, "task not found");
16240
+ const history = await store.audit.getTaskHistory(id);
16241
+ return json2({ history, count: history.length });
16242
+ }
15837
16243
  if (action === "lock" || action === "unlock") {
15838
16244
  if (method !== "POST")
15839
16245
  return error(405, `method ${method} not allowed on /v1/tasks/:id/${action}`);
15840
- if (typeof store.tasks.lock !== "function" || typeof store.tasks.unlock !== "function") {
15841
- return error(501, "task locking is not supported by this storage backend");
15842
- }
15843
16246
  const body2 = await readJson(req) ?? {};
15844
16247
  if (!await store.tasks.get(id))
15845
16248
  return error(404, "task not found");
15846
16249
  if (action === "lock") {
15847
- const agentId2 = body2.agent_id || principal.agent || "todos-serve";
15848
- return json2({ result: await store.tasks.lock(id, agentId2) });
15849
- }
15850
- const released = await store.tasks.unlock(id, body2.agent_id || principal.agent || undefined);
16250
+ if (typeof store.tasks.lock !== "function")
16251
+ return error(501, "task locking is not supported by this storage backend");
16252
+ const agentId3 = body2.agent_id || principal.agent || "todos-serve";
16253
+ return json2({ result: await store.tasks.lock(id, agentId3) });
16254
+ }
16255
+ if (typeof store.tasks.unlock !== "function")
16256
+ return error(501, "task unlocking is not supported by this storage backend");
16257
+ if (body2.force === true) {
16258
+ if (!principal.scopes.includes("todos:*"))
16259
+ return error(403, "force unlock requires todos:* scope");
16260
+ const released2 = await store.tasks.unlock(id);
16261
+ return json2({ success: released2 });
16262
+ }
16263
+ if (body2.agent_id && principal.agent && body2.agent_id !== principal.agent && !principal.scopes.includes("todos:*")) {
16264
+ return error(403, "unlock agent_id must match the authenticated agent");
16265
+ }
16266
+ const agentId2 = principal.agent || body2.agent_id;
16267
+ if (!agentId2)
16268
+ return error(403, "unlock requires an agent-bound key or force=true");
16269
+ const released = await store.tasks.unlock(id, agentId2);
15851
16270
  return json2({ success: released });
15852
16271
  }
15853
16272
  if (action === "dependencies") {
@@ -16130,12 +16549,28 @@ async function handleV1Request(req, url) {
16130
16549
  const activity = await store.audit.getRecentActivity(limit);
16131
16550
  return json2({ activity, count: activity.length });
16132
16551
  }
16133
- if (resource === "task-lists" && !id) {
16134
- if (method !== "GET")
16135
- return error(405, `method ${method} not allowed on /v1/task-lists`);
16136
- const projectId = url.searchParams.get("project_id") ?? undefined;
16137
- const taskLists = await store.taskLists.list(projectId);
16138
- return json2({ task_lists: taskLists, count: taskLists.length });
16552
+ if (resource === "task-lists") {
16553
+ if (!id && method === "GET") {
16554
+ const projectId = url.searchParams.get("project_id") ?? undefined;
16555
+ const taskLists = await store.taskLists.list(projectId);
16556
+ return json2({ task_lists: taskLists, count: taskLists.length });
16557
+ }
16558
+ if (!id && method === "POST") {
16559
+ const body = await readJson(req);
16560
+ if (!body || typeof body.name !== "string" || !body.name.trim())
16561
+ return error(400, "name is required");
16562
+ const taskList = await store.taskLists.create(body, contextFromPrincipal(principal));
16563
+ return json2({ task_list: taskList }, 201);
16564
+ }
16565
+ if (id && method === "GET") {
16566
+ const taskList = await store.taskLists.get(id);
16567
+ return taskList ? json2({ task_list: taskList }) : error(404, "task list not found");
16568
+ }
16569
+ if (id && method === "DELETE") {
16570
+ const deleted = await store.taskLists.delete(id, contextFromPrincipal(principal));
16571
+ return deleted ? json2({ deleted: true, id }) : error(404, "task list not found");
16572
+ }
16573
+ return error(405, `method ${method} not allowed on /v1/task-lists${id ? "/:id" : ""}`);
16139
16574
  }
16140
16575
  if (resource === "dependencies" && !id) {
16141
16576
  if (method !== "GET")
@@ -16143,8 +16578,8 @@ async function handleV1Request(req, url) {
16143
16578
  if (typeof store.dependencies?.listAll !== "function") {
16144
16579
  return error(501, "dependency edge listing is not supported by this storage backend");
16145
16580
  }
16146
- const dependencies = await store.dependencies.listAll();
16147
- return json2({ dependencies, count: dependencies.length });
16581
+ const dependencies2 = await store.dependencies.listAll();
16582
+ return json2({ dependencies: dependencies2, count: dependencies2.length });
16148
16583
  }
16149
16584
  if (resource === "commits" && id) {
16150
16585
  if (method !== "GET")
@@ -16201,12 +16636,16 @@ async function handleV1Request(req, url) {
16201
16636
  }
16202
16637
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
16203
16638
  } catch (e) {
16639
+ if (e instanceof LockError)
16640
+ return error(409, e.message, { code: LockError.code });
16204
16641
  return error(500, e.message || "internal error");
16205
16642
  }
16206
16643
  }
16207
- var JSON_HEADERS;
16644
+ var JSON_HEADERS, DEFAULT_COMMENT_PAGE_SIZE = 100, MAX_COMMENT_PAGE_SIZE = 500, LEGACY_COMMENT_RESPONSE_LIMIT = 500;
16208
16645
  var init_v1 = __esm(() => {
16646
+ init_types();
16209
16647
  init_cloud();
16648
+ init_redaction();
16210
16649
  JSON_HEADERS = { "Content-Type": "application/json" };
16211
16650
  });
16212
16651
 
@@ -19583,7 +20022,7 @@ class JSONSchemaGenerator {
19583
20022
  if (val === undefined) {
19584
20023
  if (this.unrepresentable === "throw") {
19585
20024
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
19586
- } else {}
20025
+ }
19587
20026
  } else if (typeof val === "bigint") {
19588
20027
  if (this.unrepresentable === "throw") {
19589
20028
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -43949,14 +44388,18 @@ function unwrapTask(raw) {
43949
44388
  }
43950
44389
  function toListQuery(filter = {}) {
43951
44390
  const query = {};
43952
- if (typeof filter.status === "string")
43953
- query["status"] = filter.status;
43954
- if (typeof filter.priority === "string")
43955
- query["priority"] = filter.priority;
44391
+ if (filter.status)
44392
+ query["status"] = Array.isArray(filter.status) ? filter.status.join(",") : filter.status;
44393
+ if (filter.priority)
44394
+ query["priority"] = Array.isArray(filter.priority) ? filter.priority.join(",") : filter.priority;
43956
44395
  if (filter.project_id)
43957
44396
  query["project_id"] = filter.project_id;
44397
+ if (filter.parent_id !== undefined)
44398
+ query["parent_id"] = filter.parent_id ?? "";
43958
44399
  if (filter.plan_id)
43959
44400
  query["plan_id"] = filter.plan_id;
44401
+ if (filter.task_list_id)
44402
+ query["task_list_id"] = filter.task_list_id;
43960
44403
  if (filter.assigned_to)
43961
44404
  query["assigned_to"] = filter.assigned_to;
43962
44405
  if (filter.agent_id)
@@ -44006,10 +44449,19 @@ async function cloudListProjects(client) {
44006
44449
  }
44007
44450
  async function cloudAddComment(client, taskId, input) {
44008
44451
  const raw = await client.transport.post(`/tasks/${encodeURIComponent(taskId)}/comments`, input);
44009
- if (raw && typeof raw === "object" && "comment" in raw) {
44010
- return raw.comment;
44011
- }
44012
- return raw;
44452
+ const comment = raw && typeof raw === "object" && "comment" in raw ? raw.comment : raw;
44453
+ if (!isTaskComment(comment))
44454
+ throw new Error("Invalid cloud comment response");
44455
+ return redactComment3(comment);
44456
+ }
44457
+ function isTaskComment(value) {
44458
+ if (!value || typeof value !== "object" || Array.isArray(value))
44459
+ return false;
44460
+ const comment = value;
44461
+ 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";
44462
+ }
44463
+ function redactComment3(comment) {
44464
+ return { ...comment, content: redactEvidenceText(comment.content) };
44013
44465
  }
44014
44466
  async function cloudCountTasks(client, filter = {}) {
44015
44467
  const { limit: _drop, offset: _o, ...rest } = filter;
@@ -44042,6 +44494,7 @@ async function cloudReleaseAgent(client, idOrName, sessionId) {
44042
44494
  var _cache;
44043
44495
  var init_cloud_router = __esm(() => {
44044
44496
  init_storage();
44497
+ init_redaction();
44045
44498
  });
44046
44499
 
44047
44500
  // src/mcp/tools/task-crud.ts
@@ -69380,7 +69833,7 @@ var require_to_json_schema = __commonJS((exports) => {
69380
69833
  if (val === undefined) {
69381
69834
  if (this.unrepresentable === "throw") {
69382
69835
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
69383
- } else {}
69836
+ }
69384
69837
  } else if (typeof val === "bigint") {
69385
69838
  if (this.unrepresentable === "throw") {
69386
69839
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -89861,7 +90314,7 @@ function captureEnvironmentSnapshot(input = {}) {
89861
90314
  }
89862
90315
  function writeEnvironmentSnapshot(snapshot, outputPath) {
89863
90316
  const path = outputPath ? resolve16(outputPath) : join14(defaultSnapshotDir(), `${snapshot.id}.json`);
89864
- ensureDir2(dirname9(path));
90317
+ ensureDir(dirname9(path));
89865
90318
  writeJsonFile(path, snapshot);
89866
90319
  return path;
89867
90320
  }
@@ -91048,11 +91501,19 @@ function printHelp2() {
91048
91501
 
91049
91502
  Start the @hasna/todos dashboard server.
91050
91503
 
91504
+ Commands:
91505
+ migrate Apply idempotent schema migrations
91506
+ redact-comments Preview historical comment redaction (dry-run by default)
91507
+
91051
91508
  Options:
91052
91509
  --port <port> HTTP port to bind. Defaults to ${DEFAULT_PORT}
91053
91510
  --host <host> Hostname to bind. Defaults to 127.0.0.1
91054
91511
  --api-key <key> Require this API key for dashboard/API requests
91055
91512
  --no-open Do not open the dashboard in a browser
91513
+ --batch-size <n> redact-comments batch size, 1-500 (default: 100)
91514
+ --apply Apply redact-comments changes (default is dry-run)
91515
+ --confirm <value> Explicit confirmation required with --apply
91516
+ --json Emit redact-comments aggregate JSON
91056
91517
  -V, --version output the version number
91057
91518
  -h, --help display help for command
91058
91519
 
@@ -91091,7 +91552,14 @@ async function findFreePort(start) {
91091
91552
  return start;
91092
91553
  }
91093
91554
  async function runMigrate() {
91094
- const { ensureCloudSchema: ensureCloudSchema2, normalizeCloudPayloads: normalizeCloudPayloads2, pingCloud: pingCloud2, resolveCloudDatabaseUrl: resolveCloudDatabaseUrl2, closeCloud: closeCloud2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
91555
+ const {
91556
+ ensureCloudSchema: ensureCloudSchema2,
91557
+ ensureCloudCommentCursorIndex: ensureCloudCommentCursorIndex2,
91558
+ normalizeCloudPayloads: normalizeCloudPayloads2,
91559
+ pingCloud: pingCloud2,
91560
+ resolveCloudDatabaseUrl: resolveCloudDatabaseUrl2,
91561
+ closeCloud: closeCloud2
91562
+ } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
91095
91563
  if (!resolveCloudDatabaseUrl2()) {
91096
91564
  console.error("migrate: no database URL (HASNA_TODOS_DATABASE_URL / TODOS_DATABASE_URL / DATABASE_URL)");
91097
91565
  process.exit(2);
@@ -91103,15 +91571,55 @@ async function runMigrate() {
91103
91571
  console.log("migrate: normalizing legacy double-encoded jsonb payloads\u2026");
91104
91572
  const normalized = await normalizeCloudPayloads2();
91105
91573
  console.log(`migrate: normalized ${normalized} payload row(s)`);
91574
+ console.log("migrate: prebuilding comment cursor index concurrently\u2026");
91575
+ await ensureCloudCommentCursorIndex2();
91106
91576
  console.log("migrate: done");
91107
91577
  await closeCloud2();
91108
91578
  process.exit(0);
91109
91579
  }
91110
- async function main2() {
91111
- if (process.argv.includes("migrate")) {
91112
- await runMigrate();
91113
- return;
91580
+ async function runCommentRedactionBackfill() {
91581
+ const {
91582
+ backfillCloudCommentRedaction: backfillCloudCommentRedaction2,
91583
+ resolveCloudDatabaseUrl: resolveCloudDatabaseUrl2,
91584
+ closeCloud: closeCloud2
91585
+ } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
91586
+ const {
91587
+ COMMENT_REDACTION_BACKFILL_CONFIRMATION: COMMENT_REDACTION_BACKFILL_CONFIRMATION2,
91588
+ isCommentRedactionBackfillComplete: isCommentRedactionBackfillComplete2
91589
+ } = await Promise.resolve().then(() => (init_comment_redaction_backfill(), exports_comment_redaction_backfill));
91590
+ if (!resolveCloudDatabaseUrl2()) {
91591
+ console.error("redact-comments: no database URL (HASNA_TODOS_DATABASE_URL / TODOS_DATABASE_URL / DATABASE_URL)");
91592
+ process.exit(2);
91593
+ }
91594
+ const apply = process.argv.includes("--apply");
91595
+ const rawBatchSize = parseStringArg("--batch-size");
91596
+ try {
91597
+ const report = await backfillCloudCommentRedaction2({
91598
+ apply,
91599
+ confirmation: parseStringArg("--confirm"),
91600
+ batchSize: rawBatchSize === undefined ? 100 : Number(rawBatchSize)
91601
+ });
91602
+ if (process.argv.includes("--json")) {
91603
+ console.log(JSON.stringify(report));
91604
+ } else {
91605
+ console.log(`redact-comments: ${report.dry_run ? "dry-run" : "applied"}; scanned=${report.scanned} candidates=${report.candidates} updated=${report.updated} conflicts=${report.conflicts} remaining=${report.remaining_candidates} batches=${report.batches}`);
91606
+ if (report.dry_run && report.candidates > 0) {
91607
+ console.log(`redact-comments: obtain approval before using --apply --confirm=${COMMENT_REDACTION_BACKFILL_CONFIRMATION2}`);
91608
+ }
91609
+ }
91610
+ if (apply && !isCommentRedactionBackfillComplete2(report)) {
91611
+ console.error("redact-comments: incomplete apply; resolve conflicts and rerun until conflicts=0 and remaining=0");
91612
+ process.exitCode = 1;
91613
+ }
91614
+ } catch (error3) {
91615
+ const message = error3.message.replace(/postgres(?:ql)?:\/\/[^@\s]+@/gi, "postgresql://[REDACTED]@");
91616
+ console.error(`redact-comments: failed: ${message}`);
91617
+ process.exitCode = 1;
91618
+ } finally {
91619
+ await closeCloud2();
91114
91620
  }
91621
+ }
91622
+ async function main2() {
91115
91623
  if (hasVersionFlag2()) {
91116
91624
  console.log(getPackageVersion());
91117
91625
  return;
@@ -91120,6 +91628,14 @@ async function main2() {
91120
91628
  printHelp2();
91121
91629
  return;
91122
91630
  }
91631
+ if (process.argv.includes("migrate")) {
91632
+ await runMigrate();
91633
+ return;
91634
+ }
91635
+ if (process.argv.includes("redact-comments")) {
91636
+ await runCommentRedactionBackfill();
91637
+ return;
91638
+ }
91123
91639
  const explicitPortArg = process.argv.some((a) => a === "--port" || a.startsWith("--port="));
91124
91640
  const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : undefined;
91125
91641
  const requestedPort = explicitPortArg ? parsePort() : envPort ?? parsePort();