@hasna/todos 0.15.34 → 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.
package/dist/cli/index.js CHANGED
@@ -2123,7 +2123,7 @@ var package_default;
2123
2123
  var init_package = __esm(() => {
2124
2124
  package_default = {
2125
2125
  name: "@hasna/todos",
2126
- version: "0.15.34",
2126
+ version: "0.15.35",
2127
2127
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
2128
2128
  type: "module",
2129
2129
  main: "dist/index.js",
@@ -2198,6 +2198,9 @@ var init_package = __esm(() => {
2198
2198
  "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
2199
2199
  "verify:attested-container-candidate": "bun run scripts/attested-container-candidate.ts verify",
2200
2200
  "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
2201
+ "emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
2202
+ "verify:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts verify",
2203
+ "test:attested-container-compatibility-vector": "bun test scripts/attested-container-compatibility-vector.test.ts",
2201
2204
  "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
2202
2205
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
2203
2206
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
@@ -5819,7 +5822,31 @@ function classifyRemoteRequestError(baseUrl, route, error) {
5819
5822
  }
5820
5823
  throw error;
5821
5824
  }
5822
- function protectRemoteClient(client) {
5825
+ function withBoundedRemoteRequest(options, requestTimeoutMs, run) {
5826
+ const controller = new AbortController;
5827
+ const onBaseAbort = () => controller.abort();
5828
+ if (options?.signal) {
5829
+ if (options.signal.aborted)
5830
+ controller.abort();
5831
+ else
5832
+ options.signal.addEventListener("abort", onBaseAbort, { once: true });
5833
+ }
5834
+ let timer;
5835
+ const deadline = new Promise((_, reject) => {
5836
+ timer = setTimeout(() => {
5837
+ controller.abort();
5838
+ reject(new DOMException(`Todos remote request exceeded the ${requestTimeoutMs}ms bounded request timeout`, "AbortError"));
5839
+ }, requestTimeoutMs);
5840
+ });
5841
+ const attempt = run({ ...options, signal: controller.signal });
5842
+ attempt.catch(() => {});
5843
+ return Promise.race([attempt, deadline]).finally(() => {
5844
+ if (timer)
5845
+ clearTimeout(timer);
5846
+ options?.signal?.removeEventListener("abort", onBaseAbort);
5847
+ });
5848
+ }
5849
+ function protectRemoteClient(client, requestTimeoutMs) {
5823
5850
  const baseUrl = remoteAuthorityBase(client);
5824
5851
  const protect = async (route, request) => {
5825
5852
  try {
@@ -5828,25 +5855,26 @@ function protectRemoteClient(client) {
5828
5855
  return classifyRemoteRequestError(baseUrl, route, error);
5829
5856
  }
5830
5857
  };
5858
+ const bounded = (options, run) => withBoundedRemoteRequest(options, requestTimeoutMs, run);
5831
5859
  const transport = client.transport;
5832
5860
  const protectedTransport = {
5833
5861
  baseUrl: transport.baseUrl,
5834
- request: (method, path, body, options) => protect(path, () => transport.request(method, path, body, options)),
5835
- get: (path, options) => protect(path, () => transport.get(path, options)),
5836
- post: (path, body, options) => protect(path, () => transport.post(path, body, options)),
5837
- put: (path, body, options) => protect(path, () => transport.put(path, body, options)),
5838
- patch: (path, body, options) => protect(path, () => transport.patch(path, body, options)),
5839
- del: (path, body, options) => protect(path, () => transport.del(path, body, options))
5862
+ request: (method, path, body, options) => protect(path, () => bounded(options, (opts) => transport.request(method, path, body, opts))),
5863
+ get: (path, options) => protect(path, () => bounded(options, (opts) => transport.get(path, opts))),
5864
+ post: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.post(path, body, opts))),
5865
+ put: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.put(path, body, opts))),
5866
+ patch: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.patch(path, body, opts))),
5867
+ del: (path, body, options) => protect(path, () => bounded(options, (opts) => transport.del(path, body, opts)))
5840
5868
  };
5841
5869
  return {
5842
5870
  name: client.name,
5843
5871
  baseUrl: client.baseUrl,
5844
5872
  transport: protectedTransport,
5845
- list: (resource, options) => protect(`/${resource}`, () => client.list(resource, options)),
5846
- get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.get(resource, id, options)),
5847
- create: (resource, body, options) => protect(`/${resource}`, () => client.create(resource, body, options)),
5848
- update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.update(resource, id, patch, options)),
5849
- delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.delete(resource, id, options))
5873
+ list: (resource, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.list(resource, opts))),
5874
+ get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.get(resource, id, opts))),
5875
+ create: (resource, body, options) => protect(`/${resource}`, () => bounded(options, (opts) => client.create(resource, body, opts))),
5876
+ update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.update(resource, id, patch, opts))),
5877
+ delete: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => bounded(options, (opts) => client.delete(resource, id, opts)))
5850
5878
  };
5851
5879
  }
5852
5880
  function remoteAuthorityBase(client) {
@@ -5867,17 +5895,18 @@ async function requiredRemoteRoute(client, route, request, recognized404Codes =
5867
5895
  throw error;
5868
5896
  }
5869
5897
  }
5870
- function getTodosCloudClient(env = process.env) {
5898
+ function getTodosCloudClient(env = process.env, requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS) {
5871
5899
  if (requestedTransport(env) !== "http")
5872
5900
  return null;
5873
5901
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
5874
- fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
5902
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
5903
+ timeoutMs: requestTimeoutMs
5875
5904
  });
5876
5905
  if (resolved.transport === "cloud-http")
5877
- return protectRemoteClient(resolved.client);
5906
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
5878
5907
  const transportName = resolved.transport;
5879
5908
  if (transportName === "http")
5880
- return protectRemoteClient(resolved.client);
5909
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
5881
5910
  return null;
5882
5911
  }
5883
5912
  function isCloudRouting(env = process.env) {
@@ -7535,7 +7564,7 @@ async function cloudTimeline(client, options = {}) {
7535
7564
  const limit = options.limit ?? 50;
7536
7565
  return { entries: entries.slice(offset, offset + limit), total, limit, offset };
7537
7566
  }
7538
- var UUID_RE, COMPLETION_EVIDENCE_FIELDS, completionCapabilityCache, retryCapabilityCache, taskCreatorCapabilityCache, gitRefCapabilityCache, remoteCommandCapabilityCache, LEGACY_STORAGE_MODE_KEYS, PRIORITY_RANK, listTagsCapabilityCache, PLAN_COMPLETION_PROTECTED_FIELDS, RELATION_HYDRATION_CONCURRENCY = 6;
7567
+ var UUID_RE, COMPLETION_EVIDENCE_FIELDS, completionCapabilityCache, retryCapabilityCache, taskCreatorCapabilityCache, gitRefCapabilityCache, remoteCommandCapabilityCache, LEGACY_STORAGE_MODE_KEYS, REMOTE_REQUEST_TIMEOUT_MS = 1e4, PRIORITY_RANK, listTagsCapabilityCache, PLAN_COMPLETION_PROTECTED_FIELDS, RELATION_HYDRATION_CONCURRENCY = 6;
7539
7568
  var init_cloud_router = __esm(() => {
7540
7569
  init_types();
7541
7570
  init_redaction();
@@ -10035,6 +10064,10 @@ var init_migrations = __esm(() => {
10035
10064
  );
10036
10065
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
10037
10066
  COMMIT;
10067
+ `,
10068
+ `BEGIN;
10069
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
10070
+ COMMIT;
10038
10071
  `
10039
10072
  ];
10040
10073
  });
@@ -10708,6 +10741,8 @@ function ensureSchema(db) {
10708
10741
  ensureColumn("projects", "task_list_id", "TEXT");
10709
10742
  ensureColumn("projects", "task_prefix", "TEXT");
10710
10743
  ensureColumn("projects", "task_counter", "INTEGER NOT NULL DEFAULT 0");
10744
+ ensureColumn("projects", "parent_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
10745
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_projects_parent_id ON projects(parent_id)");
10711
10746
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
10712
10747
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
10713
10748
  ensureColumn("tasks", "short_id", "TEXT");
@@ -12779,10 +12814,12 @@ __export(exports_projects, {
12779
12814
  renameProject: () => renameProject,
12780
12815
  removeProjectSource: () => removeProjectSource,
12781
12816
  removeMachineLocalPath: () => removeMachineLocalPath,
12817
+ orderProjectsParentFirst: () => orderProjectsParentFirst,
12782
12818
  nextTaskShortId: () => nextTaskShortId,
12783
12819
  listProjects: () => listProjects,
12784
12820
  listProjectSources: () => listProjectSources,
12785
12821
  listMachineLocalPaths: () => listMachineLocalPaths,
12822
+ listChildProjects: () => listChildProjects,
12786
12823
  getProjectWithSources: () => getProjectWithSources,
12787
12824
  getProjectByPath: () => getProjectByPath,
12788
12825
  getProject: () => getProject,
@@ -12790,6 +12827,7 @@ __export(exports_projects, {
12790
12827
  ensureProject: () => ensureProject,
12791
12828
  deleteProject: () => deleteProject,
12792
12829
  createProject: () => createProject,
12830
+ assertNotProjectAncestor: () => assertNotProjectAncestor,
12793
12831
  addProjectSource: () => addProjectSource
12794
12832
  });
12795
12833
  function slugify(name) {
@@ -12821,17 +12859,24 @@ function createProject(input, db) {
12821
12859
  const id = uuid();
12822
12860
  const timestamp2 = now();
12823
12861
  const derivedSlug = slugify(input.name);
12824
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugify(input.task_list_id);
12862
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugify(input.task_list_id);
12825
12863
  if (!derivedSlug || !taskListId)
12826
12864
  throw new Error("Project name and task-list slug must be non-empty");
12827
12865
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
12828
12866
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
12829
12867
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
12830
12868
  }
12869
+ const parentId = input.parent_id ?? null;
12870
+ if (parentId !== null) {
12871
+ const parent = getProject(parentId, d);
12872
+ if (!parent)
12873
+ throw new ProjectNotFoundError(parentId);
12874
+ assertNotProjectAncestor(id, parentId, d);
12875
+ }
12831
12876
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
12832
12877
  const machineId = currentStorageMachineId(d);
12833
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
12834
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, timestamp2, timestamp2, machineId]);
12878
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, parent_id, created_at, updated_at, machine_id)
12879
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, taskPrefix, parentId, timestamp2, timestamp2, machineId]);
12835
12880
  return getProject(id, d);
12836
12881
  })();
12837
12882
  }
@@ -12856,6 +12901,64 @@ function listProjects(db) {
12856
12901
  const d = db || getDatabase();
12857
12902
  return d.query("SELECT * FROM projects ORDER BY name").all();
12858
12903
  }
12904
+ function listChildProjects(parentId, db) {
12905
+ const d = db || getDatabase();
12906
+ return d.query("SELECT * FROM projects WHERE parent_id = ? ORDER BY name").all(parentId);
12907
+ }
12908
+ function orderProjectsParentFirst(projects) {
12909
+ const projectId = (project) => {
12910
+ const id = project.id;
12911
+ return typeof id === "string" && id.length > 0 ? id : null;
12912
+ };
12913
+ const parentId = (project) => {
12914
+ const parent = project.parent_id;
12915
+ return parent == null ? null : String(parent);
12916
+ };
12917
+ const byId = new Set;
12918
+ for (const project of projects) {
12919
+ const id = projectId(project);
12920
+ if (id !== null)
12921
+ byId.add(id);
12922
+ }
12923
+ const ordered = [];
12924
+ const emitted = new Set;
12925
+ let remaining = [...projects];
12926
+ let progress = true;
12927
+ while (progress && remaining.length > 0) {
12928
+ progress = false;
12929
+ const deferred = [];
12930
+ for (const project of remaining) {
12931
+ const parent = parentId(project);
12932
+ if (parent === null || emitted.has(parent) || !byId.has(parent)) {
12933
+ ordered.push(project);
12934
+ const id = projectId(project);
12935
+ if (id !== null)
12936
+ emitted.add(id);
12937
+ progress = true;
12938
+ } else {
12939
+ deferred.push(project);
12940
+ }
12941
+ }
12942
+ remaining = deferred;
12943
+ }
12944
+ ordered.push(...remaining);
12945
+ return ordered;
12946
+ }
12947
+ function assertNotProjectAncestor(projectId, ancestorId, db) {
12948
+ const d = db || getDatabase();
12949
+ let cursor = ancestorId;
12950
+ const seen = new Set;
12951
+ while (cursor !== null) {
12952
+ if (cursor === projectId) {
12953
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
12954
+ }
12955
+ if (seen.has(cursor))
12956
+ break;
12957
+ seen.add(cursor);
12958
+ const row = d.query("SELECT parent_id FROM projects WHERE id = ?").get(cursor);
12959
+ cursor = row?.parent_id ?? null;
12960
+ }
12961
+ }
12859
12962
  function updateProject(id, input, db) {
12860
12963
  const d = db || getDatabase();
12861
12964
  const project = getProject(id, d);
@@ -12878,6 +12981,16 @@ function updateProject(id, input, db) {
12878
12981
  sets.push("path = ?");
12879
12982
  params.push(input.path);
12880
12983
  }
12984
+ if (input.parent_id !== undefined) {
12985
+ if (input.parent_id !== null) {
12986
+ const parent = getProject(input.parent_id, d);
12987
+ if (!parent)
12988
+ throw new ProjectNotFoundError(input.parent_id);
12989
+ assertNotProjectAncestor(id, input.parent_id, d);
12990
+ }
12991
+ sets.push("parent_id = ?");
12992
+ params.push(input.parent_id);
12993
+ }
12881
12994
  params.push(id);
12882
12995
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
12883
12996
  return getProject(id, d);
@@ -23142,11 +23255,14 @@ function registerTaskCommands(program2) {
23142
23255
  const narrowsAfterQuery = Boolean(opts.dueToday) || Boolean(opts.overdue) || creatorFilterActive && cloud || taskListFilterActive;
23143
23256
  const withholdLimit = requestedLimit !== undefined && (reordersAfterQuery || narrowsAfterQuery);
23144
23257
  const scanCeiling = cloud && (withholdLimit || requestedLimit === undefined) ? Math.max(requestedLimit ?? 0, listScanLimit()) : undefined;
23258
+ const probeLimit = !withholdLimit && requestedLimit !== undefined ? requestedLimit + 1 : undefined;
23145
23259
  const serverFilter = (() => {
23146
23260
  const base = withholdLimit ? (() => {
23147
23261
  const { limit: _dropped, ...rest } = filter;
23148
23262
  return rest;
23149
23263
  })() : filter;
23264
+ if (probeLimit !== undefined)
23265
+ return { ...base, limit: probeLimit };
23150
23266
  return scanCeiling === undefined ? base : { ...base, limit: scanCeiling };
23151
23267
  })();
23152
23268
  let tasks = cloud ? await cloudListTasks(cloud, serverFilter) : listTasks(serverFilter);
@@ -23195,8 +23311,21 @@ function registerTaskCommands(program2) {
23195
23311
  return 0;
23196
23312
  });
23197
23313
  }
23198
- if (withholdLimit && requestedLimit !== undefined)
23199
- tasks = tasks.slice(0, requestedLimit);
23314
+ let truncatedByLimit = false;
23315
+ if (requestedLimit !== undefined) {
23316
+ if (withholdLimit) {
23317
+ truncatedByLimit = tasks.length > requestedLimit;
23318
+ tasks = tasks.slice(0, requestedLimit);
23319
+ } else if (tasks.length > requestedLimit) {
23320
+ truncatedByLimit = true;
23321
+ tasks = tasks.slice(0, requestedLimit);
23322
+ }
23323
+ }
23324
+ if (truncatedByLimit) {
23325
+ console.error(chalk3.yellow(`Warning: the matching set has more than --limit ${requestedLimit} rows, so only the first
23326
+ ` + ` ${requestedLimit} are shown. This read is bounded \u2014 raise --limit or narrow the
23327
+ ` + ` query (--project, --status, --assigned) to see the full population.`));
23328
+ }
23200
23329
  if (rosterPromise && assignedFilter) {
23201
23330
  try {
23202
23331
  const roster = await rosterPromise;
@@ -25296,7 +25425,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25296
25425
  }
25297
25426
  }
25298
25427
  };
25299
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
25428
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
25300
25429
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
25301
25430
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
25302
25431
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -25527,6 +25656,7 @@ var init_sqlite_snapshot = __esm(() => {
25527
25656
  "task_list_id",
25528
25657
  "task_prefix",
25529
25658
  "task_counter",
25659
+ "parent_id",
25530
25660
  "created_at",
25531
25661
  "updated_at",
25532
25662
  "machine_id",
@@ -28531,7 +28661,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
28531
28661
  function bootstrapProject(options = {}, db) {
28532
28662
  const d = db || getDatabase();
28533
28663
  const discovery = discoverProjectWorkspace(options.path);
28534
- const taskListSlug = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
28664
+ const taskListSlug = options.taskListSlug || slugify(options.name || discovery.projectName);
28535
28665
  if (options.dryRun) {
28536
28666
  return {
28537
28667
  dryRun: true,
@@ -30901,7 +31031,7 @@ function registerProjectCommands(program2) {
30901
31031
  }
30902
31032
  }
30903
31033
  });
30904
- program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--show <project>", "Resolve and show a project").option("--update <project>", "Update a project's name, path, or description").option("--deregister <project>", "Deregister a project without deleting its tasks; refuses projects with incomplete tasks").option("--path-prefix <prefix>", "Require deregistered project path to start with this prefix").option("--dry-run", "Show what would change without modifying local state").option("--name <name>", "Project name (with --add)").option("--path <path>", "Project path (with --update)").option("--description <text>", "Project description (with --add or --update)").option("--task-list-id <id>", "Custom task list ID (with --add)").option("--ensure-task-list <project>", "Plan or apply creation of an existing project's declared task list").option("--rollback-task-list <project>", "Conditionally roll back a task list created by --ensure-task-list").option("--apply", "Apply --ensure-task-list or --rollback-task-list; ensure plans by default").option("--idempotency-key <key>", "Stable idempotency key for --ensure-task-list --apply").option("--receipt <id>", "Accepted ensure receipt for --rollback-task-list --apply").action(async (opts) => {
31034
+ program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--show <project>", "Resolve and show a project").option("--update <project>", "Update a project's name, path, or description").option("--deregister <project>", "Deregister a project without deleting its tasks; refuses projects with incomplete tasks").option("--path-prefix <prefix>", "Require deregistered project path to start with this prefix").option("--dry-run", "Show what would change without modifying local state").option("--name <name>", "Project name (with --add)").option("--path <path>", "Project path (with --update)").option("--description <text>", "Project description (with --add or --update)").option("--task-list-id <id>", "Custom task list ID (with --add)").option("--parent <project>", "Parent project (id, path, task-list slug, or name) to create a sub-project under (with --add)").option("--ensure-task-list <project>", "Plan or apply creation of an existing project's declared task list").option("--rollback-task-list <project>", "Conditionally roll back a task list created by --ensure-task-list").option("--apply", "Apply --ensure-task-list or --rollback-task-list; ensure plans by default").option("--idempotency-key <key>", "Stable idempotency key for --ensure-task-list --apply").option("--receipt <id>", "Accepted ensure receipt for --rollback-task-list --apply").action(async (opts) => {
30905
31035
  const globalOpts = program2.opts();
30906
31036
  const cloud = getTodosCloudClient();
30907
31037
  if (opts.ensureTaskList && opts.rollbackTaskList) {
@@ -31018,10 +31148,21 @@ function registerProjectCommands(program2) {
31018
31148
  if (opts.add) {
31019
31149
  const projectPath = resolve13(opts.add);
31020
31150
  const name = opts.name || basename5(projectPath);
31151
+ let parentId;
31152
+ if (opts.parent !== undefined) {
31153
+ const parent = cloud ? await cloudResolveProject(cloud, opts.parent) : resolveExplicitProject(opts.parent);
31154
+ parentId = parent.id;
31155
+ }
31021
31156
  const existing = cloud ? (await cloudListProjects(cloud)).find((project2) => project2.path === projectPath) : getProjectByPath(projectPath);
31022
31157
  let project;
31023
31158
  if (existing) {
31024
31159
  project = existing;
31160
+ if (opts.parent !== undefined && existing.parent_id !== parentId) {
31161
+ if (cloud)
31162
+ project = await cloudUpdateProject(cloud, existing.id, { parent_id: parentId });
31163
+ else
31164
+ project = updateProject(existing.id, { parent_id: parentId });
31165
+ }
31025
31166
  if (opts.taskListId) {
31026
31167
  if (cloud && existing.task_list_id !== opts.taskListId) {
31027
31168
  handleError(new Error("Remote project task-list slug changes require project-rename"));
@@ -31030,7 +31171,7 @@ function registerProjectCommands(program2) {
31030
31171
  project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
31031
31172
  }
31032
31173
  } else {
31033
- const input = { name, path: projectPath, description: opts.description, task_list_id: opts.taskListId };
31174
+ const input = { name, path: projectPath, description: opts.description, task_list_id: opts.taskListId, parent_id: parentId };
31034
31175
  project = cloud ? await cloudCreateProject(cloud, input) : createProject(input);
31035
31176
  }
31036
31177
  if (!cloud) {
@@ -31047,6 +31188,8 @@ function registerProjectCommands(program2) {
31047
31188
  console.log(chalk5.green(`Project registered: ${project.name} (${project.path})`));
31048
31189
  if (project.task_list_id)
31049
31190
  console.log(chalk5.dim(` Task list: ${project.task_list_id}`));
31191
+ if (parentId)
31192
+ console.log(chalk5.dim(` Parent: ${parentId}`));
31050
31193
  }
31051
31194
  return;
31052
31195
  }
@@ -32538,8 +32681,12 @@ class PostgresJsonRecordStore {
32538
32681
  OR ($11::text <> $2
32539
32682
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
32540
32683
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
32541
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
32542
- ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
32684
+ (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
32685
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', '')) AS membership_changed,
32686
+ (NOT (COALESCE((SELECT payload->>'plan_id' FROM locked_task), '')
32687
+ IS DISTINCT FROM COALESCE($3::jsonb->>'plan_id', ''))
32688
+ OR $8::text IS NULL
32689
+ OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
32543
32690
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
32544
32691
  ), guarded AS (
32545
32692
  SELECT
@@ -32562,7 +32709,6 @@ class PostgresJsonRecordStore {
32562
32709
  AND guarded.version_matches
32563
32710
  AND guarded.parent_found
32564
32711
  AND guarded.parent_acyclic
32565
- AND guarded.all_plans_found
32566
32712
  AND guarded.target_plan_found
32567
32713
  AND NOT guarded.project_conflict
32568
32714
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -32578,7 +32724,7 @@ class PostgresJsonRecordStore {
32578
32724
  RETURNING payload
32579
32725
  )
32580
32726
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
32581
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
32727
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
32582
32728
  (SELECT payload FROM stored) AS payload,
32583
32729
  (SELECT payload FROM locked_task) AS current_payload
32584
32730
  FROM guarded`, [
@@ -32610,7 +32756,7 @@ class PostgresJsonRecordStore {
32610
32756
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
32611
32757
  throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
32612
32758
  }
32613
- if (!row?.all_plans_found || !row.target_plan_found) {
32759
+ if (!row?.target_plan_found) {
32614
32760
  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 });
32615
32761
  }
32616
32762
  if (row.project_conflict) {
@@ -33819,17 +33965,26 @@ async function getChangedSince(since, filters, store) {
33819
33965
  async function createProject2(input, store, context) {
33820
33966
  const timestamp2 = new Date().toISOString();
33821
33967
  const derivedSlug = slugifyRaw(input.name);
33822
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : slugifyRaw(input.task_list_id);
33968
+ const taskListId = input.task_list_id === undefined ? derivedSlug : slugifyRaw(input.task_list_id);
33823
33969
  if (!derivedSlug || !taskListId)
33824
33970
  throw new Error("Project name and task-list slug must be non-empty");
33971
+ const parentId = input.parent_id ?? null;
33972
+ const id = randomUUID4();
33973
+ if (parentId !== null) {
33974
+ const parent = await store.get("projects", parentId);
33975
+ if (!parent)
33976
+ throw new ProjectNotFoundError(parentId);
33977
+ await assertNotProjectAncestorPostgres(id, parentId, store);
33978
+ }
33825
33979
  const project = {
33826
- id: randomUUID4(),
33980
+ id,
33827
33981
  name: input.name,
33828
33982
  path: input.path,
33829
33983
  description: input.description ?? null,
33830
33984
  task_list_id: taskListId,
33831
33985
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
33832
33986
  task_counter: 0,
33987
+ parent_id: parentId,
33833
33988
  created_at: timestamp2,
33834
33989
  updated_at: timestamp2,
33835
33990
  machine_id: store.machineId(context),
@@ -33837,12 +33992,38 @@ async function createProject2(input, store, context) {
33837
33992
  };
33838
33993
  return store.upsert("projects", project, context);
33839
33994
  }
33995
+ async function assertNotProjectAncestorPostgres(projectId, candidateParentId, store) {
33996
+ const all = await store.list("projects");
33997
+ const byId = new Map(all.map((project) => [project.id, project.parent_id ?? null]));
33998
+ let cursor = candidateParentId;
33999
+ const seen = new Set;
34000
+ while (cursor !== null) {
34001
+ if (cursor === projectId) {
34002
+ throw new ResourceConflictError("PROJECT_PARENT_CYCLE", `Project "${projectId}" cannot be placed under its own descendant`);
34003
+ }
34004
+ if (seen.has(cursor))
34005
+ break;
34006
+ seen.add(cursor);
34007
+ cursor = byId.get(cursor) ?? null;
34008
+ }
34009
+ }
33840
34010
  async function updateProject2(id, input, store) {
33841
34011
  if ("task_list_id" in input) {
33842
34012
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
33843
34013
  }
33844
34014
  const project = await requireRecord("projects", id, store);
33845
- const updated = { ...project, ...definedPatch(input), updated_at: new Date().toISOString() };
34015
+ if (input.parent_id !== undefined && input.parent_id !== null) {
34016
+ const parent = await store.get("projects", input.parent_id);
34017
+ if (!parent)
34018
+ throw new ProjectNotFoundError(input.parent_id);
34019
+ await assertNotProjectAncestorPostgres(id, input.parent_id, store);
34020
+ }
34021
+ const updated = {
34022
+ ...project,
34023
+ ...definedPatch(input),
34024
+ ...input.parent_id !== undefined ? { parent_id: input.parent_id } : {},
34025
+ updated_at: new Date().toISOString()
34026
+ };
33846
34027
  return store.upsert("projects", updated);
33847
34028
  }
33848
34029
  async function createPlan2(input, store, context) {
@@ -35234,7 +35415,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35234
35415
  }
35235
35416
  async createProject(input) {
35236
35417
  const derivedSlug = normalizeSlug(input.name);
35237
- const taskListId = input.task_list_id === undefined ? `todos-${derivedSlug}` : normalizeSlug(input.task_list_id);
35418
+ const taskListId = input.task_list_id === undefined ? derivedSlug : normalizeSlug(input.task_list_id);
35238
35419
  if (!derivedSlug || !taskListId) {
35239
35420
  throw new Error("Project name and task-list slug must be non-empty");
35240
35421
  }
@@ -35246,6 +35427,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35246
35427
  task_list_id: taskListId,
35247
35428
  task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
35248
35429
  task_counter: 0,
35430
+ parent_id: input.parent_id ?? null,
35249
35431
  created_at: now(),
35250
35432
  updated_at: now(),
35251
35433
  machine_id: currentStorageMachineId(this.db)
@@ -35256,14 +35438,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35256
35438
  try {
35257
35439
  const result = this.db.run(`INSERT INTO projects (
35258
35440
  id, name, path, description, task_list_id, task_prefix,
35259
- task_counter, created_at, updated_at, machine_id
35260
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
35441
+ task_counter, parent_id, created_at, updated_at, machine_id
35442
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
35261
35443
  project.id,
35262
35444
  project.name,
35263
35445
  project.path,
35264
35446
  project.description,
35265
35447
  project.task_list_id,
35266
35448
  project.task_prefix,
35449
+ project.parent_id,
35267
35450
  project.created_at,
35268
35451
  project.updated_at,
35269
35452
  project.machine_id ?? null
@@ -35660,7 +35843,10 @@ function taskListSlug(projectSlug) {
35660
35843
  if (!slug || slug !== projectSlug) {
35661
35844
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
35662
35845
  }
35663
- return `todos-${slug}`;
35846
+ return slug;
35847
+ }
35848
+ function legacyTaskListSlug(projectSlug) {
35849
+ return `todos-${normalizeSlug(projectSlug)}`;
35664
35850
  }
35665
35851
  function deterministicTaskPrefix(projectSlug) {
35666
35852
  const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
@@ -36179,6 +36365,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36179
36365
  created_by_operation: false
36180
36366
  };
36181
36367
  }
36368
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === legacyTaskListSlug(request.project_slug)) {
36369
+ return {
36370
+ record: boundExistingProjectRecord(conflict2),
36371
+ created_by_operation: false
36372
+ };
36373
+ }
36182
36374
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
36183
36375
  }
36184
36376
  await this.fault("before_object_write", request);
@@ -36215,6 +36407,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36215
36407
  }
36216
36408
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
36217
36409
  }
36410
+ if (request.bind_existing === true) {
36411
+ const legacy = await transaction.findTaskListConflict(todosProjectId, legacyTaskListSlug(request.project_slug));
36412
+ if (legacy && legacy.project_id === todosProjectId) {
36413
+ return {
36414
+ record: boundExistingTaskListRecord(legacy),
36415
+ created_by_operation: false
36416
+ };
36417
+ }
36418
+ }
36218
36419
  await this.fault("before_object_write", request);
36219
36420
  const taskList = await transaction.createTaskList({
36220
36421
  name: request.project_name,
@@ -51904,7 +52105,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51904
52105
  path: { type: "string", minLength: 1 },
51905
52106
  description: { type: "string" },
51906
52107
  task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
51907
- task_prefix: { type: "string", minLength: 1 }
52108
+ task_prefix: { type: "string", minLength: 1 },
52109
+ parent_id: { type: "string", minLength: 1 }
51908
52110
  }
51909
52111
  },
51910
52112
  UpdateProjectInput: {
@@ -51914,7 +52116,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51914
52116
  properties: {
51915
52117
  name: { type: "string", minLength: 1 },
51916
52118
  path: { type: "string", minLength: 1 },
51917
- description: { type: "string", nullable: true }
52119
+ description: { type: "string", nullable: true },
52120
+ parent_id: { type: "string", minLength: 1, nullable: true }
51918
52121
  }
51919
52122
  },
51920
52123
  RenameProjectInput: {
@@ -54213,6 +54416,7 @@ var init_openapi = __esm(() => {
54213
54416
  task_list_id: { type: "string", nullable: true },
54214
54417
  task_prefix: { type: "string", nullable: true },
54215
54418
  task_counter: { type: "number" },
54419
+ parent_id: { type: "string", nullable: true },
54216
54420
  created_at: { type: "string" },
54217
54421
  updated_at: { type: "string" }
54218
54422
  }
@@ -55283,7 +55487,7 @@ function validateProjectPatch(value) {
55283
55487
  if (!value || typeof value !== "object" || Array.isArray(value))
55284
55488
  return { ok: false, message: "project patch must be an object" };
55285
55489
  const body2 = value;
55286
- const allowed = new Set(["name", "path", "description"]);
55490
+ const allowed = new Set(["name", "path", "description", "parent_id"]);
55287
55491
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
55288
55492
  if (unknown)
55289
55493
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -55295,13 +55499,15 @@ function validateProjectPatch(value) {
55295
55499
  return { ok: false, message: "path must be a non-empty string" };
55296
55500
  if (body2["description"] !== undefined && body2["description"] !== null && typeof body2["description"] !== "string")
55297
55501
  return { ok: false, message: "description must be a string or null" };
55502
+ if (body2["parent_id"] !== undefined && body2["parent_id"] !== null && (typeof body2["parent_id"] !== "string" || !body2["parent_id"].trim()))
55503
+ return { ok: false, message: "parent_id must be a string or null" };
55298
55504
  return { ok: true, patch: body2 };
55299
55505
  }
55300
55506
  function validateProjectCreate(value) {
55301
55507
  if (!value || typeof value !== "object" || Array.isArray(value))
55302
55508
  return { ok: false, message: "project body must be an object" };
55303
55509
  const body2 = value;
55304
- const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix"]);
55510
+ const allowed = new Set(["name", "path", "description", "task_list_id", "task_prefix", "parent_id"]);
55305
55511
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
55306
55512
  if (unknown)
55307
55513
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -55319,6 +55525,9 @@ function validateProjectCreate(value) {
55319
55525
  if (body2["task_prefix"] !== undefined && (typeof body2["task_prefix"] !== "string" || !body2["task_prefix"].trim())) {
55320
55526
  return { ok: false, message: "task_prefix must be a non-empty string" };
55321
55527
  }
55528
+ if (body2["parent_id"] !== undefined && (typeof body2["parent_id"] !== "string" || !body2["parent_id"].trim())) {
55529
+ return { ok: false, message: "parent_id must be a non-empty string" };
55530
+ }
55322
55531
  return { ok: true, input: body2 };
55323
55532
  }
55324
55533
  function validatePlanCreate(value) {
@@ -62034,6 +62243,7 @@ function registerTaskProjectTools(server, ctx) {
62034
62243
  name: exports_external.string().describe("Project name"),
62035
62244
  path: exports_external.string().describe("Unique filesystem path for the project"),
62036
62245
  description: exports_external.string().optional(),
62246
+ parent_id: exports_external.string().optional().describe("Optional parent project id to create this as a sub-project"),
62037
62247
  status: exports_external.enum(["active", "completed", "on_hold", "archived"]).optional(),
62038
62248
  short_id: exports_external.string().nullable().optional().describe("Short ID (auto-generated if omitted)"),
62039
62249
  metadata: exports_external.record(exports_external.unknown()).optional()
@@ -69584,6 +69794,7 @@ function createAgentProjectDemoBundle() {
69584
69794
  task_list_id: ids.list,
69585
69795
  task_prefix: "DEMO",
69586
69796
  task_counter: 4,
69797
+ parent_id: null,
69587
69798
  created_at: createdAt,
69588
69799
  updated_at: completedAt
69589
69800
  });
@@ -89333,6 +89544,7 @@ var init_json_contracts = __esm(() => {
89333
89544
  task_list_id: nullableIdField,
89334
89545
  task_prefix: field(["string", "null"], "Optional task prefix.", true),
89335
89546
  task_counter: field("integer", "Monotonic project task counter."),
89547
+ parent_id: field(["string", "null"], "Optional parent project id; null means top-level.", true),
89336
89548
  created_at: isoDateField,
89337
89549
  updated_at: isoDateField
89338
89550
  },