@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
@@ -2900,6 +2900,10 @@ var init_migrations = __esm(() => {
2900
2900
  );
2901
2901
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2902
2902
  COMMIT;
2903
+ `,
2904
+ `BEGIN;
2905
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
2906
+ COMMIT;
2903
2907
  `
2904
2908
  ];
2905
2909
  });
@@ -3573,6 +3577,8 @@ function ensureSchema(db) {
3573
3577
  ensureColumn("projects", "task_list_id", "TEXT");
3574
3578
  ensureColumn("projects", "task_prefix", "TEXT");
3575
3579
  ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
3580
+ ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
3581
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
3576
3582
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
3577
3583
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
3578
3584
  ensureColumn("tasks", "short_id", "TEXT");
@@ -3686,6 +3692,20 @@ function ensureSchema(db) {
3686
3692
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_event ON webhook_deliveries(event)");
3687
3693
  ensureColumn("task_comments", "type", "TEXT DEFAULT 'comment'");
3688
3694
  ensureColumn("task_comments", "progress_pct", "INTEGER");
3695
+ ensureTable("plan_comments", `
3696
+ CREATE TABLE plan_comments (
3697
+ id TEXT PRIMARY KEY,
3698
+ plan_id TEXT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
3699
+ agent_id TEXT,
3700
+ session_id TEXT,
3701
+ content TEXT NOT NULL,
3702
+ type TEXT DEFAULT 'comment' CHECK(type IN ('comment', 'progress', 'note')),
3703
+ progress_pct INTEGER CHECK(progress_pct IS NULL OR (progress_pct >= 0 AND progress_pct <= 100)),
3704
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3705
+ machine_id TEXT,
3706
+ synced_at TEXT
3707
+ )`);
3708
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_plan_comments_plan ON plan_comments(plan_id)");
3689
3709
  ensureColumn("projects", "machine_id", "TEXT");
3690
3710
  ensureColumn("projects", "synced_at", "TEXT");
3691
3711
  ensureColumn("tasks", "machine_id", "TEXT");
@@ -4667,6 +4687,7 @@ var init_machines = __esm(() => {
4667
4687
  "task_lists",
4668
4688
  "plans",
4669
4689
  "task_comments",
4690
+ "plan_comments",
4670
4691
  "sessions",
4671
4692
  "task_history",
4672
4693
  "webhooks",
@@ -4860,20 +4881,16 @@ var init_identity_mapping = __esm(() => {
4860
4881
  // src/storage/config.ts
4861
4882
  var exports_config = {};
4862
4883
  __export(exports_config, {
4863
- parseStorageMode: () => parseStorageMode,
4864
4884
  parseStorageBackend: () => parseStorageBackend,
4865
4885
  loadTodosStorageConfig: () => loadTodosStorageConfig,
4866
4886
  loadStorageConfig: () => loadStorageConfig,
4867
4887
  isTodosShadowEnabled: () => isTodosShadowEnabled,
4868
- isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
4869
4888
  isTodosPostgresBackend: () => isTodosPostgresBackend,
4870
4889
  getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
4871
- getTodosStorageMode: () => getTodosStorageMode,
4872
4890
  getTodosStorageEnvName: () => getTodosStorageEnvName,
4873
4891
  getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
4874
4892
  getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
4875
4893
  getTodosStorageBackend: () => getTodosStorageBackend,
4876
- getStorageMode: () => getStorageMode,
4877
4894
  getStorageDatabaseUrl: () => getStorageDatabaseUrl,
4878
4895
  getStorageDatabaseEnv: () => getStorageDatabaseEnv,
4879
4896
  getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
@@ -4883,7 +4900,6 @@ __export(exports_config, {
4883
4900
  TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
4884
4901
  TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
4885
4902
  STORAGE_TABLES: () => STORAGE_TABLES,
4886
- REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
4887
4903
  CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
4888
4904
  CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
4889
4905
  CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
@@ -4938,9 +4954,6 @@ function loadStorageConfig(env = process.env) {
4938
4954
  function isTodosPostgresBackend(config) {
4939
4955
  return config.mode === "postgres";
4940
4956
  }
4941
- function isTodosRemoteStorageEnabled(config) {
4942
- return isTodosPostgresBackend(config);
4943
- }
4944
4957
  function assertTodosRemoteStorageConfig(config) {
4945
4958
  if (!isTodosPostgresBackend(config))
4946
4959
  return;
@@ -4956,32 +4969,15 @@ function parseStorageBackend(value) {
4956
4969
  return "sqlite";
4957
4970
  if (normalized === "postgres" || normalized === "postgresql")
4958
4971
  return "postgres";
4959
- if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
4960
- 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.`);
4961
- }
4962
4972
  throw new Error(`Storage backend must be sqlite or postgres`);
4963
4973
  }
4964
- function parseStorageMode(value) {
4965
- return parseStorageBackend(value);
4966
- }
4967
4974
  function getTodosStorageBackend(env = process.env) {
4968
- for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
4969
- if (Object.hasOwn(env, key) && env[key] !== undefined) {
4970
- 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.`);
4971
- }
4972
- }
4973
4975
  if (!getTodosStorageDatabaseUrl(env))
4974
4976
  return "sqlite";
4975
4977
  if (isTodosShadowEnabled(env))
4976
4978
  return "sqlite";
4977
4979
  return "postgres";
4978
4980
  }
4979
- function getTodosStorageMode(env = process.env) {
4980
- return getTodosStorageBackend(env);
4981
- }
4982
- function getStorageMode(env = process.env) {
4983
- return getTodosStorageBackend(env);
4984
- }
4985
4981
  function isTodosShadowEnabled(env = process.env) {
4986
4982
  return parseBoolean(readStorageEnv(env, "shadow").value, false);
4987
4983
  }
@@ -5048,7 +5044,7 @@ function parsePositiveInteger(value, fallback) {
5048
5044
  }
5049
5045
  return parsed;
5050
5046
  }
5051
- 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";
5047
+ 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";
5052
5048
  var init_config2 = __esm(() => {
5053
5049
  TODOS_STORAGE_TABLES = [
5054
5050
  "todos_sync_records",
@@ -5087,12 +5083,6 @@ var init_config2 = __esm(() => {
5087
5083
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
5088
5084
  syncDryRun: "TODOS_SYNC_DRY_RUN"
5089
5085
  };
5090
- REMOVED_STORAGE_MODE_ENV_KEYS = [
5091
- "HASNA_TODOS_STORAGE_MODE",
5092
- "HASNA_TODOS_MODE",
5093
- "TODOS_STORAGE_MODE",
5094
- "TODOS_MODE"
5095
- ];
5096
5086
  });
5097
5087
 
5098
5088
  // src/storage/shadow-outbox-schema.ts
@@ -5574,17 +5564,24 @@ function createProject2(input, db) {
5574
5564
  const id = uuid();
5575
5565
  const timestamp2 = now();
5576
5566
  const derivedSlug = slugify(input.name);
5577
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
5567
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
5578
5568
  if (!derivedSlug || !taskListId)
5579
5569
  throw new Error("Project name and task-list slug must be non-empty");
5580
5570
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
5581
5571
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
5582
5572
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
5583
5573
  }
5574
+ const parentId = input.parent_id ?? null;
5575
+ if (parentId !== null) {
5576
+ const parent = getProject(parentId, d);
5577
+ if (!parent)
5578
+ throw new ProjectNotFoundError(parentId);
5579
+ assertNotProjectAncestor(id, parentId, d);
5580
+ }
5584
5581
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
5585
5582
  const machineId = currentStorageMachineId(d);
5586
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
5587
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
5583
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
5584
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
5588
5585
  return getProject(id, d);
5589
5586
  })();
5590
5587
  }
@@ -5609,6 +5606,60 @@ function listProjects(db) {
5609
5606
  const d = db || getDatabase();
5610
5607
  return d.query("SELECT * FROM projects ORDER BY name").all();
5611
5608
  }
5609
+ function orderProjectsParentFirst(projects) {
5610
+ const projectId = (project) => {
5611
+ const id = project.id;
5612
+ return typeof id === "string" && id.length > 0 ? id : null;
5613
+ };
5614
+ const parentId = (project) => {
5615
+ const parent = project.parent_id;
5616
+ return parent == null ? null : String(parent);
5617
+ };
5618
+ const byId = new Set;
5619
+ for (const project of projects) {
5620
+ const id = projectId(project);
5621
+ if (id !== null)
5622
+ byId.add(id);
5623
+ }
5624
+ const ordered = [];
5625
+ const emitted = new Set;
5626
+ let remaining = [...projects];
5627
+ let progress = true;
5628
+ while (progress && remaining.length > 0) {
5629
+ progress = false;
5630
+ const deferred = [];
5631
+ for (const project of remaining) {
5632
+ const parent = parentId(project);
5633
+ if (parent === null || emitted.has(parent) || !byId.has(parent)) {
5634
+ ordered.push(project);
5635
+ const id = projectId(project);
5636
+ if (id !== null)
5637
+ emitted.add(id);
5638
+ progress = true;
5639
+ } else {
5640
+ deferred.push(project);
5641
+ }
5642
+ }
5643
+ remaining = deferred;
5644
+ }
5645
+ ordered.push(...remaining);
5646
+ return ordered;
5647
+ }
5648
+ function assertNotProjectAncestor(projectId, ancestorId, db) {
5649
+ const d = db || getDatabase();
5650
+ let cursor = ancestorId;
5651
+ const seen = new Set;
5652
+ while (cursor !== null) {
5653
+ if (cursor === projectId) {
5654
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
5655
+ }
5656
+ if (seen.has(cursor))
5657
+ break;
5658
+ seen.add(cursor);
5659
+ const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
5660
+ cursor = row?.parent_id ?? null;
5661
+ }
5662
+ }
5612
5663
  function updateProject2(id, input, db) {
5613
5664
  const d = db || getDatabase();
5614
5665
  const project = getProject(id, d);
@@ -5631,6 +5682,16 @@ function updateProject2(id, input, db) {
5631
5682
  sets.push("path = ?");
5632
5683
  params.push(input.path);
5633
5684
  }
5685
+ if (input.parent_id !== undefined) {
5686
+ if (input.parent_id !== null) {
5687
+ const parent = getProject(input.parent_id, d);
5688
+ if (!parent)
5689
+ throw new ProjectNotFoundError(input.parent_id);
5690
+ assertNotProjectAncestor(id, input.parent_id, d);
5691
+ }
5692
+ sets.push("parent_id = ?");
5693
+ params.push(input.parent_id);
5694
+ }
5634
5695
  params.push(id);
5635
5696
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
5636
5697
  return getProject(id, d);
@@ -7193,22 +7254,31 @@ function updateTaskList2(id, input, db) {
7193
7254
  throw new TaskListNotFoundError(id);
7194
7255
  const sets = ["updated_at = ?"];
7195
7256
  const params = [now()];
7196
- if (input.slug !== undefined) {
7197
- const slug = slugify(input.slug);
7198
- if (!slug)
7199
- throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
7200
- 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);
7257
+ const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? existing.project_id : input.project_id;
7258
+ const scopeChanged = projectId !== existing.project_id;
7259
+ const effectiveSlug = input.slug !== undefined ? slugify(input.slug) : existing.slug;
7260
+ if (input.slug !== undefined && !effectiveSlug) {
7261
+ throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
7262
+ }
7263
+ if (scopeChanged || input.slug !== undefined && effectiveSlug !== existing.slug) {
7264
+ 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);
7201
7265
  if (duplicate) {
7202
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
7266
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
7203
7267
  }
7204
- if (slug !== existing.slug) {
7268
+ if (scopeChanged || effectiveSlug !== existing.slug) {
7205
7269
  releaseCanonicalSlugClaims("task_list", id, d);
7206
- if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(existing.project_id), slug, id, d)) {
7207
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
7270
+ if (!claimCanonicalSlug("task_list", taskListSlugScopeKey(projectId), effectiveSlug, id, d)) {
7271
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
7208
7272
  }
7209
7273
  }
7274
+ }
7275
+ if (input.slug !== undefined) {
7210
7276
  sets.push("slug = ?");
7211
- params.push(slug);
7277
+ params.push(effectiveSlug);
7278
+ }
7279
+ if (projectId !== existing.project_id) {
7280
+ sets.push("project_id = ?");
7281
+ params.push(projectId);
7212
7282
  }
7213
7283
  if (input.name !== undefined) {
7214
7284
  sets.push("name = ?");
@@ -9244,8 +9314,8 @@ function failTask2(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 failTask2(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
  logTaskChange2(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(() => {
@@ -12731,7 +12812,7 @@ import { createHash as createHash7 } from "crypto";
12731
12812
  // package.json
12732
12813
  var package_default = {
12733
12814
  name: "@hasna/todos",
12734
- version: "0.15.34",
12815
+ version: "0.15.41",
12735
12816
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12736
12817
  type: "module",
12737
12818
  main: "dist/index.js",
@@ -12777,6 +12858,10 @@ var package_default = {
12777
12858
  "./task-manifest": {
12778
12859
  types: "./dist/task-manifest.d.ts",
12779
12860
  import: "./dist/task-manifest.js"
12861
+ },
12862
+ "./task-subtree-transfer": {
12863
+ types: "./dist/task-subtree-transfer.d.ts",
12864
+ import: "./dist/task-subtree-transfer.js"
12780
12865
  }
12781
12866
  },
12782
12867
  workspaces: [
@@ -12790,8 +12875,8 @@ var package_default = {
12790
12875
  "README.md"
12791
12876
  ],
12792
12877
  scripts: {
12793
- build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
12794
- "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
12878
+ build: "rm -rf dist dashboard/dist && cd dashboard && bun install --frozen-lockfile && bun run build && cd .. && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --root src --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && tsc --emitDeclarationOnly --outDir dist",
12879
+ "build:server": "rm -rf dist && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/server/index.ts --outdir dist/server --target bun && bun build src/sdk/index.ts --outdir dist/sdk --target bun --external '@hasna/contracts' --external '@hasna/contracts/*' && bun build src/index.ts src/mcp.ts src/registry.ts src/contracts.ts src/storage.ts src/testing.ts src/project-registration.ts src/task-manifest.ts src/task-subtree-transfer.ts --outdir dist --target bun --external '@hasna/contracts' --external '@hasna/contracts/*'",
12795
12880
  migrate: "bun run src/server/index.ts migrate",
12796
12881
  "backfill:comment-redaction": "bun run src/server/index.ts redact-comments",
12797
12882
  "generate:sdk": "bun run scripts/generate-sdk.ts",
@@ -12806,6 +12891,9 @@ var package_default = {
12806
12891
  "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
12807
12892
  "verify:attested-container-candidate": "bun run scripts/attested-container-candidate.ts verify",
12808
12893
  "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
12894
+ "emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
12895
+ "verify:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts verify",
12896
+ "test:attested-container-compatibility-vector": "bun test scripts/attested-container-compatibility-vector.test.ts",
12809
12897
  "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
12810
12898
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
12811
12899
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
@@ -12842,7 +12930,7 @@ var package_default = {
12842
12930
  author: "Andrei Hasna <andrei@hasna.com>",
12843
12931
  license: "Apache-2.0",
12844
12932
  dependencies: {
12845
- "@hasna/contracts": "0.5.2",
12933
+ "@hasna/contracts": "0.13.3",
12846
12934
  "@hasna/events": "^0.1.11",
12847
12935
  "@modelcontextprotocol/sdk": "^1.12.1",
12848
12936
  chalk: "^5.4.1",
@@ -13703,7 +13791,26 @@ function createPostgresTodosStorageAdapter(options) {
13703
13791
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
13704
13792
  update: (id, input) => updatePlan(id, input, store),
13705
13793
  completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
13706
- delete: (id, context) => store.deletePlan(id, context)
13794
+ delete: (id, context) => store.deletePlan(id, context),
13795
+ addComment: (input, context) => addPlanComment(input, store, context),
13796
+ getComments: async (planId) => {
13797
+ const pages = [];
13798
+ let before;
13799
+ while (true) {
13800
+ const page = await store.listPlanComments(planId, { limit: 1000, ...before ? { before } : {} });
13801
+ if (page.length === 0)
13802
+ break;
13803
+ pages.unshift(page);
13804
+ if (page.length < 1000)
13805
+ break;
13806
+ const oldest = page[0];
13807
+ before = { created_at: oldest.created_at, id: oldest.id };
13808
+ }
13809
+ return pages.flat().map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
13810
+ },
13811
+ getCommentsPage: async (planId, options2) => {
13812
+ return (await store.listPlanComments(planId, options2)).map(redactPlanComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
13813
+ }
13707
13814
  },
13708
13815
  planProjectLinks: {
13709
13816
  apply: (input, context) => store.applyPlanProjectLink(input, context),
@@ -13921,6 +14028,27 @@ class PostgresJsonRecordStore {
13921
14028
  LIMIT $${params.length}`, params);
13922
14029
  return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
13923
14030
  }
14031
+ async listPlanComments(planId, options = {}) {
14032
+ await this.ensureSchema();
14033
+ const limit = options.limit ?? 100;
14034
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1001) {
14035
+ throw new Error("Postgres plan comment limit must be an integer between 1 and 1001");
14036
+ }
14037
+ const params = [this.service, planId];
14038
+ let cursorPredicate = "";
14039
+ if (options.before) {
14040
+ params.push(options.before.created_at, options.before.id);
14041
+ cursorPredicate = `AND (payload->>'created_at', object_id) < ($3, $4)`;
14042
+ }
14043
+ params.push(limit);
14044
+ const result = await this.options.client.query(`/* todos:list-plan-comments */ SELECT payload FROM ${this.tableName}
14045
+ WHERE service = $1 AND object_type = 'plan_comments' AND deleted_at IS NULL
14046
+ AND payload->>'plan_id' = $2
14047
+ ${cursorPredicate}
14048
+ ORDER BY payload->>'created_at' DESC, object_id DESC
14049
+ LIMIT $${params.length}`, params);
14050
+ return result.rows.map((row) => payloadRecord2(row.payload)).reverse();
14051
+ }
13924
14052
  async listRecords(type) {
13925
14053
  await this.ensureSchema();
13926
14054
  const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at
@@ -14252,8 +14380,12 @@ class PostgresJsonRecordStore {
14252
14380
  OR ($11::text <> $2
14253
14381
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
14254
14382
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
14255
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
14256
- ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
14383
+ (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
14384
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
14385
+ (NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
14386
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
14387
+ OR $8::text IS NULL
14388
+ OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
14257
14389
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
14258
14390
  ), guarded AS (
14259
14391
  SELECT
@@ -14276,7 +14408,6 @@ class PostgresJsonRecordStore {
14276
14408
  AND guarded.version_matches
14277
14409
  AND guarded.parent_found
14278
14410
  AND guarded.parent_acyclic
14279
- AND guarded.all_plans_found
14280
14411
  AND guarded.target_plan_found
14281
14412
  AND NOT guarded.project_conflict
14282
14413
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -14292,7 +14423,7 @@ class PostgresJsonRecordStore {
14292
14423
  RETURNING payload
14293
14424
  )
14294
14425
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
14295
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
14426
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
14296
14427
  (SELECT payload FROM stored) AS payload,
14297
14428
  (SELECT payload FROM locked_task) AS current_payload
14298
14429
  FROM guarded`, [
@@ -14324,7 +14455,7 @@ class PostgresJsonRecordStore {
14324
14455
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
14325
14456
  throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
14326
14457
  }
14327
- if (!row?.all_plans_found || !row.target_plan_found) {
14458
+ if (!row?.target_plan_found) {
14328
14459
  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 });
14329
14460
  }
14330
14461
  if (row.project_conflict) {
@@ -15079,6 +15210,15 @@ class PostgresJsonRecordStore {
15079
15210
  WHERE r.service = $1 AND r.object_type = 'tasks' AND r.deleted_at IS NULL
15080
15211
  AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
15081
15212
  RETURNING 1
15213
+ ), deleted_plan_comments AS (
15214
+ UPDATE ${this.tableName} r SET
15215
+ deleted_at = $3::timestamptz,
15216
+ updated_at = $3::timestamptz,
15217
+ source_machine_id = COALESCE($4, r.source_machine_id),
15218
+ version = COALESCE(r.version, 0) + 1
15219
+ WHERE r.service = $1 AND r.object_type = 'plan_comments' AND r.deleted_at IS NULL
15220
+ AND r.payload->>'plan_id' = $2 AND EXISTS (SELECT 1 FROM target)
15221
+ RETURNING 1
15082
15222
  ), deleted_plan AS (
15083
15223
  UPDATE ${this.tableName} r SET
15084
15224
  deleted_at = $3::timestamptz,
@@ -15254,7 +15394,8 @@ async function updateTask(id, input, store, context) {
15254
15394
  task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
15255
15395
  parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
15256
15396
  created_by: existing.created_by,
15257
- completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
15397
+ 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,
15398
+ started_at: input.status === "in_progress" ? existing.started_at ?? new Date().toISOString() : existing.started_at
15258
15399
  };
15259
15400
  const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
15260
15401
  operation: "update",
@@ -15299,7 +15440,8 @@ async function failTask(id, agentId, reason, options, store) {
15299
15440
  status: "failed",
15300
15441
  assigned_to: task.assigned_to ?? agentId ?? null,
15301
15442
  reason: reason ?? task.reason,
15302
- retry_after: options?.retry_after ?? task.retry_after
15443
+ retry_after: options?.retry_after ?? task.retry_after,
15444
+ completed_at: task.completed_at ?? new Date().toISOString()
15303
15445
  }, store);
15304
15446
  if (!options?.retry)
15305
15447
  return { task: failed };
@@ -15536,17 +15678,26 @@ async function getChangedSince(since, filters, store) {
15536
15678
  async function createProject(input, store, context) {
15537
15679
  const timestamp = new Date().toISOString();
15538
15680
  const derivedSlug = slugifyRaw(input.name);
15539
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
15681
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
15540
15682
  if (!derivedSlug || !taskListId)
15541
15683
  throw new Error("Project name and task-list slug must be non-empty");
15684
+ const parentId = input.parent_id ?? null;
15685
+ const id = randomUUID();
15686
+ if (parentId !== null) {
15687
+ const parent = await store.get("projects", parentId);
15688
+ if (!parent)
15689
+ throw new ProjectNotFoundError(parentId);
15690
+ await assertNotProjectAncestorPostgres(id, parentId, store);
15691
+ }
15542
15692
  const project = {
15543
- id: randomUUID(),
15693
+ id,
15544
15694
  name: input.name,
15545
15695
  path: input.path,
15546
15696
  description: input.description ?? null,
15547
15697
  task_list_id: taskListId,
15548
15698
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
15549
15699
  task_counter: 0,
15700
+ parent_id: parentId,
15550
15701
  created_at: timestamp,
15551
15702
  updated_at: timestamp,
15552
15703
  machine_id: store.machineId(context),
@@ -15554,12 +15705,38 @@ async function createProject(input, store, context) {
15554
15705
  };
15555
15706
  return store.upsert("projects", project, context);
15556
15707
  }
15708
+ async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
15709
+ const all = await store.list("projects");
15710
+ const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
15711
+ let cursor = candidateParentId;
15712
+ const seen = new Set;
15713
+ while (cursor !== null) {
15714
+ if (cursor === projectId) {
15715
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
15716
+ }
15717
+ if (seen.has(cursor))
15718
+ break;
15719
+ seen.add(cursor);
15720
+ cursor = byId.get(cursor) ?? null;
15721
+ }
15722
+ }
15557
15723
  async function updateProject(id, input, store) {
15558
15724
  if ("task_list_id" in input) {
15559
15725
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
15560
15726
  }
15561
15727
  const project = await requireRecord("projects", id, store);
15562
- const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
15728
+ if (input.parent_id !== undefined && input.parent_id !== null) {
15729
+ const parent = await store.get("projects", input.parent_id);
15730
+ if (!parent)
15731
+ throw new ProjectNotFoundError(input.parent_id);
15732
+ await assertNotProjectAncestorPostgres(id, input.parent_id, store);
15733
+ }
15734
+ const updated = {
15735
+ ...project,
15736
+ ...definedPatch(input),
15737
+ ...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
15738
+ updated_at: new Date().toISOString()
15739
+ };
15563
15740
  return store.upsert("projects", updated);
15564
15741
  }
15565
15742
  async function createPlan(input, store, context) {
@@ -15712,16 +15889,22 @@ async function createTaskList(input, store, context) {
15712
15889
  async function updateTaskList(id, input, store) {
15713
15890
  const list = await requireRecord("task_lists", id, store);
15714
15891
  const patch = definedPatch(input);
15892
+ const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
15715
15893
  if (input.slug !== undefined) {
15716
15894
  const slug = slugifyRaw(input.slug);
15717
15895
  if (!slug)
15718
15896
  throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
15719
- const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === list.project_id && candidate.slug === slug);
15897
+ patch.slug = slug;
15898
+ }
15899
+ const effectiveSlug = patch.slug ?? list.slug;
15900
+ if (projectId !== list.project_id || effectiveSlug !== list.slug) {
15901
+ const duplicate = (await store.list("task_lists")).find((candidate) => candidate.id !== id && candidate.project_id === projectId && candidate.slug === effectiveSlug);
15720
15902
  if (duplicate) {
15721
- throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${slug}" already exists in this scope`);
15903
+ throw new ResourceConflictError("TASK_LIST_SLUG_CONFLICT", `Task list with slug "${effectiveSlug}" already exists in this scope`);
15722
15904
  }
15723
- patch.slug = slug;
15724
15905
  }
15906
+ if (projectId !== list.project_id)
15907
+ patch.project_id = projectId;
15725
15908
  return store.upsert("task_lists", {
15726
15909
  ...list,
15727
15910
  ...patch,
@@ -15814,6 +15997,22 @@ async function addComment(input, store, context) {
15814
15997
  function redactComment(comment) {
15815
15998
  return { ...comment, content: redactEvidenceText(comment.content) };
15816
15999
  }
16000
+ async function addPlanComment(input, store, context) {
16001
+ const comment = {
16002
+ id: randomUUID(),
16003
+ plan_id: input.plan_id,
16004
+ agent_id: input.agent_id ?? context?.agentId ?? null,
16005
+ session_id: input.session_id ?? context?.sessionId ?? null,
16006
+ content: redactEvidenceText(input.content),
16007
+ type: input.type ?? "comment",
16008
+ progress_pct: input.progress_pct ?? null,
16009
+ created_at: new Date().toISOString()
16010
+ };
16011
+ return store.upsert("plan_comments", comment, context);
16012
+ }
16013
+ function redactPlanComment(comment) {
16014
+ return { ...comment, content: redactEvidenceText(comment.content) };
16015
+ }
15817
16016
  async function exportSnapshot(store) {
15818
16017
  return {
15819
16018
  exportedAt: new Date().toISOString(),
@@ -16699,6 +16898,40 @@ init_tasks();
16699
16898
  init_projects();
16700
16899
  init_plans();
16701
16900
 
16901
+ // src/db/plan-comments.ts
16902
+ init_types();
16903
+ init_database();
16904
+ init_plans();
16905
+ init_prewrite_secrets();
16906
+ function addPlanComment2(input, db) {
16907
+ const d = db || getDatabase();
16908
+ if (!getPlan(input.plan_id, d)) {
16909
+ throw new PlanNotFoundError(input.plan_id);
16910
+ }
16911
+ const id = uuid();
16912
+ const timestamp2 = now();
16913
+ d.run(`INSERT INTO plan_comments (id, plan_id, agent_id, session_id, content, type, progress_pct, created_at)
16914
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
16915
+ id,
16916
+ input.plan_id,
16917
+ input.agent_id || null,
16918
+ input.session_id || null,
16919
+ sanitizePreWriteText(input.content, "comment.content"),
16920
+ input.type || "comment",
16921
+ input.progress_pct ?? null,
16922
+ timestamp2
16923
+ ]);
16924
+ return getPlanComment(id, d);
16925
+ }
16926
+ function getPlanComment(id, db) {
16927
+ const d = db || getDatabase();
16928
+ return d.query("SELECT * FROM plan_comments WHERE id = ?").get(id);
16929
+ }
16930
+ function listPlanComments(planId, db) {
16931
+ const d = db || getDatabase();
16932
+ return d.query("SELECT * FROM plan_comments WHERE plan_id = ? ORDER BY created_at, rowid").all(planId);
16933
+ }
16934
+
16702
16935
  // src/db/agents.ts
16703
16936
  init_types();
16704
16937
  init_database();
@@ -17351,6 +17584,20 @@ init_task_lists();
17351
17584
  init_templates();
17352
17585
  init_audit();
17353
17586
  init_comments();
17587
+
17588
+ // src/lib/comment-cursor.ts
17589
+ function isStrictlyOlder(comment, before) {
17590
+ return comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id;
17591
+ }
17592
+ function compareCommentKeyset(left, right) {
17593
+ if (isStrictlyOlder(left, right))
17594
+ return -1;
17595
+ if (isStrictlyOlder(right, left))
17596
+ return 1;
17597
+ return 0;
17598
+ }
17599
+
17600
+ // src/storage/local-sqlite.ts
17354
17601
  init_database();
17355
17602
 
17356
17603
  // src/db/integrity.ts
@@ -17570,6 +17817,7 @@ var PROJECT_COLUMNS = [
17570
17817
  "task_list_id",
17571
17818
  "task_prefix",
17572
17819
  "task_counter",
17820
+ "parent_id",
17573
17821
  "created_at",
17574
17822
  "updated_at",
17575
17823
  "machine_id",
@@ -17789,7 +18037,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
17789
18037
  }
17790
18038
  }
17791
18039
  };
17792
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
18040
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
17793
18041
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
17794
18042
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
17795
18043
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -18121,7 +18369,22 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
18121
18369
  list: (projectId) => listPlans(projectId, database()),
18122
18370
  update: (id, input) => updatePlan2(id, input, database()),
18123
18371
  completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
18124
- delete: (id) => deletePlan(id, database())
18372
+ delete: (id) => deletePlan(id, database()),
18373
+ addComment: (input) => addPlanComment2(input, database()),
18374
+ getComments: (planId) => listPlanComments(planId, database()),
18375
+ getCommentsPage: (planId, options2) => {
18376
+ if (options2?.limit !== undefined && (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 1001)) {
18377
+ throw new Error("Plan comment limit must be an integer between 1 and 1001");
18378
+ }
18379
+ let comments = listPlanComments(planId, database());
18380
+ comments = comments.sort(compareCommentKeyset);
18381
+ if (options2?.before) {
18382
+ const before = options2.before;
18383
+ comments = comments.filter((comment) => isStrictlyOlder(comment, before));
18384
+ }
18385
+ const limit = options2?.limit ?? 100;
18386
+ return comments.slice(-limit);
18387
+ }
18125
18388
  },
18126
18389
  planProjectLinks: {
18127
18390
  apply: (input) => applyPlanProjectLinkSqlite(input, database()),
@@ -18162,10 +18425,10 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
18162
18425
  throw new Error("Comment limit must be an integer between 1 and 1001");
18163
18426
  }
18164
18427
  let comments = listComments(taskId, database());
18165
- comments = comments.sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id));
18428
+ comments = comments.sort(compareCommentKeyset);
18166
18429
  if (options2?.before) {
18167
18430
  const before = options2.before;
18168
- comments = comments.filter((comment) => comment.created_at < before.created_at || comment.created_at === before.created_at && comment.id < before.id);
18431
+ comments = comments.filter((comment) => isStrictlyOlder(comment, before));
18169
18432
  }
18170
18433
  if (options2?.limit !== undefined)
18171
18434
  comments = comments.slice(-options2.limit);
@@ -18619,7 +18882,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
18619
18882
  }
18620
18883
  async createProject(input) {
18621
18884
  const derivedSlug = normalizeSlug(input.name);
18622
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
18885
+ const taskListId = input.task_list_id === undefined ? derivedSlug : normalizeSlug(input.task_list_id);
18623
18886
  if (!derivedSlug || !taskListId) {
18624
18887
  throw new Error("Project name and task-list slug must be non-empty");
18625
18888
  }
@@ -18631,6 +18894,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
18631
18894
  task_list_id: taskListId,
18632
18895
  task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
18633
18896
  task_counter: 0,
18897
+ parent_id: input.parent_id ?? null,
18634
18898
  created_at: now(),
18635
18899
  updated_at: now(),
18636
18900
  machine_id: currentStorageMachineId(this.db)
@@ -18641,14 +18905,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
18641
18905
  try {
18642
18906
  const result = this.db.run(`INSERT INTO projects (
18643
18907
  id, name, path, description, task_list_id, task_prefix,
18644
- task_counter, created_at, updated_at, machine_id
18645
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
18908
+ task_counter, parent_id, created_at, updated_at, machine_id
18909
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
18646
18910
  project.id,
18647
18911
  project.name,
18648
18912
  project.path,
18649
18913
  project.description,
18650
18914
  project.task_list_id,
18651
18915
  project.task_prefix,
18916
+ project.parent_id,
18652
18917
  project.created_at,
18653
18918
  project.updated_at,
18654
18919
  project.machine_id ?? null
@@ -19044,7 +19309,10 @@ function taskListSlug(projectSlug) {
19044
19309
  if (!slug || slug !== projectSlug) {
19045
19310
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
19046
19311
  }
19047
- return `todos-${slug}`;
19312
+ return slug;
19313
+ }
19314
+ function legacyTaskListSlug(projectSlug) {
19315
+ return `todos-${normalizeSlug(projectSlug)}`;
19048
19316
  }
19049
19317
  function deterministicTaskPrefix(projectSlug) {
19050
19318
  const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
@@ -19563,6 +19831,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19563
19831
  created_by_operation: false
19564
19832
  };
19565
19833
  }
19834
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === legacyTaskListSlug(request.project_slug)) {
19835
+ return {
19836
+ record: boundExistingProjectRecord(conflict2),
19837
+ created_by_operation: false
19838
+ };
19839
+ }
19566
19840
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
19567
19841
  }
19568
19842
  await this.fault("before_object_write", request);
@@ -19599,6 +19873,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19599
19873
  }
19600
19874
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
19601
19875
  }
19876
+ if (request.bind_existing === true) {
19877
+ const legacy = await transaction.findTaskListConflict(todosProjectId, legacyTaskListSlug(request.project_slug));
19878
+ if (legacy && legacy.project_id === todosProjectId) {
19879
+ return {
19880
+ record: boundExistingTaskListRecord(legacy),
19881
+ created_by_operation: false
19882
+ };
19883
+ }
19884
+ }
19602
19885
  await this.fault("before_object_write", request);
19603
19886
  const taskList = await transaction.createTaskList({
19604
19887
  name: request.project_name,