@hasna/todos 0.15.33 → 0.15.35

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 (43) hide show
  1. package/dist/cli/cloud-router.d.ts +33 -47
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  5. package/dist/cli/index.js +344 -127
  6. package/dist/cli/stage-a.d.ts +3 -1
  7. package/dist/cli/stage-a.d.ts.map +1 -1
  8. package/dist/contracts.js +115 -27
  9. package/dist/db/migrations.d.ts.map +1 -1
  10. package/dist/db/projects.d.ts +23 -0
  11. package/dist/db/projects.d.ts.map +1 -1
  12. package/dist/db/schema.d.ts.map +1 -1
  13. package/dist/index.js +275 -116
  14. package/dist/json-contracts.d.ts.map +1 -1
  15. package/dist/lib/assignee-validation.d.ts.map +1 -1
  16. package/dist/lib/import-export-bridge.d.ts.map +1 -1
  17. package/dist/lib/onboarding-fixtures.d.ts.map +1 -1
  18. package/dist/mcp/index.js +285 -113
  19. package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
  20. package/dist/mcp.js +3 -2
  21. package/dist/project-registration/authority.d.ts.map +1 -1
  22. package/dist/project-registration/sqlite.d.ts.map +1 -1
  23. package/dist/project-registration.js +185 -40
  24. package/dist/registry.js +115 -27
  25. package/dist/release-provenance.json +5 -5
  26. package/dist/sdk/v1.generated.d.ts +3 -0
  27. package/dist/sdk/v1.generated.d.ts.map +1 -1
  28. package/dist/server/index.js +1268 -7051
  29. package/dist/server/openapi.d.ts +13 -0
  30. package/dist/server/openapi.d.ts.map +1 -1
  31. package/dist/server/v1.d.ts.map +1 -1
  32. package/dist/storage/config.d.ts +24 -4
  33. package/dist/storage/config.d.ts.map +1 -1
  34. package/dist/storage/hybrid.d.ts +5 -3
  35. package/dist/storage/hybrid.d.ts.map +1 -1
  36. package/dist/storage.js +158 -34
  37. package/dist/task-manifest.js +3 -3
  38. package/dist/testing.d.ts +16 -7
  39. package/dist/testing.d.ts.map +1 -1
  40. package/dist/testing.js +10 -6
  41. package/dist/types/index.d.ts +5 -2
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -1884,6 +1884,10 @@ var init_migrations = __esm(() => {
1884
1884
  );
1885
1885
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
1886
1886
  COMMIT;
1887
+ `,
1888
+ `BEGIN;
1889
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
1890
+ COMMIT;
1887
1891
  `
1888
1892
  ];
1889
1893
  });
@@ -2557,6 +2561,8 @@ function ensureSchema(db) {
2557
2561
  ensureColumn("projects", "task_list_id", "TEXT");
2558
2562
  ensureColumn("projects", "task_prefix", "TEXT");
2559
2563
  ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
2564
+ ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
2565
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
2560
2566
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
2561
2567
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
2562
2568
  ensureColumn("tasks", "short_id", "TEXT");
@@ -4177,6 +4183,7 @@ __export(exports_config, {
4177
4183
  TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
4178
4184
  TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
4179
4185
  STORAGE_TABLES: () => STORAGE_TABLES,
4186
+ REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
4180
4187
  CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
4181
4188
  CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
4182
4189
  CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
@@ -4238,25 +4245,36 @@ function assertTodosRemoteStorageConfig(config) {
4238
4245
  if (!isTodosPostgresBackend(config))
4239
4246
  return;
4240
4247
  if (!config.database?.url) {
4241
- throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
4248
+ throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when the postgresql backend is selected`);
4242
4249
  }
4243
4250
  }
4244
4251
  function parseStorageBackend(value) {
4245
4252
  const normalized = clean(value)?.toLowerCase();
4246
4253
  if (!normalized)
4247
4254
  return "sqlite";
4248
- if (normalized === "sqlite" || normalized === "postgres")
4249
- return normalized;
4250
- const legacy = LEGACY_BACKEND_TOKENS[normalized];
4251
- if (legacy)
4252
- return legacy;
4253
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
4255
+ if (normalized === "sqlite")
4256
+ return "sqlite";
4257
+ if (normalized === "postgres" || normalized === "postgresql")
4258
+ return "postgres";
4259
+ if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
4260
+ 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.`);
4261
+ }
4262
+ throw new Error(`Storage backend must be sqlite or postgres`);
4254
4263
  }
4255
4264
  function parseStorageMode(value) {
4256
4265
  return parseStorageBackend(value);
4257
4266
  }
4258
4267
  function getTodosStorageBackend(env = process.env) {
4259
- return parseStorageBackend(readStorageEnv(env, "mode").value);
4268
+ for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
4269
+ if (Object.hasOwn(env, key) && env[key] !== undefined) {
4270
+ 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.`);
4271
+ }
4272
+ }
4273
+ if (!getTodosStorageDatabaseUrl(env))
4274
+ return "sqlite";
4275
+ if (isTodosShadowEnabled(env))
4276
+ return "sqlite";
4277
+ return "postgres";
4260
4278
  }
4261
4279
  function getTodosStorageMode(env = process.env) {
4262
4280
  return getTodosStorageBackend(env);
@@ -4330,7 +4348,7 @@ function parsePositiveInteger(value, fallback) {
4330
4348
  }
4331
4349
  return parsed;
4332
4350
  }
4333
- 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", LEGACY_BACKEND_TOKENS;
4351
+ 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";
4334
4352
  var init_config = __esm(() => {
4335
4353
  TODOS_STORAGE_TABLES = [
4336
4354
  "todos_sync_records",
@@ -4338,7 +4356,6 @@ var init_config = __esm(() => {
4338
4356
  ];
4339
4357
  STORAGE_TABLES = TODOS_STORAGE_TABLES;
4340
4358
  TODOS_STORAGE_ENV = {
4341
- mode: "HASNA_TODOS_STORAGE_MODE",
4342
4359
  shadow: "HASNA_TODOS_SHADOW",
4343
4360
  databaseUrl: "HASNA_TODOS_DATABASE_URL",
4344
4361
  databaseSsl: "HASNA_TODOS_DATABASE_SSL",
@@ -4355,7 +4372,6 @@ var init_config = __esm(() => {
4355
4372
  syncDryRun: "HASNA_TODOS_SYNC_DRY_RUN"
4356
4373
  };
4357
4374
  TODOS_STORAGE_FALLBACK_ENV = {
4358
- mode: "TODOS_STORAGE_MODE",
4359
4375
  shadow: "TODOS_SHADOW",
4360
4376
  databaseUrl: "TODOS_DATABASE_URL",
4361
4377
  databaseSsl: "TODOS_DATABASE_SSL",
@@ -4371,14 +4387,12 @@ var init_config = __esm(() => {
4371
4387
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
4372
4388
  syncDryRun: "TODOS_SYNC_DRY_RUN"
4373
4389
  };
4374
- LEGACY_BACKEND_TOKENS = {
4375
- local: "sqlite",
4376
- remote: "postgres",
4377
- postgresql: "postgres",
4378
- hybrid: "postgres",
4379
- self_hosted: "postgres",
4380
- cloud: "postgres"
4381
- };
4390
+ REMOVED_STORAGE_MODE_ENV_KEYS = [
4391
+ "HASNA_TODOS_STORAGE_MODE",
4392
+ "HASNA_TODOS_MODE",
4393
+ "TODOS_STORAGE_MODE",
4394
+ "TODOS_MODE"
4395
+ ];
4382
4396
  });
4383
4397
 
4384
4398
  // src/storage/shadow-outbox-schema.ts
@@ -5037,17 +5051,24 @@ function createProject(input, db) {
5037
5051
  const id = uuid();
5038
5052
  const timestamp2 = now();
5039
5053
  const derivedSlug = slugify(input.name);
5040
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
5054
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
5041
5055
  if (!derivedSlug || !taskListId)
5042
5056
  throw new Error("Project name and task-list slug must be non-empty");
5043
5057
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
5044
5058
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
5045
5059
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
5046
5060
  }
5061
+ const parentId = input.parent_id ?? null;
5062
+ if (parentId !== null) {
5063
+ const parent = getProject(parentId, d);
5064
+ if (!parent)
5065
+ throw new ProjectNotFoundError(parentId);
5066
+ assertNotProjectAncestor(id, parentId, d);
5067
+ }
5047
5068
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
5048
5069
  const machineId = currentStorageMachineId(d);
5049
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
5050
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
5070
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
5071
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
5051
5072
  return getProject(id, d);
5052
5073
  })();
5053
5074
  }
@@ -5072,6 +5093,60 @@ function listProjects(db) {
5072
5093
  const d = db || getDatabase();
5073
5094
  return d.query("SELECT * FROM projects ORDER BY name").all();
5074
5095
  }
5096
+ function orderProjectsParentFirst(projects) {
5097
+ const projectId = (project) => {
5098
+ const id = project.id;
5099
+ return typeof id === "string" && id.length > 0 ? id : null;
5100
+ };
5101
+ const parentId = (project) => {
5102
+ const parent = project.parent_id;
5103
+ return parent == null ? null : String(parent);
5104
+ };
5105
+ const byId = new Set;
5106
+ for (const project of projects) {
5107
+ const id = projectId(project);
5108
+ if (id !== null)
5109
+ byId.add(id);
5110
+ }
5111
+ const ordered = [];
5112
+ const emitted = new Set;
5113
+ let remaining = [...projects];
5114
+ let progress = true;
5115
+ while (progress && remaining.length > 0) {
5116
+ progress = false;
5117
+ const deferred = [];
5118
+ for (const project of remaining) {
5119
+ const parent = parentId(project);
5120
+ if (parent === null || emitted.has(parent) || !byId.has(parent)) {
5121
+ ordered.push(project);
5122
+ const id = projectId(project);
5123
+ if (id !== null)
5124
+ emitted.add(id);
5125
+ progress = true;
5126
+ } else {
5127
+ deferred.push(project);
5128
+ }
5129
+ }
5130
+ remaining = deferred;
5131
+ }
5132
+ ordered.push(...remaining);
5133
+ return ordered;
5134
+ }
5135
+ function assertNotProjectAncestor(projectId, ancestorId, db) {
5136
+ const d = db || getDatabase();
5137
+ let cursor = ancestorId;
5138
+ const seen = new Set;
5139
+ while (cursor !== null) {
5140
+ if (cursor === projectId) {
5141
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
5142
+ }
5143
+ if (seen.has(cursor))
5144
+ break;
5145
+ seen.add(cursor);
5146
+ const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
5147
+ cursor = row?.parent_id ?? null;
5148
+ }
5149
+ }
5075
5150
  function updateProject(id, input, db) {
5076
5151
  const d = db || getDatabase();
5077
5152
  const project = getProject(id, d);
@@ -5094,6 +5169,16 @@ function updateProject(id, input, db) {
5094
5169
  sets.push("path = ?");
5095
5170
  params.push(input.path);
5096
5171
  }
5172
+ if (input.parent_id !== undefined) {
5173
+ if (input.parent_id !== null) {
5174
+ const parent = getProject(input.parent_id, d);
5175
+ if (!parent)
5176
+ throw new ProjectNotFoundError(input.parent_id);
5177
+ assertNotProjectAncestor(id, input.parent_id, d);
5178
+ }
5179
+ sets.push("parent_id = ?");
5180
+ params.push(input.parent_id);
5181
+ }
5097
5182
  params.push(id);
5098
5183
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
5099
5184
  return getProject(id, d);
@@ -7227,8 +7312,8 @@ var init_secret_redaction = __esm(() => {
7227
7312
  SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
7228
7313
  DEFAULT_PATTERNS = [
7229
7314
  { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
7230
- { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
7231
- { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
7315
+ { name: "github_pat", pattern: /\bgh[p]_[a-zA-Z0-9]{20,}\b/g },
7316
+ { name: "github_oauth", pattern: /\bgh[o]_[a-zA-Z0-9]{20,}\b/g },
7232
7317
  { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
7233
7318
  { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
7234
7319
  { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
@@ -7240,7 +7325,7 @@ var init_secret_redaction = __esm(() => {
7240
7325
  /example\.com/i,
7241
7326
  /your-api-key-here/i,
7242
7327
  /sk-test/i,
7243
- /ghp_xxx/i
7328
+ /gh[p]_xxx/i
7244
7329
  ];
7245
7330
  customRedactors = [];
7246
7331
  });
@@ -12833,7 +12918,7 @@ var init_dispatches = __esm(() => {
12833
12918
  // package.json
12834
12919
  var package_default = {
12835
12920
  name: "@hasna/todos",
12836
- version: "0.15.33",
12921
+ version: "0.15.35",
12837
12922
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12838
12923
  type: "module",
12839
12924
  main: "dist/index.js",
@@ -12907,9 +12992,10 @@ var package_default = {
12907
12992
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
12908
12993
  "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
12909
12994
  "verify:attested-container-candidate": "bun run scripts/attested-container-candidate.ts verify",
12910
- "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts scripts/attested-container-compatibility-vector.test.ts",
12995
+ "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
12911
12996
  "emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
12912
12997
  "verify:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts verify",
12998
+ "test:attested-container-compatibility-vector": "bun test scripts/attested-container-compatibility-vector.test.ts",
12913
12999
  "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
12914
13000
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
12915
13001
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
@@ -13598,6 +13684,7 @@ var TODOS_JSON_CONTRACTS = [
13598
13684
  task_list_id: nullableIdField,
13599
13685
  task_prefix: field(["string", "null"], "Optional task prefix.", true),
13600
13686
  task_counter: field("integer", "Monotonic project task counter."),
13687
+ parent_id: field(["string", "null"], "Optional parent project id; null means top-level.", true),
13601
13688
  created_at: isoDateField,
13602
13689
  updated_at: isoDateField
13603
13690
  },
@@ -16883,6 +16970,7 @@ function createAgentProjectDemoBundle() {
16883
16970
  task_list_id: ids.list,
16884
16971
  task_prefix: "DEMO",
16885
16972
  task_counter: 4,
16973
+ parent_id: null,
16886
16974
  created_at: createdAt,
16887
16975
  updated_at: completedAt
16888
16976
  });
@@ -26523,6 +26611,7 @@ var PROJECT_COLUMNS = [
26523
26611
  "task_list_id",
26524
26612
  "task_prefix",
26525
26613
  "task_counter",
26614
+ "parent_id",
26526
26615
  "created_at",
26527
26616
  "updated_at",
26528
26617
  "machine_id",
@@ -26742,7 +26831,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
26742
26831
  }
26743
26832
  }
26744
26833
  };
26745
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
26834
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
26746
26835
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
26747
26836
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
26748
26837
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -28197,8 +28286,12 @@ class PostgresJsonRecordStore {
28197
28286
  OR ($11::text <> $2
28198
28287
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
28199
28288
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
28200
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
28201
- ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
28289
+ (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
28290
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
28291
+ (NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
28292
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
28293
+ OR $8::text IS NULL
28294
+ OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
28202
28295
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
28203
28296
  ), guarded AS (
28204
28297
  SELECT
@@ -28221,7 +28314,6 @@ class PostgresJsonRecordStore {
28221
28314
  AND guarded.version_matches
28222
28315
  AND guarded.parent_found
28223
28316
  AND guarded.parent_acyclic
28224
- AND guarded.all_plans_found
28225
28317
  AND guarded.target_plan_found
28226
28318
  AND NOT guarded.project_conflict
28227
28319
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -28237,7 +28329,7 @@ class PostgresJsonRecordStore {
28237
28329
  RETURNING payload
28238
28330
  )
28239
28331
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
28240
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
28332
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
28241
28333
  (SELECT payload FROM stored) AS payload,
28242
28334
  (SELECT payload FROM locked_task) AS current_payload
28243
28335
  FROM guarded`, [
@@ -28269,7 +28361,7 @@ class PostgresJsonRecordStore {
28269
28361
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
28270
28362
  throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
28271
28363
  }
28272
- if (!row?.all_plans_found || !row.target_plan_found) {
28364
+ if (!row?.target_plan_found) {
28273
28365
  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 });
28274
28366
  }
28275
28367
  if (row.project_conflict) {
@@ -29481,17 +29573,26 @@ async function getChangedSince(since, filters, store) {
29481
29573
  async function createProject2(input, store, context) {
29482
29574
  const timestamp3 = new Date().toISOString();
29483
29575
  const derivedSlug = slugifyRaw(input.name);
29484
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
29576
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
29485
29577
  if (!derivedSlug || !taskListId)
29486
29578
  throw new Error("Project name and task-list slug must be non-empty");
29579
+ const parentId = input.parent_id ?? null;
29580
+ const id = randomUUID3();
29581
+ if (parentId !== null) {
29582
+ const parent = await store.get("projects", parentId);
29583
+ if (!parent)
29584
+ throw new ProjectNotFoundError(parentId);
29585
+ await assertNotProjectAncestorPostgres(id, parentId, store);
29586
+ }
29487
29587
  const project = {
29488
- id: randomUUID3(),
29588
+ id,
29489
29589
  name: input.name,
29490
29590
  path: input.path,
29491
29591
  description: input.description ?? null,
29492
29592
  task_list_id: taskListId,
29493
29593
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
29494
29594
  task_counter: 0,
29595
+ parent_id: parentId,
29495
29596
  created_at: timestamp3,
29496
29597
  updated_at: timestamp3,
29497
29598
  machine_id: store.machineId(context),
@@ -29499,12 +29600,38 @@ async function createProject2(input, store, context) {
29499
29600
  };
29500
29601
  return store.upsert("projects", project, context);
29501
29602
  }
29603
+ async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
29604
+ const all = await store.list("projects");
29605
+ const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
29606
+ let cursor = candidateParentId;
29607
+ const seen = new Set;
29608
+ while (cursor !== null) {
29609
+ if (cursor === projectId) {
29610
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
29611
+ }
29612
+ if (seen.has(cursor))
29613
+ break;
29614
+ seen.add(cursor);
29615
+ cursor = byId.get(cursor) ?? null;
29616
+ }
29617
+ }
29502
29618
  async function updateProject2(id, input, store) {
29503
29619
  if ("task_list_id" in input) {
29504
29620
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
29505
29621
  }
29506
29622
  const project = await requireRecord("projects", id, store);
29507
- const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
29623
+ if (input.parent_id !== undefined && input.parent_id !== null) {
29624
+ const parent = await store.get("projects", input.parent_id);
29625
+ if (!parent)
29626
+ throw new ProjectNotFoundError(input.parent_id);
29627
+ await assertNotProjectAncestorPostgres(id, input.parent_id, store);
29628
+ }
29629
+ const updated = {
29630
+ ...project,
29631
+ ...definedPatch(input),
29632
+ ...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
29633
+ updated_at: new Date().toISOString()
29634
+ };
29508
29635
  return store.upsert("projects", updated);
29509
29636
  }
29510
29637
  async function createPlan2(input, store, context) {
@@ -35334,7 +35461,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35334
35461
  }
35335
35462
  async createProject(input) {
35336
35463
  const derivedSlug = normalizeSlug(input.name);
35337
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
35464
+ const taskListId = input.task_list_id === undefined ? derivedSlug : normalizeSlug(input.task_list_id);
35338
35465
  if (!derivedSlug || !taskListId) {
35339
35466
  throw new Error("Project name and task-list slug must be non-empty");
35340
35467
  }
@@ -35346,6 +35473,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35346
35473
  task_list_id: taskListId,
35347
35474
  task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
35348
35475
  task_counter: 0,
35476
+ parent_id: input.parent_id ?? null,
35349
35477
  created_at: now(),
35350
35478
  updated_at: now(),
35351
35479
  machine_id: currentStorageMachineId(this.db)
@@ -35356,14 +35484,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35356
35484
  try {
35357
35485
  const result = this.db.run(`INSERT INTO projects (
35358
35486
  id, name, path, description, task_list_id, task_prefix,
35359
- task_counter, created_at, updated_at, machine_id
35360
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
35487
+ task_counter, parent_id, created_at, updated_at, machine_id
35488
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
35361
35489
  project.id,
35362
35490
  project.name,
35363
35491
  project.path,
35364
35492
  project.description,
35365
35493
  project.task_list_id,
35366
35494
  project.task_prefix,
35495
+ project.parent_id,
35367
35496
  project.created_at,
35368
35497
  project.updated_at,
35369
35498
  project.machine_id ?? null
@@ -35759,7 +35888,10 @@ function taskListSlug(projectSlug) {
35759
35888
  if (!slug || slug !== projectSlug) {
35760
35889
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
35761
35890
  }
35762
- return `todos-${slug}`;
35891
+ return slug;
35892
+ }
35893
+ function legacyTaskListSlug(projectSlug) {
35894
+ return `todos-${normalizeSlug(projectSlug)}`;
35763
35895
  }
35764
35896
  function deterministicTaskPrefix(projectSlug) {
35765
35897
  const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
@@ -36278,6 +36410,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36278
36410
  created_by_operation: false
36279
36411
  };
36280
36412
  }
36413
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === legacyTaskListSlug(request.project_slug)) {
36414
+ return {
36415
+ record: boundExistingProjectRecord(conflict2),
36416
+ created_by_operation: false
36417
+ };
36418
+ }
36281
36419
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
36282
36420
  }
36283
36421
  await this.fault("before_object_write", request);
@@ -36314,6 +36452,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36314
36452
  }
36315
36453
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
36316
36454
  }
36455
+ if (request.bind_existing === true) {
36456
+ const legacy = await transaction.findTaskListConflict(todosProjectId, legacyTaskListSlug(request.project_slug));
36457
+ if (legacy && legacy.project_id === todosProjectId) {
36458
+ return {
36459
+ record: boundExistingTaskListRecord(legacy),
36460
+ created_by_operation: false
36461
+ };
36462
+ }
36463
+ }
36317
36464
  await this.fault("before_object_write", request);
36318
36465
  const taskList = await transaction.createTaskList({
36319
36466
  name: request.project_name,
@@ -43481,54 +43628,49 @@ function createTodosTaskManifestHttpClient(options) {
43481
43628
  import { createHash as createHash16 } from "crypto";
43482
43629
  // src/cli/cloud-router.ts
43483
43630
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
43484
- import { normalizeStorageMode } from "@hasna/contracts/mode";
43485
43631
  init_types();
43486
43632
  init_redaction();
43487
- var TRANSPORT_TOKENS = {
43488
- sqlite: "sqlite",
43489
- http: "http",
43490
- local: "sqlite",
43491
- remote: "http",
43492
- self_hosted: "http",
43493
- cloud: "http",
43494
- hybrid: "http"
43495
- };
43496
43633
  var completionCapabilityCache = new Map;
43497
43634
  var retryCapabilityCache = new Map;
43498
43635
  var taskCreatorCapabilityCache = new Map;
43499
43636
  var gitRefCapabilityCache = new Map;
43500
43637
  var remoteCommandCapabilityCache = new Map;
43501
- function cleanMode(value) {
43502
- const normalized = value?.trim().toLowerCase();
43503
- return normalized || null;
43638
+ var LEGACY_STORAGE_MODE_KEYS = [
43639
+ "HASNA_TODOS_STORAGE_MODE",
43640
+ "HASNA_TODOS_MODE",
43641
+ "TODOS_STORAGE_MODE",
43642
+ "TODOS_MODE"
43643
+ ];
43644
+ function assertNoLegacyStorageMode(env = process.env) {
43645
+ for (const key2 of LEGACY_STORAGE_MODE_KEYS) {
43646
+ if (Object.hasOwn(env, key2) && env[key2] !== undefined) {
43647
+ throw new Error(`REMOTE_STORAGE_MODE_REMOVED: ${key2} was removed. Deployment modes no longer exist: delete the storage-mode variable. ` + `The client uses the on-box SQLite store, or the HTTP API selected by ` + `HASNA_TODOS_API_URL + HASNA_TODOS_API_KEY. ` + `On the server, set HASNA_TODOS_DATABASE_URL to select the postgresql backend, ` + `or leave it unset for sqlite.`);
43648
+ }
43649
+ }
43504
43650
  }
43505
43651
  function resolveTodosCliStorageMode(env = process.env) {
43506
- for (const source9 of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
43507
- if (env[source9] !== undefined && env[source9].trim() === "") {
43508
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source9} must not be blank; local SQLite fallback is disabled for invalid routing state`);
43509
- }
43652
+ assertNoLegacyStorageMode(env);
43653
+ const urlValue = env.HASNA_TODOS_API_URL?.trim();
43654
+ const keyValue = env.HASNA_TODOS_API_KEY?.trim();
43655
+ if (urlValue && keyValue) {
43656
+ return {
43657
+ mode: "http",
43658
+ transport: "http",
43659
+ selected: true,
43660
+ source: "HASNA_TODOS_API_URL+HASNA_TODOS_API_KEY"
43661
+ };
43510
43662
  }
43511
- const canonical = cleanMode(env.HASNA_TODOS_STORAGE_MODE);
43512
- const fallback = cleanMode(env.TODOS_STORAGE_MODE);
43513
- for (const [source9, value] of [
43514
- ["HASNA_TODOS_STORAGE_MODE", canonical],
43515
- ["TODOS_STORAGE_MODE", fallback]
43516
- ]) {
43517
- if (value && !(value in TRANSPORT_TOKENS)) {
43518
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source9}=${value} must be sqlite (local file) or http (hosted /v1 authority); ` + "legacy values local and remote are accepted; " + "local SQLite fallback is disabled for invalid routing state");
43519
- }
43663
+ if (urlValue) {
43664
+ throw new Error("REMOTE_API_KEY_MISSING: remote Todos storage requires HASNA_TODOS_API_KEY; local SQLite fallback is disabled");
43520
43665
  }
43521
- const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
43522
- const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
43523
- if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
43524
- throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
43666
+ if (keyValue) {
43667
+ throw new Error("REMOTE_API_URL_MISSING: remote Todos storage requires HASNA_TODOS_API_URL; local SQLite fallback is disabled");
43525
43668
  }
43526
- const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
43527
43669
  return {
43528
- mode: transport,
43529
- transport,
43530
- selected: transport === "http",
43531
- source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
43670
+ mode: "sqlite",
43671
+ transport: "sqlite",
43672
+ selected: false,
43673
+ source: "default"
43532
43674
  };
43533
43675
  }
43534
43676
  function requestedTransport(env) {
@@ -43572,7 +43714,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
43572
43714
  return {
43573
43715
  selected: true,
43574
43716
  ok: false,
43575
- mode: cleanMode(env.HASNA_TODOS_STORAGE_MODE) ?? cleanMode(env.TODOS_STORAGE_MODE) ?? "invalid",
43717
+ mode: "invalid",
43576
43718
  api_url_configured: Boolean(env.HASNA_TODOS_API_URL?.trim()),
43577
43719
  api_key_configured: Boolean(env.HASNA_TODOS_API_KEY?.trim()),
43578
43720
  v1_base_url: null,
@@ -43618,29 +43760,12 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
43618
43760
  local_fallback: false
43619
43761
  };
43620
43762
  }
43621
- var SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
43622
- var cachedServerMode = null;
43623
- function serverStorageMode(normalize2 = normalizeStorageMode) {
43624
- const useCache = normalize2 === normalizeStorageMode;
43625
- if (useCache && cachedServerMode !== null)
43626
- return cachedServerMode;
43627
- for (const candidate of SERVER_MODE_CANDIDATES) {
43628
- try {
43629
- normalize2(candidate);
43630
- if (useCache)
43631
- cachedServerMode = candidate;
43632
- return candidate;
43633
- } catch {}
43634
- }
43635
- throw new Error(`REMOTE_STORAGE_MODE_UNSUPPORTED: no known server storage mode is accepted by the installed ` + `@hasna/contracts (tried ${SERVER_MODE_CANDIDATES.join(", ")}); the storage-mode enum has changed. ` + `Add the new server token to SERVER_MODE_CANDIDATES in src/cli/cloud-router.ts; ` + `local SQLite fallback is disabled.`);
43636
- }
43637
43763
  function requireTodosRemoteAuthorityEnv(env) {
43638
43764
  const status2 = getTodosRemoteAuthorityConfigStatus(env);
43639
43765
  if (!status2.ok)
43640
43766
  throw new Error(status2.issues[0]);
43641
43767
  return {
43642
43768
  ...env,
43643
- HASNA_TODOS_STORAGE_MODE: serverStorageMode(),
43644
43769
  HASNA_TODOS_API_URL: status2.v1_base_url.replace(/\/v1$/, ""),
43645
43770
  HASNA_TODOS_API_KEY: env.HASNA_TODOS_API_KEY.trim()
43646
43771
  };
@@ -43678,7 +43803,32 @@ function classifyRemoteRequestError(baseUrl, route, error) {
43678
43803
  }
43679
43804
  throw error;
43680
43805
  }
43681
- function protectRemoteClient(client) {
43806
+ var REMOTE_REQUEST_TIMEOUT_MS = 1e4;
43807
+ function withBoundedRemoteRequest(options, requestTimeoutMs, run) {
43808
+ const controller = new AbortController;
43809
+ const onBaseAbort = () => controller.abort();
43810
+ if (options?.signal) {
43811
+ if (options.signal.aborted)
43812
+ controller.abort();
43813
+ else
43814
+ options.signal.addEventListener("abort", onBaseAbort, { once: true });
43815
+ }
43816
+ let timer;
43817
+ const deadline = new Promise((_, reject) => {
43818
+ timer = setTimeout(() => {
43819
+ controller.abort();
43820
+ reject(new DOMException(`Todos remote request exceeded the ${requestTimeoutMs}ms bounded request timeout`, "AbortError"));
43821
+ }, requestTimeoutMs);
43822
+ });
43823
+ const attempt = run({ ...options, signal: controller.signal });
43824
+ attempt.catch(() => {});
43825
+ return Promise.race([attempt, deadline]).finally(() => {
43826
+ if (timer)
43827
+ clearTimeout(timer);
43828
+ options?.signal?.removeEventListener("abort", onBaseAbort);
43829
+ });
43830
+ }
43831
+ function protectRemoteClient(client, requestTimeoutMs) {
43682
43832
  const baseUrl = remoteAuthorityBase(client);
43683
43833
  const protect = async (route, request) => {
43684
43834
  try {
@@ -43687,25 +43837,26 @@ function protectRemoteClient(client) {
43687
43837
  return classifyRemoteRequestError(baseUrl, route, error);
43688
43838
  }
43689
43839
  };
43840
+ const bounded = (options, run) => withBoundedRemoteRequest(options, requestTimeoutMs, run);
43690
43841
  const transport = client.transport;
43691
43842
  const protectedTransport = {
43692
43843
  baseUrl: transport.baseUrl,
43693
- request: (method, path, body2, options) => protect(path, () => transport.request(method, path, body2, options)),
43694
- get: (path, options) => protect(path, () => transport.get(path, options)),
43695
- post: (path, body2, options) => protect(path, () => transport.post(path, body2, options)),
43696
- put: (path, body2, options) => protect(path, () => transport.put(path, body2, options)),
43697
- patch: (path, body2, options) => protect(path, () => transport.patch(path, body2, options)),
43698
- del: (path, body2, options) => protect(path, () => transport.del(path, body2, options))
43844
+ request: (method, path, body2, options) => protect(path, () => bounded(options, (opts) => transport.request(method, path, body2, opts))),
43845
+ get: (path, options) => protect(path, () => bounded(options, (opts) => transport.get(path, opts))),
43846
+ post: (path, body2, options) => protect(path, () => bounded(options, (opts) => transport.post(path, body2, opts))),
43847
+ put: (path, body2, options) => protect(path, () => bounded(options, (opts) => transport.put(path, body2, opts))),
43848
+ patch: (path, body2, options) => protect(path, () => bounded(options, (opts) => transport.patch(path, body2, opts))),
43849
+ del: (path, body2, options) => protect(path, () => bounded(options, (opts) => transport.del(path, body2, opts)))
43699
43850
  };
43700
43851
  return {
43701
43852
  name: client.name,
43702
43853
  baseUrl: client.baseUrl,
43703
43854
  transport: protectedTransport,
43704
- list: (resource, options) => protect(`/${resource}`, () => client.list(resource, options)),
43705
- get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.get(resource, id, options)),
43706
- create: (resource, body2, options) => protect(`/${resource}`, () => client.create(resource, body2, options)),
43707
- update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.update(resource, id, patch, options)),
43708
- delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.delete(resource, id, options))
43855
+ list: (resource, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.list(resource, opts))),
43856
+ get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.get(resource, id, opts))),
43857
+ create: (resource, body2, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.create(resource, body2, opts))),
43858
+ update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.update(resource, id, patch, opts))),
43859
+ delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.delete(resource, id, opts)))
43709
43860
  };
43710
43861
  }
43711
43862
  function remoteAuthorityBase(client) {
@@ -43726,13 +43877,19 @@ async function requiredRemoteRoute(client, route, request, recognized404Codes =
43726
43877
  throw error;
43727
43878
  }
43728
43879
  }
43729
- function getTodosCloudClient(env = process.env) {
43880
+ function getTodosCloudClient(env = process.env, requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS) {
43730
43881
  if (requestedTransport(env) !== "http")
43731
43882
  return null;
43732
43883
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
43733
- fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
43884
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
43885
+ timeoutMs: requestTimeoutMs
43734
43886
  });
43735
- return resolved.transport === "cloud-http" ? protectRemoteClient(resolved.client) : null;
43887
+ if (resolved.transport === "cloud-http")
43888
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
43889
+ const transportName = resolved.transport;
43890
+ if (transportName === "http")
43891
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
43892
+ return null;
43736
43893
  }
43737
43894
  function unwrapTask(raw) {
43738
43895
  if (raw && typeof raw === "object" && "task" in raw) {
@@ -45527,7 +45684,7 @@ function getNativeStorageStatus(env = process.env) {
45527
45684
  const remoteEnabled = isTodosPostgresBackend(config);
45528
45685
  const remoteFieldsConfigured = Boolean(config.database || config.objectStorage);
45529
45686
  if (!remoteEnabled && remoteFieldsConfigured) {
45530
- warnings.push(`the sqlite backend (${TODOS_STORAGE_ENV.mode}) ignores configured remote storage fields`);
45687
+ warnings.push(`the sqlite backend ignores configured remote storage fields`);
45531
45688
  }
45532
45689
  if (remoteEnabled && !config.objectStorage) {
45533
45690
  warnings.push(`${TODOS_STORAGE_ENV.s3Bucket} is not configured, so artifact sync will stay local`);
@@ -45620,7 +45777,7 @@ function storageEnvStatus(env) {
45620
45777
  function fallbackConfig(env) {
45621
45778
  let mode;
45622
45779
  try {
45623
- mode = parseStorageBackend(clean2(env[TODOS_STORAGE_ENV.mode]));
45780
+ mode = getTodosStorageBackend(env);
45624
45781
  } catch {
45625
45782
  mode = "sqlite";
45626
45783
  }
@@ -47804,7 +47961,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
47804
47961
  function bootstrapProject(options = {}, db) {
47805
47962
  const d = db || getDatabase();
47806
47963
  const discovery = discoverProjectWorkspace(options.path);
47807
- const taskListSlug2 = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
47964
+ const taskListSlug2 = options.taskListSlug || slugify(options.name || discovery.projectName);
47808
47965
  if (options.dryRun) {
47809
47966
  return {
47810
47967
  dryRun: true,
@@ -55222,21 +55379,22 @@ function upsertProject(raw, d) {
55222
55379
  const existing = getProject(id, d);
55223
55380
  const ts = now();
55224
55381
  if (!existing) {
55225
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at)
55226
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
55382
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at)
55383
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
55227
55384
  id,
55228
55385
  raw.name,
55229
55386
  raw.path,
55230
55387
  raw.description ?? null,
55231
- raw.task_list_id ?? `todos-${String(raw.name).toLowerCase().replace(/\s+/g, "-")}`,
55388
+ raw.task_list_id ?? String(raw.name).toLowerCase().replace(/\s+/g, "-"),
55232
55389
  raw.task_prefix ?? "TSK",
55233
55390
  raw.task_counter ?? 0,
55391
+ raw.parent_id ?? null,
55234
55392
  raw.created_at ?? ts,
55235
55393
  raw.updated_at ?? ts
55236
55394
  ].map(sqlValue));
55237
55395
  return "created";
55238
55396
  }
55239
- d.run(`UPDATE projects SET name = ?, path = ?, description = ?, task_list_id = ?, task_prefix = ?, task_counter = ?, updated_at = ?
55397
+ d.run(`UPDATE projects SET name = ?, path = ?, description = ?, task_list_id = ?, task_prefix = ?, task_counter = ?, parent_id = ?, updated_at = ?
55240
55398
  WHERE id = ?`, [
55241
55399
  raw.name ?? existing.name,
55242
55400
  raw.path ?? existing.path,
@@ -55244,6 +55402,7 @@ function upsertProject(raw, d) {
55244
55402
  raw.task_list_id ?? existing.task_list_id,
55245
55403
  raw.task_prefix ?? existing.task_prefix,
55246
55404
  raw.task_counter ?? existing.task_counter,
55405
+ raw.parent_id ?? existing.parent_id,
55247
55406
  raw.updated_at ?? ts,
55248
55407
  id
55249
55408
  ].map(sqlValue));
@@ -55324,7 +55483,7 @@ function importBundle(bundle, options = {}, db) {
55324
55483
  };
55325
55484
  if (options.dry_run)
55326
55485
  return result;
55327
- for (const raw of bundle.projects) {
55486
+ for (const raw of orderProjectsParentFirst(bundle.projects)) {
55328
55487
  try {
55329
55488
  const id = raw.id;
55330
55489
  const local = getProject(id, d);