@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/mcp/index.js CHANGED
@@ -2096,6 +2096,10 @@ var init_migrations = __esm(() => {
2096
2096
  );
2097
2097
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
2098
2098
  COMMIT;
2099
+ `,
2100
+ `BEGIN;
2101
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
2102
+ COMMIT;
2099
2103
  `
2100
2104
  ];
2101
2105
  });
@@ -2769,6 +2773,8 @@ function ensureSchema(db) {
2769
2773
  ensureColumn("projects", "task_list_id", "TEXT");
2770
2774
  ensureColumn("projects", "task_prefix", "TEXT");
2771
2775
  ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
2776
+ ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
2777
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
2772
2778
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
2773
2779
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
2774
2780
  ensureColumn("tasks", "short_id", "TEXT");
@@ -4129,6 +4135,7 @@ __export(exports_config, {
4129
4135
  TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
4130
4136
  TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
4131
4137
  STORAGE_TABLES: () => STORAGE_TABLES,
4138
+ REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
4132
4139
  CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
4133
4140
  CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
4134
4141
  CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
@@ -4190,25 +4197,36 @@ function assertTodosRemoteStorageConfig(config) {
4190
4197
  if (!isTodosPostgresBackend(config))
4191
4198
  return;
4192
4199
  if (!config.database?.url) {
4193
- throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
4200
+ throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when the postgresql backend is selected`);
4194
4201
  }
4195
4202
  }
4196
4203
  function parseStorageBackend(value) {
4197
4204
  const normalized = clean(value)?.toLowerCase();
4198
4205
  if (!normalized)
4199
4206
  return "sqlite";
4200
- if (normalized === "sqlite" || normalized === "postgres")
4201
- return normalized;
4202
- const legacy = LEGACY_BACKEND_TOKENS[normalized];
4203
- if (legacy)
4204
- return legacy;
4205
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
4207
+ if (normalized === "sqlite")
4208
+ return "sqlite";
4209
+ if (normalized === "postgres" || normalized === "postgresql")
4210
+ return "postgres";
4211
+ if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
4212
+ 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.`);
4213
+ }
4214
+ throw new Error(`Storage backend must be sqlite or postgres`);
4206
4215
  }
4207
4216
  function parseStorageMode(value) {
4208
4217
  return parseStorageBackend(value);
4209
4218
  }
4210
4219
  function getTodosStorageBackend(env = process.env) {
4211
- return parseStorageBackend(readStorageEnv(env, "mode").value);
4220
+ for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
4221
+ if (Object.hasOwn(env, key) && env[key] !== undefined) {
4222
+ 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.`);
4223
+ }
4224
+ }
4225
+ if (!getTodosStorageDatabaseUrl(env))
4226
+ return "sqlite";
4227
+ if (isTodosShadowEnabled(env))
4228
+ return "sqlite";
4229
+ return "postgres";
4212
4230
  }
4213
4231
  function getTodosStorageMode(env = process.env) {
4214
4232
  return getTodosStorageBackend(env);
@@ -4282,7 +4300,7 @@ function parsePositiveInteger(value, fallback) {
4282
4300
  }
4283
4301
  return parsed;
4284
4302
  }
4285
- 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;
4303
+ 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";
4286
4304
  var init_config = __esm(() => {
4287
4305
  TODOS_STORAGE_TABLES = [
4288
4306
  "todos_sync_records",
@@ -4290,7 +4308,6 @@ var init_config = __esm(() => {
4290
4308
  ];
4291
4309
  STORAGE_TABLES = TODOS_STORAGE_TABLES;
4292
4310
  TODOS_STORAGE_ENV = {
4293
- mode: "HASNA_TODOS_STORAGE_MODE",
4294
4311
  shadow: "HASNA_TODOS_SHADOW",
4295
4312
  databaseUrl: "HASNA_TODOS_DATABASE_URL",
4296
4313
  databaseSsl: "HASNA_TODOS_DATABASE_SSL",
@@ -4307,7 +4324,6 @@ var init_config = __esm(() => {
4307
4324
  syncDryRun: "HASNA_TODOS_SYNC_DRY_RUN"
4308
4325
  };
4309
4326
  TODOS_STORAGE_FALLBACK_ENV = {
4310
- mode: "TODOS_STORAGE_MODE",
4311
4327
  shadow: "TODOS_SHADOW",
4312
4328
  databaseUrl: "TODOS_DATABASE_URL",
4313
4329
  databaseSsl: "TODOS_DATABASE_SSL",
@@ -4323,14 +4339,12 @@ var init_config = __esm(() => {
4323
4339
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
4324
4340
  syncDryRun: "TODOS_SYNC_DRY_RUN"
4325
4341
  };
4326
- LEGACY_BACKEND_TOKENS = {
4327
- local: "sqlite",
4328
- remote: "postgres",
4329
- postgresql: "postgres",
4330
- hybrid: "postgres",
4331
- self_hosted: "postgres",
4332
- cloud: "postgres"
4333
- };
4342
+ REMOVED_STORAGE_MODE_ENV_KEYS = [
4343
+ "HASNA_TODOS_STORAGE_MODE",
4344
+ "HASNA_TODOS_MODE",
4345
+ "TODOS_STORAGE_MODE",
4346
+ "TODOS_MODE"
4347
+ ];
4334
4348
  });
4335
4349
 
4336
4350
  // src/storage/shadow-outbox-schema.ts
@@ -9677,8 +9691,8 @@ var init_secret_redaction = __esm(() => {
9677
9691
  SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
9678
9692
  DEFAULT_PATTERNS = [
9679
9693
  { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
9680
- { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
9681
- { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
9694
+ { name: "github_pat", pattern: /\bgh[p]_[a-zA-Z0-9]{20,}\b/g },
9695
+ { name: "github_oauth", pattern: /\bgh[o]_[a-zA-Z0-9]{20,}\b/g },
9682
9696
  { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
9683
9697
  { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
9684
9698
  { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
@@ -9690,7 +9704,7 @@ var init_secret_redaction = __esm(() => {
9690
9704
  /example\.com/i,
9691
9705
  /your-api-key-here/i,
9692
9706
  /sk-test/i,
9693
- /ghp_xxx/i
9707
+ /gh[p]_xxx/i
9694
9708
  ];
9695
9709
  customRedactors = [];
9696
9710
  });
@@ -9997,10 +10011,12 @@ __export(exports_projects, {
9997
10011
  renameProject: () => renameProject,
9998
10012
  removeProjectSource: () => removeProjectSource,
9999
10013
  removeMachineLocalPath: () => removeMachineLocalPath,
10014
+ orderProjectsParentFirst: () => orderProjectsParentFirst,
10000
10015
  nextTaskShortId: () => nextTaskShortId,
10001
10016
  listProjects: () => listProjects,
10002
10017
  listProjectSources: () => listProjectSources,
10003
10018
  listMachineLocalPaths: () => listMachineLocalPaths,
10019
+ listChildProjects: () => listChildProjects,
10004
10020
  getProjectWithSources: () => getProjectWithSources,
10005
10021
  getProjectByPath: () => getProjectByPath,
10006
10022
  getProject: () => getProject,
@@ -10008,6 +10024,7 @@ __export(exports_projects, {
10008
10024
  ensureProject: () => ensureProject,
10009
10025
  deleteProject: () => deleteProject,
10010
10026
  createProject: () => createProject,
10027
+ assertNotProjectAncestor: () => assertNotProjectAncestor,
10011
10028
  addProjectSource: () => addProjectSource
10012
10029
  });
10013
10030
  function slugify(name) {
@@ -10039,17 +10056,24 @@ function createProject(input, db) {
10039
10056
  const id = uuid();
10040
10057
  const timestamp2 = now();
10041
10058
  const derivedSlug = slugify(input.name);
10042
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
10059
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
10043
10060
  if (!derivedSlug || !taskListId)
10044
10061
  throw new Error("Project name and task-list slug must be non-empty");
10045
10062
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
10046
10063
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
10047
10064
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
10048
10065
  }
10066
+ const parentId = input.parent_id ?? null;
10067
+ if (parentId !== null) {
10068
+ const parent = getProject(parentId, d);
10069
+ if (!parent)
10070
+ throw new ProjectNotFoundError(parentId);
10071
+ assertNotProjectAncestor(id, parentId, d);
10072
+ }
10049
10073
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
10050
10074
  const machineId = currentStorageMachineId(d);
10051
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
10052
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
10075
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
10076
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
10053
10077
  return getProject(id, d);
10054
10078
  })();
10055
10079
  }
@@ -10074,6 +10098,64 @@ function listProjects(db) {
10074
10098
  const d = db || getDatabase();
10075
10099
  return d.query("SELECT * FROM projects ORDER BY name").all();
10076
10100
  }
10101
+ function listChildProjects(parentId, db) {
10102
+ const d = db || getDatabase();
10103
+ return d.query("SELECT * FROM projects WHERE parent_id = ? ORDER BY name").all(parentId);
10104
+ }
10105
+ function orderProjectsParentFirst(projects) {
10106
+ const projectId = (project) => {
10107
+ const id = project.id;
10108
+ return typeof id === "string" && id.length > 0 ? id : null;
10109
+ };
10110
+ const parentId = (project) => {
10111
+ const parent = project.parent_id;
10112
+ return parent == null ? null : String(parent);
10113
+ };
10114
+ const byId = new Set;
10115
+ for (const project of projects) {
10116
+ const id = projectId(project);
10117
+ if (id !== null)
10118
+ byId.add(id);
10119
+ }
10120
+ const ordered = [];
10121
+ const emitted = new Set;
10122
+ let remaining = [...projects];
10123
+ let progress = true;
10124
+ while (progress && remaining.length > 0) {
10125
+ progress = false;
10126
+ const deferred = [];
10127
+ for (const project of remaining) {
10128
+ const parent = parentId(project);
10129
+ if (parent === null || emitted.has(parent) || !byId.has(parent)) {
10130
+ ordered.push(project);
10131
+ const id = projectId(project);
10132
+ if (id !== null)
10133
+ emitted.add(id);
10134
+ progress = true;
10135
+ } else {
10136
+ deferred.push(project);
10137
+ }
10138
+ }
10139
+ remaining = deferred;
10140
+ }
10141
+ ordered.push(...remaining);
10142
+ return ordered;
10143
+ }
10144
+ function assertNotProjectAncestor(projectId, ancestorId, db) {
10145
+ const d = db || getDatabase();
10146
+ let cursor = ancestorId;
10147
+ const seen = new Set;
10148
+ while (cursor !== null) {
10149
+ if (cursor === projectId) {
10150
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
10151
+ }
10152
+ if (seen.has(cursor))
10153
+ break;
10154
+ seen.add(cursor);
10155
+ const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
10156
+ cursor = row?.parent_id ?? null;
10157
+ }
10158
+ }
10077
10159
  function updateProject(id, input, db) {
10078
10160
  const d = db || getDatabase();
10079
10161
  const project = getProject(id, d);
@@ -10096,6 +10178,16 @@ function updateProject(id, input, db) {
10096
10178
  sets.push("path = ?");
10097
10179
  params.push(input.path);
10098
10180
  }
10181
+ if (input.parent_id !== undefined) {
10182
+ if (input.parent_id !== null) {
10183
+ const parent = getProject(input.parent_id, d);
10184
+ if (!parent)
10185
+ throw new ProjectNotFoundError(input.parent_id);
10186
+ assertNotProjectAncestor(id, input.parent_id, d);
10187
+ }
10188
+ sets.push("parent_id = ?");
10189
+ params.push(input.parent_id);
10190
+ }
10099
10191
  params.push(id);
10100
10192
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
10101
10193
  return getProject(id, d);
@@ -21093,40 +21185,38 @@ var init_page_validation = __esm(() => {
21093
21185
 
21094
21186
  // src/cli/cloud-router.ts
21095
21187
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
21096
- import { normalizeStorageMode } from "@hasna/contracts/mode";
21097
21188
  import { randomUUID as randomUUID3 } from "crypto";
21098
21189
  import { resolve as resolvePath } from "path";
21099
- function cleanMode(value) {
21100
- const normalized = value?.trim().toLowerCase();
21101
- return normalized || null;
21190
+ function assertNoLegacyStorageMode(env = process.env) {
21191
+ for (const key of LEGACY_STORAGE_MODE_KEYS) {
21192
+ if (Object.hasOwn(env, key) && env[key] !== undefined) {
21193
+ throw new Error(`REMOTE_STORAGE_MODE_REMOVED: ${key} 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.`);
21194
+ }
21195
+ }
21102
21196
  }
21103
21197
  function resolveTodosCliStorageMode(env = process.env) {
21104
- for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
21105
- if (env[source] !== undefined && env[source].trim() === "") {
21106
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source} must not be blank; local SQLite fallback is disabled for invalid routing state`);
21107
- }
21198
+ assertNoLegacyStorageMode(env);
21199
+ const urlValue = env.HASNA_TODOS_API_URL?.trim();
21200
+ const keyValue = env.HASNA_TODOS_API_KEY?.trim();
21201
+ if (urlValue && keyValue) {
21202
+ return {
21203
+ mode: "http",
21204
+ transport: "http",
21205
+ selected: true,
21206
+ source: "HASNA_TODOS_API_URL+HASNA_TODOS_API_KEY"
21207
+ };
21108
21208
  }
21109
- const canonical = cleanMode(env.HASNA_TODOS_STORAGE_MODE);
21110
- const fallback = cleanMode(env.TODOS_STORAGE_MODE);
21111
- for (const [source, value] of [
21112
- ["HASNA_TODOS_STORAGE_MODE", canonical],
21113
- ["TODOS_STORAGE_MODE", fallback]
21114
- ]) {
21115
- if (value && !(value in TRANSPORT_TOKENS)) {
21116
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source}=${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");
21117
- }
21209
+ if (urlValue) {
21210
+ throw new Error("REMOTE_API_KEY_MISSING: remote Todos storage requires HASNA_TODOS_API_KEY; local SQLite fallback is disabled");
21118
21211
  }
21119
- const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
21120
- const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
21121
- if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
21122
- throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
21212
+ if (keyValue) {
21213
+ throw new Error("REMOTE_API_URL_MISSING: remote Todos storage requires HASNA_TODOS_API_URL; local SQLite fallback is disabled");
21123
21214
  }
21124
- const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
21125
21215
  return {
21126
- mode: transport,
21127
- transport,
21128
- selected: transport === "http",
21129
- source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
21216
+ mode: "sqlite",
21217
+ transport: "sqlite",
21218
+ selected: false,
21219
+ source: "default"
21130
21220
  };
21131
21221
  }
21132
21222
  function requestedTransport(env) {
@@ -21170,7 +21260,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
21170
21260
  return {
21171
21261
  selected: true,
21172
21262
  ok: false,
21173
- mode: cleanMode(env.HASNA_TODOS_STORAGE_MODE) ?? cleanMode(env.TODOS_STORAGE_MODE) ?? "invalid",
21263
+ mode: "invalid",
21174
21264
  api_url_configured: Boolean(env.HASNA_TODOS_API_URL?.trim()),
21175
21265
  api_key_configured: Boolean(env.HASNA_TODOS_API_KEY?.trim()),
21176
21266
  v1_base_url: null,
@@ -21216,27 +21306,12 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
21216
21306
  local_fallback: false
21217
21307
  };
21218
21308
  }
21219
- function serverStorageMode(normalize = normalizeStorageMode) {
21220
- const useCache = normalize === normalizeStorageMode;
21221
- if (useCache && cachedServerMode !== null)
21222
- return cachedServerMode;
21223
- for (const candidate of SERVER_MODE_CANDIDATES) {
21224
- try {
21225
- normalize(candidate);
21226
- if (useCache)
21227
- cachedServerMode = candidate;
21228
- return candidate;
21229
- } catch {}
21230
- }
21231
- 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.`);
21232
- }
21233
21309
  function requireTodosRemoteAuthorityEnv(env) {
21234
21310
  const status = getTodosRemoteAuthorityConfigStatus(env);
21235
21311
  if (!status.ok)
21236
21312
  throw new Error(status.issues[0]);
21237
21313
  return {
21238
21314
  ...env,
21239
- HASNA_TODOS_STORAGE_MODE: serverStorageMode(),
21240
21315
  HASNA_TODOS_API_URL: status.v1_base_url.replace(/\/v1$/, ""),
21241
21316
  HASNA_TODOS_API_KEY: env.HASNA_TODOS_API_KEY.trim()
21242
21317
  };
@@ -21274,7 +21349,31 @@ function classifyRemoteRequestError(baseUrl, route, error) {
21274
21349
  }
21275
21350
  throw error;
21276
21351
  }
21277
- function protectRemoteClient(client) {
21352
+ function withBoundedRemoteRequest(options, requestTimeoutMs, run) {
21353
+ const controller = new AbortController;
21354
+ const onBaseAbort = () => controller.abort();
21355
+ if (options?.signal) {
21356
+ if (options.signal.aborted)
21357
+ controller.abort();
21358
+ else
21359
+ options.signal.addEventListener("abort", onBaseAbort, { once: true });
21360
+ }
21361
+ let timer;
21362
+ const deadline = new Promise((_, reject) => {
21363
+ timer = setTimeout(() => {
21364
+ controller.abort();
21365
+ reject(new DOMException(`Todos remote request exceeded the ${requestTimeoutMs}ms bounded request timeout`, "AbortError"));
21366
+ }, requestTimeoutMs);
21367
+ });
21368
+ const attempt = run({ ...options, signal: controller.signal });
21369
+ attempt.catch(() => {});
21370
+ return Promise.race([attempt, deadline]).finally(() => {
21371
+ if (timer)
21372
+ clearTimeout(timer);
21373
+ options?.signal?.removeEventListener("abort", onBaseAbort);
21374
+ });
21375
+ }
21376
+ function protectRemoteClient(client, requestTimeoutMs) {
21278
21377
  const baseUrl = remoteAuthorityBase(client);
21279
21378
  const protect = async (route, request) => {
21280
21379
  try {
@@ -21283,25 +21382,26 @@ function protectRemoteClient(client) {
21283
21382
  return classifyRemoteRequestError(baseUrl, route, error);
21284
21383
  }
21285
21384
  };
21385
+ const bounded = (options, run) => withBoundedRemoteRequest(options, requestTimeoutMs, run);
21286
21386
  const transport = client.transport;
21287
21387
  const protectedTransport = {
21288
21388
  baseUrl: transport.baseUrl,
21289
- request: (method, path, body, options) => protect(path, () => transport.request(method, path, body, options)),
21290
- get: (path, options) => protect(path, () => transport.get(path, options)),
21291
- post: (path, body, options) => protect(path, () => transport.post(path, body, options)),
21292
- put: (path, body, options) => protect(path, () => transport.put(path, body, options)),
21293
- patch: (path, body, options) => protect(path, () => transport.patch(path, body, options)),
21294
- del: (path, body, options) => protect(path, () => transport.del(path, body, options))
21389
+ request: (method, path, body, options) => protect(path, () => bounded(options, (opts) => transport.request(method, path, body, opts))),
21390
+ get: (path, options) => protect(path, () => bounded(options, (opts) => transport.get(path, opts))),
21391
+ post: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.post(path, body, opts))),
21392
+ put: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.put(path, body, opts))),
21393
+ patch: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.patch(path, body, opts))),
21394
+ del: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.del(path, body, opts)))
21295
21395
  };
21296
21396
  return {
21297
21397
  name: client.name,
21298
21398
  baseUrl: client.baseUrl,
21299
21399
  transport: protectedTransport,
21300
- list: (resource, options) => protect(`/${resource}`, () => client.list(resource, options)),
21301
- get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.get(resource, id, options)),
21302
- create: (resource, body, options) => protect(`/${resource}`, () => client.create(resource, body, options)),
21303
- update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.update(resource, id, patch, options)),
21304
- delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.delete(resource, id, options))
21400
+ list: (resource, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.list(resource, opts))),
21401
+ get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.get(resource, id, opts))),
21402
+ create: (resource, body, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.create(resource, body, opts))),
21403
+ update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.update(resource, id, patch, opts))),
21404
+ delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.delete(resource, id, opts)))
21305
21405
  };
21306
21406
  }
21307
21407
  function remoteAuthorityBase(client) {
@@ -21322,13 +21422,19 @@ async function requiredRemoteRoute(client, route, request, recognized404Codes =
21322
21422
  throw error;
21323
21423
  }
21324
21424
  }
21325
- function getTodosCloudClient(env = process.env) {
21425
+ function getTodosCloudClient(env = process.env, requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS) {
21326
21426
  if (requestedTransport(env) !== "http")
21327
21427
  return null;
21328
21428
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
21329
- fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
21429
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
21430
+ timeoutMs: requestTimeoutMs
21330
21431
  });
21331
- return resolved.transport === "cloud-http" ? protectRemoteClient(resolved.client) : null;
21432
+ if (resolved.transport === "cloud-http")
21433
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
21434
+ const transportName = resolved.transport;
21435
+ if (transportName === "http")
21436
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
21437
+ return null;
21332
21438
  }
21333
21439
  function unwrapTask(raw) {
21334
21440
  if (raw && typeof raw === "object" && "task" in raw) {
@@ -21799,7 +21905,7 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
21799
21905
  return input.toLowerCase();
21800
21906
  return (await cloudResolveTaskList(client, ref, projectId)).id;
21801
21907
  }
21802
- var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, taskCreatorCapabilityCache, gitRefCapabilityCache, remoteCommandCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21908
+ var UUID_RE, completionCapabilityCache, retryCapabilityCache, taskCreatorCapabilityCache, gitRefCapabilityCache, remoteCommandCapabilityCache, LEGACY_STORAGE_MODE_KEYS, REMOTE_REQUEST_TIMEOUT_MS = 1e4, PRIORITY_RANK, listTagsCapabilityCache;
21803
21909
  var init_cloud_router = __esm(() => {
21804
21910
  init_types();
21805
21911
  init_redaction();
@@ -21808,21 +21914,17 @@ var init_cloud_router = __esm(() => {
21808
21914
  init_adoption_validation();
21809
21915
  init_page_validation();
21810
21916
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21811
- TRANSPORT_TOKENS = {
21812
- sqlite: "sqlite",
21813
- http: "http",
21814
- local: "sqlite",
21815
- remote: "http",
21816
- self_hosted: "http",
21817
- cloud: "http",
21818
- hybrid: "http"
21819
- };
21820
21917
  completionCapabilityCache = new Map;
21821
21918
  retryCapabilityCache = new Map;
21822
21919
  taskCreatorCapabilityCache = new Map;
21823
21920
  gitRefCapabilityCache = new Map;
21824
21921
  remoteCommandCapabilityCache = new Map;
21825
- SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
21922
+ LEGACY_STORAGE_MODE_KEYS = [
21923
+ "HASNA_TODOS_STORAGE_MODE",
21924
+ "HASNA_TODOS_MODE",
21925
+ "TODOS_STORAGE_MODE",
21926
+ "TODOS_MODE"
21927
+ ];
21826
21928
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
21827
21929
  listTagsCapabilityCache = new Map;
21828
21930
  });
@@ -22330,7 +22432,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
22330
22432
  function bootstrapProject(options = {}, db) {
22331
22433
  const d = db || getDatabase();
22332
22434
  const discovery = discoverProjectWorkspace(options.path);
22333
- const taskListSlug = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
22435
+ const taskListSlug = options.taskListSlug || slugify(options.name || discovery.projectName);
22334
22436
  if (options.dryRun) {
22335
22437
  return {
22336
22438
  dryRun: true,
@@ -28457,6 +28559,7 @@ function registerTaskProjectTools(server, ctx) {
28457
28559
  name: exports_external.string().describe("Project name"),
28458
28560
  path: exports_external.string().describe("Unique filesystem path for the project"),
28459
28561
  description: exports_external.string().optional(),
28562
+ parent_id: exports_external.string().optional().describe("Optional parent project id to create this as a sub-project"),
28460
28563
  status: exports_external.enum(["active", "completed", "on_hold", "archived"]).optional(),
28461
28564
  short_id: exports_external.string().nullable().optional().describe("Short ID (auto-generated if omitted)"),
28462
28565
  metadata: exports_external.record(exports_external.unknown()).optional()
@@ -35939,7 +36042,7 @@ var package_default;
35939
36042
  var init_package = __esm(() => {
35940
36043
  package_default = {
35941
36044
  name: "@hasna/todos",
35942
- version: "0.15.33",
36045
+ version: "0.15.35",
35943
36046
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35944
36047
  type: "module",
35945
36048
  main: "dist/index.js",
@@ -36013,9 +36116,10 @@ var init_package = __esm(() => {
36013
36116
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
36014
36117
  "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
36015
36118
  "verify:attested-container-candidate": "bun run scripts/attested-container-candidate.ts verify",
36016
- "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts scripts/attested-container-compatibility-vector.test.ts",
36119
+ "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
36017
36120
  "emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
36018
36121
  "verify:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts verify",
36122
+ "test:attested-container-compatibility-vector": "bun test scripts/attested-container-compatibility-vector.test.ts",
36019
36123
  "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
36020
36124
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
36021
36125
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
@@ -37055,6 +37159,7 @@ function createAgentProjectDemoBundle() {
37055
37159
  task_list_id: ids.list,
37056
37160
  task_prefix: "DEMO",
37057
37161
  task_counter: 4,
37162
+ parent_id: null,
37058
37163
  created_at: createdAt,
37059
37164
  updated_at: completedAt
37060
37165
  });
@@ -45621,7 +45726,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45621
45726
  }
45622
45727
  }
45623
45728
  };
45624
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
45729
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
45625
45730
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
45626
45731
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
45627
45732
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -45852,6 +45957,7 @@ var init_sqlite_snapshot = __esm(() => {
45852
45957
  "task_list_id",
45853
45958
  "task_prefix",
45854
45959
  "task_counter",
45960
+ "parent_id",
45855
45961
  "created_at",
45856
45962
  "updated_at",
45857
45963
  "machine_id",
@@ -47669,8 +47775,12 @@ class PostgresJsonRecordStore {
47669
47775
  OR ($11::text <> $2
47670
47776
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
47671
47777
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
47672
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
47673
- ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
47778
+ (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
47779
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
47780
+ (NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
47781
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
47782
+ OR $8::text IS NULL
47783
+ OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
47674
47784
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
47675
47785
  ), guarded AS (
47676
47786
  SELECT
@@ -47693,7 +47803,6 @@ class PostgresJsonRecordStore {
47693
47803
  AND guarded.version_matches
47694
47804
  AND guarded.parent_found
47695
47805
  AND guarded.parent_acyclic
47696
- AND guarded.all_plans_found
47697
47806
  AND guarded.target_plan_found
47698
47807
  AND NOT guarded.project_conflict
47699
47808
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -47709,7 +47818,7 @@ class PostgresJsonRecordStore {
47709
47818
  RETURNING payload
47710
47819
  )
47711
47820
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
47712
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
47821
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
47713
47822
  (SELECT payload FROM stored) AS payload,
47714
47823
  (SELECT payload FROM locked_task) AS current_payload
47715
47824
  FROM guarded`, [
@@ -47741,7 +47850,7 @@ class PostgresJsonRecordStore {
47741
47850
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
47742
47851
  throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
47743
47852
  }
47744
- if (!row?.all_plans_found || !row.target_plan_found) {
47853
+ if (!row?.target_plan_found) {
47745
47854
  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 });
47746
47855
  }
47747
47856
  if (row.project_conflict) {
@@ -48950,17 +49059,26 @@ async function getChangedSince(since, filters, store) {
48950
49059
  async function createProject2(input, store, context) {
48951
49060
  const timestamp4 = new Date().toISOString();
48952
49061
  const derivedSlug = slugifyRaw(input.name);
48953
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
49062
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
48954
49063
  if (!derivedSlug || !taskListId)
48955
49064
  throw new Error("Project name and task-list slug must be non-empty");
49065
+ const parentId = input.parent_id ?? null;
49066
+ const id = randomUUID4();
49067
+ if (parentId !== null) {
49068
+ const parent = await store.get("projects", parentId);
49069
+ if (!parent)
49070
+ throw new ProjectNotFoundError(parentId);
49071
+ await assertNotProjectAncestorPostgres(id, parentId, store);
49072
+ }
48956
49073
  const project = {
48957
- id: randomUUID4(),
49074
+ id,
48958
49075
  name: input.name,
48959
49076
  path: input.path,
48960
49077
  description: input.description ?? null,
48961
49078
  task_list_id: taskListId,
48962
49079
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
48963
49080
  task_counter: 0,
49081
+ parent_id: parentId,
48964
49082
  created_at: timestamp4,
48965
49083
  updated_at: timestamp4,
48966
49084
  machine_id: store.machineId(context),
@@ -48968,12 +49086,38 @@ async function createProject2(input, store, context) {
48968
49086
  };
48969
49087
  return store.upsert("projects", project, context);
48970
49088
  }
49089
+ async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
49090
+ const all = await store.list("projects");
49091
+ const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
49092
+ let cursor = candidateParentId;
49093
+ const seen = new Set;
49094
+ while (cursor !== null) {
49095
+ if (cursor === projectId) {
49096
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
49097
+ }
49098
+ if (seen.has(cursor))
49099
+ break;
49100
+ seen.add(cursor);
49101
+ cursor = byId.get(cursor) ?? null;
49102
+ }
49103
+ }
48971
49104
  async function updateProject2(id, input, store) {
48972
49105
  if ("task_list_id" in input) {
48973
49106
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
48974
49107
  }
48975
49108
  const project = await requireRecord("projects", id, store);
48976
- const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
49109
+ if (input.parent_id !== undefined && input.parent_id !== null) {
49110
+ const parent = await store.get("projects", input.parent_id);
49111
+ if (!parent)
49112
+ throw new ProjectNotFoundError(input.parent_id);
49113
+ await assertNotProjectAncestorPostgres(id, input.parent_id, store);
49114
+ }
49115
+ const updated = {
49116
+ ...project,
49117
+ ...definedPatch(input),
49118
+ ...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
49119
+ updated_at: new Date().toISOString()
49120
+ };
48977
49121
  return store.upsert("projects", updated);
48978
49122
  }
48979
49123
  async function createPlan2(input, store, context) {
@@ -50844,7 +50988,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
50844
50988
  }
50845
50989
  async createProject(input) {
50846
50990
  const derivedSlug = normalizeSlug(input.name);
50847
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
50991
+ const taskListId = input.task_list_id === undefined ? derivedSlug : normalizeSlug(input.task_list_id);
50848
50992
  if (!derivedSlug || !taskListId) {
50849
50993
  throw new Error("Project name and task-list slug must be non-empty");
50850
50994
  }
@@ -50856,6 +51000,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
50856
51000
  task_list_id: taskListId,
50857
51001
  task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
50858
51002
  task_counter: 0,
51003
+ parent_id: input.parent_id ?? null,
50859
51004
  created_at: now(),
50860
51005
  updated_at: now(),
50861
51006
  machine_id: currentStorageMachineId(this.db)
@@ -50866,14 +51011,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
50866
51011
  try {
50867
51012
  const result = this.db.run(`INSERT INTO projects (
50868
51013
  id, name, path, description, task_list_id, task_prefix,
50869
- task_counter, created_at, updated_at, machine_id
50870
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
51014
+ task_counter, parent_id, created_at, updated_at, machine_id
51015
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
50871
51016
  project.id,
50872
51017
  project.name,
50873
51018
  project.path,
50874
51019
  project.description,
50875
51020
  project.task_list_id,
50876
51021
  project.task_prefix,
51022
+ project.parent_id,
50877
51023
  project.created_at,
50878
51024
  project.updated_at,
50879
51025
  project.machine_id ?? null
@@ -51157,7 +51303,10 @@ function taskListSlug(projectSlug) {
51157
51303
  if (!slug || slug !== projectSlug) {
51158
51304
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
51159
51305
  }
51160
- return `todos-${slug}`;
51306
+ return slug;
51307
+ }
51308
+ function legacyTaskListSlug(projectSlug) {
51309
+ return `todos-${normalizeSlug(projectSlug)}`;
51161
51310
  }
51162
51311
  function deterministicTaskPrefix(projectSlug) {
51163
51312
  const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
@@ -51676,6 +51825,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
51676
51825
  created_by_operation: false
51677
51826
  };
51678
51827
  }
51828
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === legacyTaskListSlug(request.project_slug)) {
51829
+ return {
51830
+ record: boundExistingProjectRecord(conflict2),
51831
+ created_by_operation: false
51832
+ };
51833
+ }
51679
51834
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
51680
51835
  }
51681
51836
  await this.fault("before_object_write", request);
@@ -51712,6 +51867,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
51712
51867
  }
51713
51868
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
51714
51869
  }
51870
+ if (request.bind_existing === true) {
51871
+ const legacy = await transaction.findTaskListConflict(todosProjectId, legacyTaskListSlug(request.project_slug));
51872
+ if (legacy && legacy.project_id === todosProjectId) {
51873
+ return {
51874
+ record: boundExistingTaskListRecord(legacy),
51875
+ created_by_operation: false
51876
+ };
51877
+ }
51878
+ }
51715
51879
  await this.fault("before_object_write", request);
51716
51880
  const taskList = await transaction.createTaskList({
51717
51881
  name: request.project_name,
@@ -55641,7 +55805,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55641
55805
  path: { type: "string", minLength: 1 },
55642
55806
  description: { type: "string" },
55643
55807
  task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
55644
- task_prefix: { type: "string", minLength: 1 }
55808
+ task_prefix: { type: "string", minLength: 1 },
55809
+ parent_id: { type: "string", minLength: 1 }
55645
55810
  }
55646
55811
  },
55647
55812
  UpdateProjectInput: {
@@ -55651,7 +55816,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55651
55816
  properties: {
55652
55817
  name: { type: "string", minLength: 1 },
55653
55818
  path: { type: "string", minLength: 1 },
55654
- description: { type: "string", nullable: true }
55819
+ description: { type: "string", nullable: true },
55820
+ parent_id: { type: "string", minLength: 1, nullable: true }
55655
55821
  }
55656
55822
  },
55657
55823
  RenameProjectInput: {
@@ -57950,6 +58116,7 @@ var init_openapi = __esm(() => {
57950
58116
  task_list_id: { type: "string", nullable: true },
57951
58117
  task_prefix: { type: "string", nullable: true },
57952
58118
  task_counter: { type: "number" },
58119
+ parent_id: { type: "string", nullable: true },
57953
58120
  created_at: { type: "string" },
57954
58121
  updated_at: { type: "string" }
57955
58122
  }
@@ -59367,7 +59534,7 @@ function validateProjectPatch(value) {
59367
59534
  if (!value || typeof value !== "object" || Array.isArray(value))
59368
59535
  return { ok: false, message: "project patch must be an object" };
59369
59536
  const body2 = value;
59370
- const allowed = new Set(["name", "path", "description"]);
59537
+ const allowed = new Set(["name", "path", "description", "parent_id"]);
59371
59538
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
59372
59539
  if (unknown)
59373
59540
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -59379,13 +59546,15 @@ function validateProjectPatch(value) {
59379
59546
  return { ok: false, message: "path must be a non-empty string" };
59380
59547
  if (body2["description"] !== undefined && body2["description"] !== null && typeof body2["description"] !== "string")
59381
59548
  return { ok: false, message: "description must be a string or null" };
59549
+ if (body2["parent_id"] !== undefined && body2["parent_id"] !== null && (typeof body2["parent_id"] !== "string" || !body2["parent_id"].trim()))
59550
+ return { ok: false, message: "parent_id must be a string or null" };
59382
59551
  return { ok: true, patch: body2 };
59383
59552
  }
59384
59553
  function validateProjectCreate(value) {
59385
59554
  if (!value || typeof value !== "object" || Array.isArray(value))
59386
59555
  return { ok: false, message: "project body must be an object" };
59387
59556
  const body2 = value;
59388
- const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
59557
+ const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix", "parent_id"]);
59389
59558
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
59390
59559
  if (unknown)
59391
59560
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -59403,6 +59572,9 @@ function validateProjectCreate(value) {
59403
59572
  if (body2["task_prefix"] !== undefined && (typeof body2["task_prefix"] !== "string" || !body2["task_prefix"].trim())) {
59404
59573
  return { ok: false, message: "task_prefix must be a non-empty string" };
59405
59574
  }
59575
+ if (body2["parent_id"] !== undefined && (typeof body2["parent_id"] !== "string" || !body2["parent_id"].trim())) {
59576
+ return { ok: false, message: "parent_id must be a non-empty string" };
59577
+ }
59406
59578
  return { ok: true, input: body2 };
59407
59579
  }
59408
59580
  function validatePlanCreate(value) {