@hasna/todos 0.15.34 → 0.15.41

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 (93) hide show
  1. package/dist/cli/cloud-router.d.ts +53 -19
  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/plan-template-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  6. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  7. package/dist/cli/commands/task-subtree-transfer-commands.d.ts +3 -0
  8. package/dist/cli/commands/task-subtree-transfer-commands.d.ts.map +1 -0
  9. package/dist/cli/index.js +3551 -836
  10. package/dist/cli/stage-a.d.ts.map +1 -1
  11. package/dist/contracts.js +147 -57
  12. package/dist/db/migrations.d.ts.map +1 -1
  13. package/dist/db/plan-comments.d.ts +14 -0
  14. package/dist/db/plan-comments.d.ts.map +1 -0
  15. package/dist/db/projects.d.ts +23 -0
  16. package/dist/db/projects.d.ts.map +1 -1
  17. package/dist/db/schema.d.ts.map +1 -1
  18. package/dist/db/task-crud.d.ts.map +1 -1
  19. package/dist/db/task-lifecycle.d.ts.map +1 -1
  20. package/dist/db/task-lists.d.ts.map +1 -1
  21. package/dist/db/task-relations.d.ts.map +1 -1
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1994 -346
  25. package/dist/json-contracts.d.ts.map +1 -1
  26. package/dist/lib/comment-cursor.d.ts +19 -0
  27. package/dist/lib/comment-cursor.d.ts.map +1 -1
  28. package/dist/lib/import-export-bridge.d.ts.map +1 -1
  29. package/dist/lib/onboarding-fixtures.d.ts.map +1 -1
  30. package/dist/mcp/index.js +2491 -507
  31. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  32. package/dist/mcp.js +11 -4
  33. package/dist/project-registration/authority.d.ts.map +1 -1
  34. package/dist/project-registration/sqlite.d.ts.map +1 -1
  35. package/dist/project-registration.js +362 -79
  36. package/dist/registry.d.ts +1 -1
  37. package/dist/registry.d.ts.map +1 -1
  38. package/dist/registry.js +154 -57
  39. package/dist/release-provenance.json +5 -5
  40. package/dist/sdk/index.d.ts +1 -1
  41. package/dist/sdk/index.d.ts.map +1 -1
  42. package/dist/sdk/index.js +35 -0
  43. package/dist/sdk/v1.generated.d.ts +144 -0
  44. package/dist/sdk/v1.generated.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +12 -2
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +5247 -8429
  48. package/dist/server/openapi.d.ts +2104 -1314
  49. package/dist/server/openapi.d.ts.map +1 -1
  50. package/dist/server/v1.d.ts +2 -1
  51. package/dist/server/v1.d.ts.map +1 -1
  52. package/dist/storage/config.d.ts +12 -30
  53. package/dist/storage/config.d.ts.map +1 -1
  54. package/dist/storage/index.d.ts +2 -2
  55. package/dist/storage/index.d.ts.map +1 -1
  56. package/dist/storage/interfaces.d.ts +13 -2
  57. package/dist/storage/interfaces.d.ts.map +1 -1
  58. package/dist/storage/local-sqlite.d.ts.map +1 -1
  59. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  60. package/dist/storage/timestamp-backfill.d.ts +62 -0
  61. package/dist/storage/timestamp-backfill.d.ts.map +1 -0
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +327 -75
  65. package/dist/task-subtree-transfer/authority.d.ts +27 -0
  66. package/dist/task-subtree-transfer/authority.d.ts.map +1 -0
  67. package/dist/task-subtree-transfer/backend.d.ts +9 -0
  68. package/dist/task-subtree-transfer/backend.d.ts.map +1 -0
  69. package/dist/task-subtree-transfer/canonical.d.ts +14 -0
  70. package/dist/task-subtree-transfer/canonical.d.ts.map +1 -0
  71. package/dist/task-subtree-transfer/http.d.ts +16 -0
  72. package/dist/task-subtree-transfer/http.d.ts.map +1 -0
  73. package/dist/task-subtree-transfer/index.d.ts +6 -0
  74. package/dist/task-subtree-transfer/index.d.ts.map +1 -0
  75. package/dist/task-subtree-transfer/planner.d.ts +24 -0
  76. package/dist/task-subtree-transfer/planner.d.ts.map +1 -0
  77. package/dist/task-subtree-transfer/postgres.d.ts +19 -0
  78. package/dist/task-subtree-transfer/postgres.d.ts.map +1 -0
  79. package/dist/task-subtree-transfer/schema-sql.d.ts +5 -0
  80. package/dist/task-subtree-transfer/schema-sql.d.ts.map +1 -0
  81. package/dist/task-subtree-transfer/sqlite.d.ts +16 -0
  82. package/dist/task-subtree-transfer/sqlite.d.ts.map +1 -0
  83. package/dist/task-subtree-transfer/types.d.ts +148 -0
  84. package/dist/task-subtree-transfer/types.d.ts.map +1 -0
  85. package/dist/task-subtree-transfer.d.ts +2 -0
  86. package/dist/task-subtree-transfer.d.ts.map +1 -0
  87. package/dist/task-subtree-transfer.js +1869 -0
  88. package/dist/testing.d.ts +41 -7
  89. package/dist/testing.d.ts.map +1 -1
  90. package/dist/testing.js +44 -1
  91. package/dist/types/index.d.ts +29 -2
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/package.json +11 -4
package/dist/storage.js CHANGED
@@ -42,20 +42,16 @@ var __require = import.meta.require;
42
42
  // src/storage/config.ts
43
43
  var exports_config = {};
44
44
  __export(exports_config, {
45
- parseStorageMode: () => parseStorageMode,
46
45
  parseStorageBackend: () => parseStorageBackend,
47
46
  loadTodosStorageConfig: () => loadTodosStorageConfig,
48
47
  loadStorageConfig: () => loadStorageConfig,
49
48
  isTodosShadowEnabled: () => isTodosShadowEnabled,
50
- isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
51
49
  isTodosPostgresBackend: () => isTodosPostgresBackend,
52
50
  getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
53
- getTodosStorageMode: () => getTodosStorageMode,
54
51
  getTodosStorageEnvName: () => getTodosStorageEnvName,
55
52
  getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
56
53
  getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
57
54
  getTodosStorageBackend: () => getTodosStorageBackend,
58
- getStorageMode: () => getStorageMode,
59
55
  getStorageDatabaseUrl: () => getStorageDatabaseUrl,
60
56
  getStorageDatabaseEnv: () => getStorageDatabaseEnv,
61
57
  getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
@@ -65,7 +61,6 @@ __export(exports_config, {
65
61
  TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
66
62
  TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
67
63
  STORAGE_TABLES: () => STORAGE_TABLES,
68
- REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
69
64
  CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
70
65
  CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
71
66
  CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
@@ -120,9 +115,6 @@ function loadStorageConfig(env = process.env) {
120
115
  function isTodosPostgresBackend(config) {
121
116
  return config.mode === "postgres";
122
117
  }
123
- function isTodosRemoteStorageEnabled(config) {
124
- return isTodosPostgresBackend(config);
125
- }
126
118
  function assertTodosRemoteStorageConfig(config) {
127
119
  if (!isTodosPostgresBackend(config))
128
120
  return;
@@ -138,32 +130,15 @@ function parseStorageBackend(value) {
138
130
  return "sqlite";
139
131
  if (normalized === "postgres" || normalized === "postgresql")
140
132
  return "postgres";
141
- if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
142
- throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} selects the backend. Deployment modes no longer exist: ` + `delete the storage-mode variable and set ${TODOS_STORAGE_ENV.databaseUrl} to select the ` + `postgresql backend, or leave it unset for sqlite.`);
143
- }
144
133
  throw new Error(`Storage backend must be sqlite or postgres`);
145
134
  }
146
- function parseStorageMode(value) {
147
- return parseStorageBackend(value);
148
- }
149
135
  function getTodosStorageBackend(env = process.env) {
150
- for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
151
- if (Object.hasOwn(env, key) && env[key] !== undefined) {
152
- throw new Error(`${key} was removed. Deployment modes no longer exist: delete the storage-mode variable. ` + `Set ${TODOS_STORAGE_ENV.databaseUrl} to select the postgresql backend, ` + `or leave it unset for sqlite.`);
153
- }
154
- }
155
136
  if (!getTodosStorageDatabaseUrl(env))
156
137
  return "sqlite";
157
138
  if (isTodosShadowEnabled(env))
158
139
  return "sqlite";
159
140
  return "postgres";
160
141
  }
161
- function getTodosStorageMode(env = process.env) {
162
- return getTodosStorageBackend(env);
163
- }
164
- function getStorageMode(env = process.env) {
165
- return getTodosStorageBackend(env);
166
- }
167
142
  function isTodosShadowEnabled(env = process.env) {
168
143
  return parseBoolean(readStorageEnv(env, "shadow").value, false);
169
144
  }
@@ -230,7 +205,7 @@ function parsePositiveInteger(value, fallback) {
230
205
  }
231
206
  return parsed;
232
207
  }
233
- var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, REMOVED_STORAGE_MODE_ENV_KEYS, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos";
208
+ var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER_ENV = "HASNA_TODOS_RDS_CLUSTER", CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV = "HASNA_TODOS_RDS_RUNTIME_PATH", CANONICAL_TODOS_RDS_DATABASE = "todos";
234
209
  var init_config = __esm(() => {
235
210
  TODOS_STORAGE_TABLES = [
236
211
  "todos_sync_records",
@@ -269,12 +244,6 @@ var init_config = __esm(() => {
269
244
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
270
245
  syncDryRun: "TODOS_SYNC_DRY_RUN"
271
246
  };
272
- REMOVED_STORAGE_MODE_ENV_KEYS = [
273
- "HASNA_TODOS_STORAGE_MODE",
274
- "HASNA_TODOS_MODE",
275
- "TODOS_STORAGE_MODE",
276
- "TODOS_MODE"
277
- ];
278
247
  });
279
248
 
280
249
  // src/types/index.ts
@@ -2335,6 +2304,10 @@ var init_migrations = __esm(() => {
2335
2304
  );
2336
2305
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2337
2306
  COMMIT;
2307
+ `,
2308
+ `BEGIN;
2309
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
2310
+ COMMIT;
2338
2311
  `
2339
2312
  ];
2340
2313
  });
@@ -3008,6 +2981,8 @@ function ensureSchema(db) {
3008
2981
  ensureColumn("projects", "task_list_id", "TEXT");
3009
2982
  ensureColumn("projects", "task_prefix", "TEXT");
3010
2983
  ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
2984
+ ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
2985
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
3011
2986
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
3012
2987
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
3013
2988
  ensureColumn("tasks", "short_id", "TEXT");
@@ -3121,6 +3096,20 @@ function ensureSchema(db) {
3121
3096
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON webhook_deliveries(event)");
3122
3097
  ensureColumn("task_comments", "type", "TEXT DEFAULT 'comment'");
3123
3098
  ensureColumn("task_comments", "progress_pct", "INTEGER");
3099
+ ensureTable("plan_comments", `
3100
+ CREATE TABLE plan_comments (
3101
+ id TEXT PRIMARY KEY,
3102
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
3103
+ agent_id TEXT,
3104
+ session_id TEXT,
3105
+ content TEXT NOT NULL,
3106
+ type TEXT DEFAULT 'comment' CHECK(type IN ('comment', 'progress', 'note')),
3107
+ progress_pct INTEGER CHECK(progress_pct IS NULL OR (progress_pct >= 0 AND progress_pct <= 100)),
3108
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3109
+ machine_id TEXT,
3110
+ synced_at TEXT
3111
+ )`);
3112
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_plan_comments_plan ON plan_comments(plan_id)");
3124
3113
  ensureColumn("projects", "machine_id", "TEXT");
3125
3114
  ensureColumn("projects", "synced_at", "TEXT");
3126
3115
  ensureColumn("tasks", "machine_id", "TEXT");
@@ -4102,6 +4091,7 @@ var init_machines = __esm(() => {
4102
4091
  "task_lists",
4103
4092
  "plans",
4104
4093
  "task_comments",
4094
+ "plan_comments",
4105
4095
  "sessions",
4106
4096
  "task_history",
4107
4097
  "webhooks",
@@ -5045,17 +5035,24 @@ function createProject(input, db) {
5045
5035
  const id = uuid();
5046
5036
  const timestamp2 = now();
5047
5037
  const derivedSlug = slugify(input.name);
5048
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
5038
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
5049
5039
  if (!derivedSlug || !taskListId)
5050
5040
  throw new Error("Project name and task-list slug must be non-empty");
5051
5041
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
5052
5042
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
5053
5043
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
5054
5044
  }
5045
+ const parentId = input.parent_id ?? null;
5046
+ if (parentId !== null) {
5047
+ const parent = getProject(parentId, d);
5048
+ if (!parent)
5049
+ throw new ProjectNotFoundError(parentId);
5050
+ assertNotProjectAncestor(id, parentId, d);
5051
+ }
5055
5052
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
5056
5053
  const machineId = currentStorageMachineId(d);
5057
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
5058
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
5054
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
5055
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
5059
5056
  return getProject(id, d);
5060
5057
  })();
5061
5058
  }
@@ -5080,6 +5077,60 @@ function listProjects(db) {
5080
5077
  const d = db || getDatabase();
5081
5078
  return d.query("SELECT * FROM projects ORDER BY name").all();
5082
5079
  }
5080
+ function orderProjectsParentFirst(projects) {
5081
+ const projectId = (project) => {
5082
+ const id = project.id;
5083
+ return typeof id === "string" && id.length > 0 ? id : null;
5084
+ };
5085
+ const parentId = (project) => {
5086
+ const parent = project.parent_id;
5087
+ return parent == null ? null : String(parent);
5088
+ };
5089
+ const byId = new Set;
5090
+ for (const project of projects) {
5091
+ const id = projectId(project);
5092
+ if (id !== null)
5093
+ byId.add(id);
5094
+ }
5095
+ const ordered = [];
5096
+ const emitted = new Set;
5097
+ let remaining = [...projects];
5098
+ let progress = true;
5099
+ while (progress && remaining.length > 0) {
5100
+ progress = false;
5101
+ const deferred = [];
5102
+ for (const project of remaining) {
5103
+ const parent = parentId(project);
5104
+ if (parent === null || emitted.has(parent) || !byId.has(parent)) {
5105
+ ordered.push(project);
5106
+ const id = projectId(project);
5107
+ if (id !== null)
5108
+ emitted.add(id);
5109
+ progress = true;
5110
+ } else {
5111
+ deferred.push(project);
5112
+ }
5113
+ }
5114
+ remaining = deferred;
5115
+ }
5116
+ ordered.push(...remaining);
5117
+ return ordered;
5118
+ }
5119
+ function assertNotProjectAncestor(projectId, ancestorId, db) {
5120
+ const d = db || getDatabase();
5121
+ let cursor = ancestorId;
5122
+ const seen = new Set;
5123
+ while (cursor !== null) {
5124
+ if (cursor === projectId) {
5125
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
5126
+ }
5127
+ if (seen.has(cursor))
5128
+ break;
5129
+ seen.add(cursor);
5130
+ const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
5131
+ cursor = row?.parent_id ?? null;
5132
+ }
5133
+ }
5083
5134
  function updateProject(id, input, db) {
5084
5135
  const d = db || getDatabase();
5085
5136
  const project = getProject(id, d);
@@ -5102,6 +5153,16 @@ function updateProject(id, input, db) {
5102
5153
  sets.push("path = ?");
5103
5154
  params.push(input.path);
5104
5155
  }
5156
+ if (input.parent_id !== undefined) {
5157
+ if (input.parent_id !== null) {
5158
+ const parent = getProject(input.parent_id, d);
5159
+ if (!parent)
5160
+ throw new ProjectNotFoundError(input.parent_id);
5161
+ assertNotProjectAncestor(id, input.parent_id, d);
5162
+ }
5163
+ sets.push("parent_id = ?");
5164
+ params.push(input.parent_id);
5165
+ }
5105
5166
  params.push(id);
5106
5167
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
5107
5168
  return getProject(id, d);
@@ -6780,22 +6841,31 @@ function updateTaskList(id, input, db) {
6780
6841
  throw new TaskListNotFoundError(id);
6781
6842
  const sets = ["updated_at = ?"];
6782
6843
  const params = [now()];
6783
- if (input.slug !== undefined) {
6784
- const slug = slugify(input.slug);
6785
- if (!slug)
6786
- throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
6787
- const duplicate = existing.project_id ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND id != ?").get(existing.project_id, slug, id) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ? AND id != ?").get(slug, id);
6844
+ const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? existing.project_id : input.project_id;
6845
+ const scopeChanged = projectId !== existing.project_id;
6846
+ const effectiveSlug = input.slug !== undefined ? slugify(input.slug) : existing.slug;
6847
+ if (input.slug !== undefined && !effectiveSlug) {
6848
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
6849
+ }
6850
+ if (scopeChanged || input.slug !== undefined && effectiveSlug !== existing.slug) {
6851
+ const duplicate = projectId ? d.query("SELECT id FROM task_lists WHERE project_id = ? AND slug = ? AND id != ?").get(projectId, effectiveSlug, id) : d.query("SELECT id FROM task_lists WHERE project_id IS NULL AND slug = ? AND id != ?").get(effectiveSlug, id);
6788
6852
  if (duplicate) {
6789
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
6853
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
6790
6854
  }
6791
- if (slug !== existing.slug) {
6855
+ if (scopeChanged || effectiveSlug !== existing.slug) {
6792
6856
  releaseCanonicalSlugClaims("task_list", id, d);
6793
- if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
6794
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
6857
+ if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(projectId), effectiveSlug, id, d)) {
6858
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
6795
6859
  }
6796
6860
  }
6861
+ }
6862
+ if (input.slug !== undefined) {
6797
6863
  sets.push("slug = ?");
6798
- params.push(slug);
6864
+ params.push(effectiveSlug);
6865
+ }
6866
+ if (projectId !== existing.project_id) {
6867
+ sets.push("project_id = ?");
6868
+ params.push(projectId);
6799
6869
  }
6800
6870
  if (input.name !== undefined) {
6801
6871
  sets.push("name = ?");
@@ -9244,8 +9314,8 @@ function failTask(id, agentId, reason, options, db) {
9244
9314
  const safeMeta = sanitizePreWriteValue(meta, "task.failure.metadata");
9245
9315
  const timestamp2 = now();
9246
9316
  const failTx = d.transaction(() => {
9247
- const res = d.run(`UPDATE tasks SET status = 'failed', reason = ?, locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
9248
- WHERE id = ? AND version = ?`, [safeReason, JSON.stringify(safeMeta), timestamp2, id, task.version]);
9317
+ const res = d.run(`UPDATE tasks SET status = 'failed', reason = ?, locked_by = NULL, locked_at = NULL, completed_at = COALESCE(completed_at, ?), metadata = ?, version = version + 1, updated_at = ?
9318
+ WHERE id = ? AND version = ?`, [safeReason, timestamp2, JSON.stringify(safeMeta), timestamp2, id, task.version]);
9249
9319
  if (res.changes === 0) {
9250
9320
  const current = getTask(id, d);
9251
9321
  throw new VersionConflictError(id, task.version, current?.version ?? -1);
@@ -9257,6 +9327,7 @@ function failTask(id, agentId, reason, options, db) {
9257
9327
  status: "failed",
9258
9328
  locked_by: null,
9259
9329
  locked_at: null,
9330
+ completed_at: task.completed_at ?? timestamp2,
9260
9331
  reason: safeReason,
9261
9332
  metadata: safeMeta,
9262
9333
  version: task.version + 1,
@@ -9905,13 +9976,20 @@ function updateTaskStored(id, input, db) {
9905
9976
  if (isTerminalStatus(input.status)) {
9906
9977
  sets.push("locked_by = NULL");
9907
9978
  sets.push("locked_at = NULL");
9908
- }
9909
- if (input.status === "completed") {
9910
- sets.push("completed_at = ?");
9911
- params.push(completionTimestamp);
9979
+ if (input.status === "completed") {
9980
+ sets.push("completed_at = ?");
9981
+ params.push(completionTimestamp);
9982
+ } else {
9983
+ sets.push("completed_at = COALESCE(completed_at, ?)");
9984
+ params.push(completionTimestamp);
9985
+ }
9912
9986
  } else if (task.status === "completed" && input.completed_at === undefined) {
9913
9987
  sets.push("completed_at = NULL");
9914
9988
  }
9989
+ if (input.status === "in_progress") {
9990
+ sets.push("started_at = COALESCE(started_at, ?)");
9991
+ params.push(timestamp2);
9992
+ }
9915
9993
  }
9916
9994
  if (input.priority !== undefined) {
9917
9995
  sets.push("priority = ?");
@@ -10049,7 +10127,6 @@ function updateTaskStored(id, input, db) {
10049
10127
  if (input.approved_by !== undefined)
10050
10128
  logTaskChange(id, "approve", "approved_by", null, input.approved_by, agentId, d);
10051
10129
  const reopened = input.status !== undefined && input.status !== "completed" && task.status === "completed" && input.completed_at === undefined;
10052
- const completedNow = input.status === "completed";
10053
10130
  const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
10054
10131
  const updatedTask = {
10055
10132
  ...task,
@@ -10060,7 +10137,8 @@ function updateTaskStored(id, input, db) {
10060
10137
  updated_at: timestamp2,
10061
10138
  locked_by: terminalNow ? null : task.locked_by,
10062
10139
  locked_at: terminalNow ? null : task.locked_at,
10063
- completed_at: completedNow ? completionTimestamp : reopened ? null : input.completed_at !== undefined ? input.completed_at : task.completed_at,
10140
+ completed_at: input.status === "completed" ? completionTimestamp : input.completed_at !== undefined ? input.completed_at : terminalNow ? task.completed_at ?? completionTimestamp : reopened ? null : task.completed_at,
10141
+ started_at: input.status === "in_progress" ? task.started_at ?? timestamp2 : task.started_at,
10064
10142
  sla_minutes: input.sla_minutes !== undefined ? input.sla_minutes : task.sla_minutes,
10065
10143
  actual_minutes: input.actual_minutes ?? task.actual_minutes,
10066
10144
  confidence: input.confidence !== undefined ? input.confidence : task.confidence,
@@ -10121,6 +10199,9 @@ function deleteTask(id, db) {
10121
10199
  version: row.version
10122
10200
  }, d);
10123
10201
  const result = d.run("DELETE FROM tasks WHERE id = ?", [id]);
10202
+ if (result.changes > 0) {
10203
+ d.run("DELETE FROM task_dependencies WHERE task_id = ? OR depends_on = ?", [id, id]);
10204
+ }
10124
10205
  return result.changes > 0;
10125
10206
  }
10126
10207
  var init_task_crud = __esm(() => {
@@ -12890,6 +12971,40 @@ init_tasks();
12890
12971
  init_projects();
12891
12972
  init_plans();
12892
12973
 
12974
+ // src/db/plan-comments.ts
12975
+ init_types();
12976
+ init_database();
12977
+ init_plans();
12978
+ init_prewrite_secrets();
12979
+ function addPlanComment(input, db) {
12980
+ const d = db || getDatabase();
12981
+ if (!getPlan(input.plan_id, d)) {
12982
+ throw new PlanNotFoundError(input.plan_id);
12983
+ }
12984
+ const id = uuid();
12985
+ const timestamp2 = now();
12986
+ d.run(`INSERT INTO plan_comments (id, plan_id, agent_id, session_id, content, type, progress_pct, created_at)
12987
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
12988
+ id,
12989
+ input.plan_id,
12990
+ input.agent_id || null,
12991
+ input.session_id || null,
12992
+ sanitizePreWriteText(input.content, "comment.content"),
12993
+ input.type || "comment",
12994
+ input.progress_pct ?? null,
12995
+ timestamp2
12996
+ ]);
12997
+ return getPlanComment(id, d);
12998
+ }
12999
+ function getPlanComment(id, db) {
13000
+ const d = db || getDatabase();
13001
+ return d.query("SELECT * FROM plan_comments WHERE id = ?").get(id);
13002
+ }
13003
+ function listPlanComments(planId, db) {
13004
+ const d = db || getDatabase();
13005
+ return d.query("SELECT * FROM plan_comments WHERE plan_id = ? ORDER BY created_at, rowid").all(planId);
13006
+ }
13007
+
12893
13008
  // src/db/agents.ts
12894
13009
  init_types();
12895
13010
  init_database();
@@ -13547,6 +13662,20 @@ init_task_lists();
13547
13662
  init_templates();
13548
13663
  init_audit();
13549
13664
  init_comments();
13665
+
13666
+ // src/lib/comment-cursor.ts
13667
+ function isStrictlyOlder(comment, before) {
13668
+ return comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id;
13669
+ }
13670
+ function compareCommentKeyset(left, right) {
13671
+ if (isStrictlyOlder(left, right))
13672
+ return -1;
13673
+ if (isStrictlyOlder(right, left))
13674
+ return 1;
13675
+ return 0;
13676
+ }
13677
+
13678
+ // src/storage/local-sqlite.ts
13550
13679
  init_database();
13551
13680
 
13552
13681
  // src/db/integrity.ts
@@ -14091,6 +14220,7 @@ var PROJECT_COLUMNS = [
14091
14220
  "task_list_id",
14092
14221
  "task_prefix",
14093
14222
  "task_counter",
14223
+ "parent_id",
14094
14224
  "created_at",
14095
14225
  "updated_at",
14096
14226
  "machine_id",
@@ -14310,7 +14440,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
14310
14440
  }
14311
14441
  }
14312
14442
  };
14313
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
14443
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
14314
14444
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
14315
14445
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
14316
14446
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -14642,7 +14772,22 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14642
14772
  list: (projectId) => listPlans(projectId, database()),
14643
14773
  update: (id, input) => updatePlan(id, input, database()),
14644
14774
  completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
14645
- delete: (id) => deletePlan(id, database())
14775
+ delete: (id) => deletePlan(id, database()),
14776
+ addComment: (input) => addPlanComment(input, database()),
14777
+ getComments: (planId) => listPlanComments(planId, database()),
14778
+ getCommentsPage: (planId, options2) => {
14779
+ if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
14780
+ throw new Error("Plan comment limit must be an integer between 1 and 1001");
14781
+ }
14782
+ let comments = listPlanComments(planId, database());
14783
+ comments = comments.sort(compareCommentKeyset);
14784
+ if (options2?.before) {
14785
+ const before = options2.before;
14786
+ comments = comments.filter((comment) => isStrictlyOlder(comment, before));
14787
+ }
14788
+ const limit = options2?.limit ?? 100;
14789
+ return comments.slice(-limit);
14790
+ }
14646
14791
  },
14647
14792
  planProjectLinks: {
14648
14793
  apply: (input) => applyPlanProjectLinkSqlite(input, database()),
@@ -14683,10 +14828,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
14683
14828
  throw new Error("Comment limit must be an integer between 1 and 1001");
14684
14829
  }
14685
14830
  let comments = listComments(taskId, database());
14686
- comments = comments.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id));
14831
+ comments = comments.sort(compareCommentKeyset);
14687
14832
  if (options2?.before) {
14688
14833
  const before = options2.before;
14689
- comments = comments.filter((comment) => comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id);
14834
+ comments = comments.filter((comment) => isStrictlyOlder(comment, before));
14690
14835
  }
14691
14836
  if (options2?.limit !== undefined)
14692
14837
  comments = comments.slice(-options2.limit);
@@ -15216,7 +15361,26 @@ function createPostgresTodosStorageAdapter(options) {
15216
15361
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
15217
15362
  update: (id, input) => updatePlan2(id, input, store),
15218
15363
  completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
15219
- delete: (id, context) => store.deletePlan(id, context)
15364
+ delete: (id, context) => store.deletePlan(id, context),
15365
+ addComment: (input, context) => addPlanComment2(input, store, context),
15366
+ getComments: async (planId) => {
15367
+ const pages = [];
15368
+ let before;
15369
+ while (true) {
15370
+ const page = await store.listPlanComments(planId, { limit: 1000, ...before ? { before } : {} });
15371
+ if (page.length === 0)
15372
+ break;
15373
+ pages.unshift(page);
15374
+ if (page.length < 1000)
15375
+ break;
15376
+ const oldest = page[0];
15377
+ before = { created_at: oldest.created_at, id: oldest.id };
15378
+ }
15379
+ return pages.flat().map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
15380
+ },
15381
+ getCommentsPage: async (planId, options2) => {
15382
+ return (await store.listPlanComments(planId, options2)).map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
15383
+ }
15220
15384
  },
15221
15385
  planProjectLinks: {
15222
15386
  apply: (input, context) => store.applyPlanProjectLink(input, context),
@@ -15434,6 +15598,27 @@ class PostgresJsonRecordStore {
15434
15598
  LIMIT $${params.length}`, params);
15435
15599
  return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
15436
15600
  }
15601
+ async listPlanComments(planId, options = {}) {
15602
+ await this.ensureSchema();
15603
+ const limit = options.limit ?? 100;
15604
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
15605
+ throw new Error("Postgres plan comment limit must be an integer between 1 and 1001");
15606
+ }
15607
+ const params = [this.service, planId];
15608
+ let cursorPredicate = "";
15609
+ if (options.before) {
15610
+ params.push(options.before.created_at, options.before.id);
15611
+ cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
15612
+ }
15613
+ params.push(limit);
15614
+ const result = await this.options.client.query(`/* todos:list-plan-comments */ SELECT payload FROM ${this.tableName}
15615
+ WHERE service = $1 AND object_type = 'plan_comments' AND deleted_at IS NULL
15616
+ AND payload->>'plan_id' = $2
15617
+ ${cursorPredicate}
15618
+ ORDER BY payload->>'created_at' DESC, object_id DESC
15619
+ LIMIT $${params.length}`, params);
15620
+ return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
15621
+ }
15437
15622
  async listRecords(type) {
15438
15623
  await this.ensureSchema();
15439
15624
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
@@ -15765,8 +15950,12 @@ class PostgresJsonRecordStore {
15765
15950
  OR ($11::text <> $2
15766
15951
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
15767
15952
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
15768
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
15769
- ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
15953
+ (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
15954
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
15955
+ (NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
15956
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
15957
+ OR $8::text IS NULL
15958
+ OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
15770
15959
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
15771
15960
  ), guarded AS (
15772
15961
  SELECT
@@ -15789,7 +15978,6 @@ class PostgresJsonRecordStore {
15789
15978
  AND guarded.version_matches
15790
15979
  AND guarded.parent_found
15791
15980
  AND guarded.parent_acyclic
15792
- AND guarded.all_plans_found
15793
15981
  AND guarded.target_plan_found
15794
15982
  AND NOT guarded.project_conflict
15795
15983
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -15805,7 +15993,7 @@ class PostgresJsonRecordStore {
15805
15993
  RETURNING payload
15806
15994
  )
15807
15995
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
15808
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
15996
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
15809
15997
  (SELECT payload FROM stored) AS payload,
15810
15998
  (SELECT payload FROM locked_task) AS current_payload
15811
15999
  FROM guarded`, [
@@ -15837,7 +16025,7 @@ class PostgresJsonRecordStore {
15837
16025
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
15838
16026
  throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
15839
16027
  }
15840
- if (!row?.all_plans_found || !row.target_plan_found) {
16028
+ if (!row?.target_plan_found) {
15841
16029
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan membership changed through a missing plan: ${targetPlanId ?? planIds.join(", ")}`, { plan_ids: planIds, target_plan_id: targetPlanId });
15842
16030
  }
15843
16031
  if (row.project_conflict) {
@@ -16592,6 +16780,15 @@ class PostgresJsonRecordStore {
16592
16780
  WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
16593
16781
  AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
16594
16782
  RETURNING 1
16783
+ ), deleted_plan_comments AS (
16784
+ UPDATE ${this.tableName} r SET
16785
+ deleted_at = $3::timestamptz,
16786
+ updated_at = $3::timestamptz,
16787
+ source_machine_id = COALESCE($4, r.source_machine_id),
16788
+ version = COALESCE(r.version, 0) + 1
16789
+ WHERE r.service = $1 AND r.object_type = 'plan_comments' AND r.deleted_at IS NULL
16790
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
16791
+ RETURNING 1
16595
16792
  ), deleted_plan AS (
16596
16793
  UPDATE ${this.tableName} r SET
16597
16794
  deleted_at = $3::timestamptz,
@@ -16767,7 +16964,8 @@ async function updateTask2(id, input, store, context) {
16767
16964
  task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
16768
16965
  parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
16769
16966
  created_by: existing.created_by,
16770
- completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
16967
+ completed_at: input.status === "completed" ? input.completed_at ?? new Date().toISOString() : input.completed_at !== undefined ? input.completed_at : input.status !== undefined && isTerminalStatus(input.status) ? existing.completed_at ?? new Date().toISOString() : reopened ? null : existing.completed_at,
16968
+ started_at: input.status === "in_progress" ? existing.started_at ?? new Date().toISOString() : existing.started_at
16771
16969
  };
16772
16970
  const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
16773
16971
  operation: "update",
@@ -16812,7 +17010,8 @@ async function failTask2(id, agentId, reason, options, store) {
16812
17010
  status: "failed",
16813
17011
  assigned_to: task.assigned_to ?? agentId ?? null,
16814
17012
  reason: reason ?? task.reason,
16815
- retry_after: options?.retry_after ?? task.retry_after
17013
+ retry_after: options?.retry_after ?? task.retry_after,
17014
+ completed_at: task.completed_at ?? new Date().toISOString()
16816
17015
  }, store);
16817
17016
  if (!options?.retry)
16818
17017
  return { task: failed };
@@ -17049,17 +17248,26 @@ async function getChangedSince(since, filters, store) {
17049
17248
  async function createProject2(input, store, context) {
17050
17249
  const timestamp2 = new Date().toISOString();
17051
17250
  const derivedSlug = slugifyRaw(input.name);
17052
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
17251
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
17053
17252
  if (!derivedSlug || !taskListId)
17054
17253
  throw new Error("Project name and task-list slug must be non-empty");
17254
+ const parentId = input.parent_id ?? null;
17255
+ const id = randomUUID3();
17256
+ if (parentId !== null) {
17257
+ const parent = await store.get("projects", parentId);
17258
+ if (!parent)
17259
+ throw new ProjectNotFoundError(parentId);
17260
+ await assertNotProjectAncestorPostgres(id, parentId, store);
17261
+ }
17055
17262
  const project = {
17056
- id: randomUUID3(),
17263
+ id,
17057
17264
  name: input.name,
17058
17265
  path: input.path,
17059
17266
  description: input.description ?? null,
17060
17267
  task_list_id: taskListId,
17061
17268
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
17062
17269
  task_counter: 0,
17270
+ parent_id: parentId,
17063
17271
  created_at: timestamp2,
17064
17272
  updated_at: timestamp2,
17065
17273
  machine_id: store.machineId(context),
@@ -17067,12 +17275,38 @@ async function createProject2(input, store, context) {
17067
17275
  };
17068
17276
  return store.upsert("projects", project, context);
17069
17277
  }
17278
+ async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
17279
+ const all = await store.list("projects");
17280
+ const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
17281
+ let cursor = candidateParentId;
17282
+ const seen = new Set;
17283
+ while (cursor !== null) {
17284
+ if (cursor === projectId) {
17285
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
17286
+ }
17287
+ if (seen.has(cursor))
17288
+ break;
17289
+ seen.add(cursor);
17290
+ cursor = byId.get(cursor) ?? null;
17291
+ }
17292
+ }
17070
17293
  async function updateProject2(id, input, store) {
17071
17294
  if ("task_list_id" in input) {
17072
17295
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
17073
17296
  }
17074
17297
  const project = await requireRecord("projects", id, store);
17075
- const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
17298
+ if (input.parent_id !== undefined && input.parent_id !== null) {
17299
+ const parent = await store.get("projects", input.parent_id);
17300
+ if (!parent)
17301
+ throw new ProjectNotFoundError(input.parent_id);
17302
+ await assertNotProjectAncestorPostgres(id, input.parent_id, store);
17303
+ }
17304
+ const updated = {
17305
+ ...project,
17306
+ ...definedPatch(input),
17307
+ ...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
17308
+ updated_at: new Date().toISOString()
17309
+ };
17076
17310
  return store.upsert("projects", updated);
17077
17311
  }
17078
17312
  async function createPlan2(input, store, context) {
@@ -17225,16 +17459,22 @@ async function createTaskList2(input, store, context) {
17225
17459
  async function updateTaskList2(id, input, store) {
17226
17460
  const list = await requireRecord("task_lists", id, store);
17227
17461
  const patch = definedPatch(input);
17462
+ const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
17228
17463
  if (input.slug !== undefined) {
17229
17464
  const slug = slugifyRaw(input.slug);
17230
17465
  if (!slug)
17231
17466
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
17232
- const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
17467
+ patch.slug = slug;
17468
+ }
17469
+ const effectiveSlug = patch.slug ?? list.slug;
17470
+ if (projectId !== list.project_id || effectiveSlug !== list.slug) {
17471
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === projectId && candidate.slug === effectiveSlug);
17233
17472
  if (duplicate) {
17234
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
17473
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
17235
17474
  }
17236
- patch.slug = slug;
17237
17475
  }
17476
+ if (projectId !== list.project_id)
17477
+ patch.project_id = projectId;
17238
17478
  return store.upsert("task_lists", {
17239
17479
  ...list,
17240
17480
  ...patch,
@@ -17327,6 +17567,22 @@ async function addComment2(input, store, context) {
17327
17567
  function redactComment(comment) {
17328
17568
  return { ...comment, content: redactEvidenceText(comment.content) };
17329
17569
  }
17570
+ async function addPlanComment2(input, store, context) {
17571
+ const comment = {
17572
+ id: randomUUID3(),
17573
+ plan_id: input.plan_id,
17574
+ agent_id: input.agent_id ?? context?.agentId ?? null,
17575
+ session_id: input.session_id ?? context?.sessionId ?? null,
17576
+ content: redactEvidenceText(input.content),
17577
+ type: input.type ?? "comment",
17578
+ progress_pct: input.progress_pct ?? null,
17579
+ created_at: new Date().toISOString()
17580
+ };
17581
+ return store.upsert("plan_comments", comment, context);
17582
+ }
17583
+ function redactPlanComment(comment) {
17584
+ return { ...comment, content: redactEvidenceText(comment.content) };
17585
+ }
17330
17586
  async function exportSnapshot(store) {
17331
17587
  return {
17332
17588
  exportedAt: new Date().toISOString(),
@@ -18935,24 +19191,20 @@ export {
18935
19191
  postgresTodosScopedSlugIndexStatusSql,
18936
19192
  postgresTodosCommentCursorIndexSql,
18937
19193
  planRunArtifactsS3Sync,
18938
- parseStorageMode,
18939
19194
  parseStorageBackend,
18940
19195
  maybeInstallShadowCapture2 as maybeInstallShadowCapture,
18941
19196
  loadTodosStorageConfig,
18942
19197
  loadStorageConfig,
18943
19198
  isTodosShadowEnabled,
18944
- isTodosRemoteStorageEnabled,
18945
19199
  isTodosPostgresBackend,
18946
19200
  isCommentRedactionBackfillComplete,
18947
19201
  installShadowOutboxSchema,
18948
19202
  importSqliteTodosStorageSnapshot,
18949
19203
  getTodosStorageShadowEnvName,
18950
- getTodosStorageMode,
18951
19204
  getTodosStorageEnvName,
18952
19205
  getTodosStorageDatabaseUrl,
18953
19206
  getTodosStorageDatabaseEnv,
18954
19207
  getTodosStorageBackend,
18955
- getStorageMode,
18956
19208
  getStorageDatabaseUrl,
18957
19209
  getStorageDatabaseEnv,
18958
19210
  getRuntimeShadowOutbox,