@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/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.33",
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",
@@ -2197,9 +2197,10 @@ var init_package = __esm(() => {
2197
2197
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
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
- "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts scripts/attested-container-compatibility-vector.test.ts",
2200
+ "test:attested-container-candidate": "bun test scripts/attested-container-candidate.test.ts",
2201
2201
  "emit:iapp-deployment-compatibility-vector": "bun run scripts/attested-container-compatibility-vector.ts emit",
2202
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",
2203
2204
  "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
2204
2205
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
2205
2206
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
@@ -5657,40 +5658,38 @@ var init_page_validation = __esm(() => {
5657
5658
 
5658
5659
  // src/cli/cloud-router.ts
5659
5660
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
5660
- import { normalizeStorageMode } from "@hasna/contracts/mode";
5661
5661
  import { randomUUID } from "crypto";
5662
5662
  import { resolve as resolvePath } from "path";
5663
- function cleanMode(value) {
5664
- const normalized = value?.trim().toLowerCase();
5665
- return normalized || null;
5663
+ function assertNoLegacyStorageMode(env = process.env) {
5664
+ for (const key of LEGACY_STORAGE_MODE_KEYS) {
5665
+ if (Object.hasOwn(env, key) && env[key] !== undefined) {
5666
+ 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.`);
5667
+ }
5668
+ }
5666
5669
  }
5667
5670
  function resolveTodosCliStorageMode(env = process.env) {
5668
- for (const source of ["HASNA_TODOS_STORAGE_MODE", "TODOS_STORAGE_MODE"]) {
5669
- if (env[source] !== undefined && env[source].trim() === "") {
5670
- throw new Error(`REMOTE_STORAGE_MODE_INVALID: ${source} must not be blank; local SQLite fallback is disabled for invalid routing state`);
5671
- }
5671
+ assertNoLegacyStorageMode(env);
5672
+ const urlValue = env.HASNA_TODOS_API_URL?.trim();
5673
+ const keyValue = env.HASNA_TODOS_API_KEY?.trim();
5674
+ if (urlValue && keyValue) {
5675
+ return {
5676
+ mode: "http",
5677
+ transport: "http",
5678
+ selected: true,
5679
+ source: "HASNA_TODOS_API_URL+HASNA_TODOS_API_KEY"
5680
+ };
5672
5681
  }
5673
- const canonical = cleanMode(env.HASNA_TODOS_STORAGE_MODE);
5674
- const fallback = cleanMode(env.TODOS_STORAGE_MODE);
5675
- for (const [source, value] of [
5676
- ["HASNA_TODOS_STORAGE_MODE", canonical],
5677
- ["TODOS_STORAGE_MODE", fallback]
5678
- ]) {
5679
- if (value && !(value in TRANSPORT_TOKENS)) {
5680
- 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");
5681
- }
5682
+ if (urlValue) {
5683
+ throw new Error("REMOTE_API_KEY_MISSING: remote Todos storage requires HASNA_TODOS_API_KEY; local SQLite fallback is disabled");
5682
5684
  }
5683
- const canonicalTransport = canonical ? TRANSPORT_TOKENS[canonical] : null;
5684
- const fallbackTransport = fallback ? TRANSPORT_TOKENS[fallback] : null;
5685
- if (canonicalTransport && fallbackTransport && canonicalTransport !== fallbackTransport) {
5686
- throw new Error(`REMOTE_STORAGE_MODE_CONFLICT: HASNA_TODOS_STORAGE_MODE=${canonical} conflicts with ` + `TODOS_STORAGE_MODE=${fallback}; local SQLite fallback is disabled`);
5685
+ if (keyValue) {
5686
+ throw new Error("REMOTE_API_URL_MISSING: remote Todos storage requires HASNA_TODOS_API_URL; local SQLite fallback is disabled");
5687
5687
  }
5688
- const transport = canonicalTransport ?? fallbackTransport ?? "sqlite";
5689
5688
  return {
5690
- mode: transport,
5691
- transport,
5692
- selected: transport === "http",
5693
- source: canonical ? "HASNA_TODOS_STORAGE_MODE" : fallback ? "TODOS_STORAGE_MODE" : "default"
5689
+ mode: "sqlite",
5690
+ transport: "sqlite",
5691
+ selected: false,
5692
+ source: "default"
5694
5693
  };
5695
5694
  }
5696
5695
  function requestedTransport(env) {
@@ -5734,7 +5733,7 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
5734
5733
  return {
5735
5734
  selected: true,
5736
5735
  ok: false,
5737
- mode: cleanMode(env.HASNA_TODOS_STORAGE_MODE) ?? cleanMode(env.TODOS_STORAGE_MODE) ?? "invalid",
5736
+ mode: "invalid",
5738
5737
  api_url_configured: Boolean(env.HASNA_TODOS_API_URL?.trim()),
5739
5738
  api_key_configured: Boolean(env.HASNA_TODOS_API_KEY?.trim()),
5740
5739
  v1_base_url: null,
@@ -5780,27 +5779,12 @@ function getTodosRemoteAuthorityConfigStatus(env = process.env) {
5780
5779
  local_fallback: false
5781
5780
  };
5782
5781
  }
5783
- function serverStorageMode(normalize = normalizeStorageMode) {
5784
- const useCache = normalize === normalizeStorageMode;
5785
- if (useCache && cachedServerMode !== null)
5786
- return cachedServerMode;
5787
- for (const candidate of SERVER_MODE_CANDIDATES) {
5788
- try {
5789
- normalize(candidate);
5790
- if (useCache)
5791
- cachedServerMode = candidate;
5792
- return candidate;
5793
- } catch {}
5794
- }
5795
- 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.`);
5796
- }
5797
5782
  function requireTodosRemoteAuthorityEnv(env) {
5798
5783
  const status = getTodosRemoteAuthorityConfigStatus(env);
5799
5784
  if (!status.ok)
5800
5785
  throw new Error(status.issues[0]);
5801
5786
  return {
5802
5787
  ...env,
5803
- HASNA_TODOS_STORAGE_MODE: serverStorageMode(),
5804
5788
  HASNA_TODOS_API_URL: status.v1_base_url.replace(/\/v1$/, ""),
5805
5789
  HASNA_TODOS_API_KEY: env.HASNA_TODOS_API_KEY.trim()
5806
5790
  };
@@ -5838,7 +5822,31 @@ function classifyRemoteRequestError(baseUrl, route, error) {
5838
5822
  }
5839
5823
  throw error;
5840
5824
  }
5841
- 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) {
5842
5850
  const baseUrl = remoteAuthorityBase(client);
5843
5851
  const protect = async (route, request) => {
5844
5852
  try {
@@ -5847,25 +5855,26 @@ function protectRemoteClient(client) {
5847
5855
  return classifyRemoteRequestError(baseUrl, route, error);
5848
5856
  }
5849
5857
  };
5858
+ const bounded = (options, run) => withBoundedRemoteRequest(options, requestTimeoutMs, run);
5850
5859
  const transport = client.transport;
5851
5860
  const protectedTransport = {
5852
5861
  baseUrl: transport.baseUrl,
5853
- request: (method, path, body, options) => protect(path, () => transport.request(method, path, body, options)),
5854
- get: (path, options) => protect(path, () => transport.get(path, options)),
5855
- post: (path, body, options) => protect(path, () => transport.post(path, body, options)),
5856
- put: (path, body, options) => protect(path, () => transport.put(path, body, options)),
5857
- patch: (path, body, options) => protect(path, () => transport.patch(path, body, options)),
5858
- 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)))
5859
5868
  };
5860
5869
  return {
5861
5870
  name: client.name,
5862
5871
  baseUrl: client.baseUrl,
5863
5872
  transport: protectedTransport,
5864
- list: (resource, options) => protect(`/${resource}`, () => client.list(resource, options)),
5865
- get: (resource, id, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.get(resource, id, options)),
5866
- create: (resource, body, options) => protect(`/${resource}`, () => client.create(resource, body, options)),
5867
- update: (resource, id, patch, options) => protect(`/${resource}/${encodeURIComponent(id)}`, () => client.update(resource, id, patch, options)),
5868
- 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)))
5869
5878
  };
5870
5879
  }
5871
5880
  function remoteAuthorityBase(client) {
@@ -5886,13 +5895,19 @@ async function requiredRemoteRoute(client, route, request, recognized404Codes =
5886
5895
  throw error;
5887
5896
  }
5888
5897
  }
5889
- function getTodosCloudClient(env = process.env) {
5898
+ function getTodosCloudClient(env = process.env, requestTimeoutMs = REMOTE_REQUEST_TIMEOUT_MS) {
5890
5899
  if (requestedTransport(env) !== "http")
5891
5900
  return null;
5892
5901
  const resolved = resolveStorageClient("todos", requireTodosRemoteAuthorityEnv(env), {
5893
- fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" })
5894
- });
5895
- return resolved.transport === "cloud-http" ? protectRemoteClient(resolved.client) : null;
5902
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
5903
+ timeoutMs: requestTimeoutMs
5904
+ });
5905
+ if (resolved.transport === "cloud-http")
5906
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
5907
+ const transportName = resolved.transport;
5908
+ if (transportName === "http")
5909
+ return protectRemoteClient(resolved.client, requestTimeoutMs);
5910
+ return null;
5896
5911
  }
5897
5912
  function isCloudRouting(env = process.env) {
5898
5913
  return getTodosCloudClient(env) !== null;
@@ -7549,7 +7564,7 @@ async function cloudTimeline(client, options = {}) {
7549
7564
  const limit = options.limit ?? 50;
7550
7565
  return { entries: entries.slice(offset, offset + limit), total, limit, offset };
7551
7566
  }
7552
- var UUID_RE, TRANSPORT_TOKENS, COMPLETION_EVIDENCE_FIELDS, completionCapabilityCache, retryCapabilityCache, taskCreatorCapabilityCache, gitRefCapabilityCache, remoteCommandCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, 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;
7553
7568
  var init_cloud_router = __esm(() => {
7554
7569
  init_types();
7555
7570
  init_redaction();
@@ -7558,15 +7573,6 @@ var init_cloud_router = __esm(() => {
7558
7573
  init_adoption_validation();
7559
7574
  init_page_validation();
7560
7575
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7561
- TRANSPORT_TOKENS = {
7562
- sqlite: "sqlite",
7563
- http: "http",
7564
- local: "sqlite",
7565
- remote: "http",
7566
- self_hosted: "http",
7567
- cloud: "http",
7568
- hybrid: "http"
7569
- };
7570
7576
  COMPLETION_EVIDENCE_FIELDS = [
7571
7577
  "attachment_ids",
7572
7578
  "files_changed",
@@ -7580,7 +7586,12 @@ var init_cloud_router = __esm(() => {
7580
7586
  taskCreatorCapabilityCache = new Map;
7581
7587
  gitRefCapabilityCache = new Map;
7582
7588
  remoteCommandCapabilityCache = new Map;
7583
- SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
7589
+ LEGACY_STORAGE_MODE_KEYS = [
7590
+ "HASNA_TODOS_STORAGE_MODE",
7591
+ "HASNA_TODOS_MODE",
7592
+ "TODOS_STORAGE_MODE",
7593
+ "TODOS_MODE"
7594
+ ];
7584
7595
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
7585
7596
  listTagsCapabilityCache = new Map;
7586
7597
  PLAN_COMPLETION_PROTECTED_FIELDS = [
@@ -7601,8 +7612,10 @@ var init_cloud_router = __esm(() => {
7601
7612
  function applyTodosCliAuthorityEnvironment(authority, env = process.env) {
7602
7613
  if (authority.route !== "local" || authority.selected_by !== "local-only-command")
7603
7614
  return;
7604
- env.HASNA_TODOS_STORAGE_MODE = "sqlite";
7605
- env.TODOS_STORAGE_MODE = "sqlite";
7615
+ env.HASNA_TODOS_API_URL = "";
7616
+ env.HASNA_TODOS_API_KEY = "";
7617
+ env.TODOS_API_URL = "";
7618
+ env.TODOS_API_KEY = "";
7606
7619
  }
7607
7620
  function isTodosCliCommandVisibleForRoute(command, route, remoteCapabilities = new Set) {
7608
7621
  if (route === "local")
@@ -7846,7 +7859,7 @@ function assertInvocationRoutable(invocation) {
7846
7859
  if (command && !owner) {
7847
7860
  const suggestions = nearestCommands(command);
7848
7861
  const didYouMean = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
7849
- throw new Error(`UNKNOWN_COMMAND: \`${command}\` is not a built-in todos command on the /v1 route.${didYouMean} ` + "Run `todos --help` for the commands available here; verbs contributed by optional packages are local-only. " + "(This is not a connectivity, storage-mode or credential problem.)");
7862
+ throw new Error(`UNKNOWN_COMMAND: \`${command}\` is not a built-in todos command on the /v1 route.${didYouMean} ` + "Run `todos --help` for the commands available here; verbs contributed by optional packages are local-only. " + "(This is not a connectivity or credential problem.)");
7850
7863
  }
7851
7864
  return owner;
7852
7865
  }
@@ -7863,7 +7876,20 @@ function assertRemoteCommandSupported(invocation, owner) {
7863
7876
  }
7864
7877
  }
7865
7878
  function initializeTodosCliAuthority(args = process.argv.slice(2), env = process.env) {
7866
- const mode = resolveTodosCliStorageMode(env);
7879
+ let mode;
7880
+ try {
7881
+ mode = resolveTodosCliStorageMode(env);
7882
+ } catch (error) {
7883
+ const message = error instanceof Error ? error.message : String(error);
7884
+ if (message.includes("REMOTE_STORAGE_MODE_REMOVED"))
7885
+ throw error;
7886
+ const invocation2 = parseInvocation(args);
7887
+ if (isMetadataInvocation(args, invocation2)) {
7888
+ const status = getTodosRemoteAuthorityConfigStatus(env);
7889
+ return { route: "remote-diagnostic", v1_base_url: status.v1_base_url };
7890
+ }
7891
+ throw error;
7892
+ }
7867
7893
  if (!mode.selected)
7868
7894
  return { route: "local", v1_base_url: null };
7869
7895
  const invocation = parseInvocation(args);
@@ -7878,7 +7904,7 @@ function initializeTodosCliAuthority(args = process.argv.slice(2), env = process
7878
7904
  assertRemoteCommandSupported(invocation, owner);
7879
7905
  const client = getTodosCloudClient(env);
7880
7906
  if (!client) {
7881
- throw new Error("REMOTE_API_UNAVAILABLE: remote mode did not resolve an HTTP client; local SQLite fallback is disabled");
7907
+ throw new Error("REMOTE_API_UNAVAILABLE: HTTP routing did not resolve an authenticated /v1 client; local SQLite fallback is disabled");
7882
7908
  }
7883
7909
  return { route: "remote-http", v1_base_url: client.baseUrl };
7884
7910
  }
@@ -10038,6 +10064,10 @@ var init_migrations = __esm(() => {
10038
10064
  );
10039
10065
  INSERT OR IGNORE INTO _migrations (id) VALUES (70);
10040
10066
  COMMIT;
10067
+ `,
10068
+ `BEGIN;
10069
+ INSERT OR IGNORE INTO _migrations (id) VALUES (71);
10070
+ COMMIT;
10041
10071
  `
10042
10072
  ];
10043
10073
  });
@@ -10711,6 +10741,8 @@ function ensureSchema(db) {
10711
10741
  ensureColumn("projects", "task_list_id", "TEXT");
10712
10742
  ensureColumn("projects", "task_prefix", "TEXT");
10713
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)");
10714
10746
  ensureColumn("tasks", "plan_id", "TEXT REFERENCES plans(id) ON DELETE SET NULL");
10715
10747
  ensureColumn("tasks", "task_list_id", "TEXT REFERENCES task_lists(id) ON DELETE SET NULL");
10716
10748
  ensureColumn("tasks", "short_id", "TEXT");
@@ -12041,6 +12073,7 @@ __export(exports_config2, {
12041
12073
  TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
12042
12074
  TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
12043
12075
  STORAGE_TABLES: () => STORAGE_TABLES,
12076
+ REMOVED_STORAGE_MODE_ENV_KEYS: () => REMOVED_STORAGE_MODE_ENV_KEYS,
12044
12077
  CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV: () => CANONICAL_TODOS_RDS_RUNTIME_PATH_ENV,
12045
12078
  CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
12046
12079
  CANONICAL_TODOS_RDS_CLUSTER_ENV: () => CANONICAL_TODOS_RDS_CLUSTER_ENV
@@ -12102,25 +12135,36 @@ function assertTodosRemoteStorageConfig(config) {
12102
12135
  if (!isTodosPostgresBackend(config))
12103
12136
  return;
12104
12137
  if (!config.database?.url) {
12105
- throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
12138
+ throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when the postgresql backend is selected`);
12106
12139
  }
12107
12140
  }
12108
12141
  function parseStorageBackend(value) {
12109
12142
  const normalized = clean(value)?.toLowerCase();
12110
12143
  if (!normalized)
12111
12144
  return "sqlite";
12112
- if (normalized === "sqlite" || normalized === "postgres")
12113
- return normalized;
12114
- const legacy = LEGACY_BACKEND_TOKENS[normalized];
12115
- if (legacy)
12116
- return legacy;
12117
- throw new Error(`${TODOS_STORAGE_ENV.mode} must be sqlite or postgres (legacy values local and remote are accepted)`);
12145
+ if (normalized === "sqlite")
12146
+ return "sqlite";
12147
+ if (normalized === "postgres" || normalized === "postgresql")
12148
+ return "postgres";
12149
+ if (["local", "remote", "cloud", "hybrid", "self_hosted"].includes(normalized)) {
12150
+ 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.`);
12151
+ }
12152
+ throw new Error(`Storage backend must be sqlite or postgres`);
12118
12153
  }
12119
12154
  function parseStorageMode(value) {
12120
12155
  return parseStorageBackend(value);
12121
12156
  }
12122
12157
  function getTodosStorageBackend(env = process.env) {
12123
- return parseStorageBackend(readStorageEnv(env, "mode").value);
12158
+ for (const key of REMOVED_STORAGE_MODE_ENV_KEYS) {
12159
+ if (Object.hasOwn(env, key) && env[key] !== undefined) {
12160
+ 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.`);
12161
+ }
12162
+ }
12163
+ if (!getTodosStorageDatabaseUrl(env))
12164
+ return "sqlite";
12165
+ if (isTodosShadowEnabled(env))
12166
+ return "sqlite";
12167
+ return "postgres";
12124
12168
  }
12125
12169
  function getTodosStorageMode(env = process.env) {
12126
12170
  return getTodosStorageBackend(env);
@@ -12194,7 +12238,7 @@ function parsePositiveInteger(value, fallback) {
12194
12238
  }
12195
12239
  return parsed;
12196
12240
  }
12197
- 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;
12241
+ 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";
12198
12242
  var init_config2 = __esm(() => {
12199
12243
  TODOS_STORAGE_TABLES = [
12200
12244
  "todos_sync_records",
@@ -12202,7 +12246,6 @@ var init_config2 = __esm(() => {
12202
12246
  ];
12203
12247
  STORAGE_TABLES = TODOS_STORAGE_TABLES;
12204
12248
  TODOS_STORAGE_ENV = {
12205
- mode: "HASNA_TODOS_STORAGE_MODE",
12206
12249
  shadow: "HASNA_TODOS_SHADOW",
12207
12250
  databaseUrl: "HASNA_TODOS_DATABASE_URL",
12208
12251
  databaseSsl: "HASNA_TODOS_DATABASE_SSL",
@@ -12219,7 +12262,6 @@ var init_config2 = __esm(() => {
12219
12262
  syncDryRun: "HASNA_TODOS_SYNC_DRY_RUN"
12220
12263
  };
12221
12264
  TODOS_STORAGE_FALLBACK_ENV = {
12222
- mode: "TODOS_STORAGE_MODE",
12223
12265
  shadow: "TODOS_SHADOW",
12224
12266
  databaseUrl: "TODOS_DATABASE_URL",
12225
12267
  databaseSsl: "TODOS_DATABASE_SSL",
@@ -12235,14 +12277,12 @@ var init_config2 = __esm(() => {
12235
12277
  syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
12236
12278
  syncDryRun: "TODOS_SYNC_DRY_RUN"
12237
12279
  };
12238
- LEGACY_BACKEND_TOKENS = {
12239
- local: "sqlite",
12240
- remote: "postgres",
12241
- postgresql: "postgres",
12242
- hybrid: "postgres",
12243
- self_hosted: "postgres",
12244
- cloud: "postgres"
12245
- };
12280
+ REMOVED_STORAGE_MODE_ENV_KEYS = [
12281
+ "HASNA_TODOS_STORAGE_MODE",
12282
+ "HASNA_TODOS_MODE",
12283
+ "TODOS_STORAGE_MODE",
12284
+ "TODOS_MODE"
12285
+ ];
12246
12286
  });
12247
12287
 
12248
12288
  // src/storage/shadow-outbox-schema.ts
@@ -12774,10 +12814,12 @@ __export(exports_projects, {
12774
12814
  renameProject: () => renameProject,
12775
12815
  removeProjectSource: () => removeProjectSource,
12776
12816
  removeMachineLocalPath: () => removeMachineLocalPath,
12817
+ orderProjectsParentFirst: () => orderProjectsParentFirst,
12777
12818
  nextTaskShortId: () => nextTaskShortId,
12778
12819
  listProjects: () => listProjects,
12779
12820
  listProjectSources: () => listProjectSources,
12780
12821
  listMachineLocalPaths: () => listMachineLocalPaths,
12822
+ listChildProjects: () => listChildProjects,
12781
12823
  getProjectWithSources: () => getProjectWithSources,
12782
12824
  getProjectByPath: () => getProjectByPath,
12783
12825
  getProject: () => getProject,
@@ -12785,6 +12827,7 @@ __export(exports_projects, {
12785
12827
  ensureProject: () => ensureProject,
12786
12828
  deleteProject: () => deleteProject,
12787
12829
  createProject: () => createProject,
12830
+ assertNotProjectAncestor: () => assertNotProjectAncestor,
12788
12831
  addProjectSource: () => addProjectSource
12789
12832
  });
12790
12833
  function slugify(name) {
@@ -12816,17 +12859,24 @@ function createProject(input, db) {
12816
12859
  const id = uuid();
12817
12860
  const timestamp2 = now();
12818
12861
  const derivedSlug = slugify(input.name);
12819
- 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);
12820
12863
  if (!derivedSlug || !taskListId)
12821
12864
  throw new Error("Project name and task-list slug must be non-empty");
12822
12865
  const slugConflict = d.query("SELECT id FROM projects WHERE task_list_id = ? LIMIT 1").get(taskListId);
12823
12866
  if (slugConflict || !claimCanonicalSlug("project", "global", taskListId, id, d)) {
12824
12867
  throw new ResourceConflictError("PROJECT_SLUG_CONFLICT", `Project slug "${taskListId}" already exists`);
12825
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
+ }
12826
12876
  const taskPrefix = input.task_prefix || generatePrefix(input.name, d);
12827
12877
  const machineId = currentStorageMachineId(d);
12828
- d.run(`INSERT INTO projects (id, name, path, description, task_list_id, task_prefix, task_counter, created_at, updated_at, machine_id)
12829
- 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]);
12830
12880
  return getProject(id, d);
12831
12881
  })();
12832
12882
  }
@@ -12851,6 +12901,64 @@ function listProjects(db) {
12851
12901
  const d = db || getDatabase();
12852
12902
  return d.query("SELECT * FROM projects ORDER BY name").all();
12853
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
+ }
12854
12962
  function updateProject(id, input, db) {
12855
12963
  const d = db || getDatabase();
12856
12964
  const project = getProject(id, d);
@@ -12873,6 +12981,16 @@ function updateProject(id, input, db) {
12873
12981
  sets.push("path = ?");
12874
12982
  params.push(input.path);
12875
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
+ }
12876
12994
  params.push(id);
12877
12995
  d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
12878
12996
  return getProject(id, d);
@@ -15385,8 +15503,8 @@ var init_secret_redaction = __esm(() => {
15385
15503
  SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
15386
15504
  DEFAULT_PATTERNS = [
15387
15505
  { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
15388
- { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
15389
- { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
15506
+ { name: "github_pat", pattern: /\bgh[p]_[a-zA-Z0-9]{20,}\b/g },
15507
+ { name: "github_oauth", pattern: /\bgh[o]_[a-zA-Z0-9]{20,}\b/g },
15390
15508
  { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
15391
15509
  { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
15392
15510
  { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
@@ -15398,7 +15516,7 @@ var init_secret_redaction = __esm(() => {
15398
15516
  /example\.com/i,
15399
15517
  /your-api-key-here/i,
15400
15518
  /sk-test/i,
15401
- /ghp_xxx/i
15519
+ /gh[p]_xxx/i
15402
15520
  ];
15403
15521
  customRedactors = [];
15404
15522
  });
@@ -23137,11 +23255,14 @@ function registerTaskCommands(program2) {
23137
23255
  const narrowsAfterQuery = Boolean(opts.dueToday) || Boolean(opts.overdue) || creatorFilterActive && cloud || taskListFilterActive;
23138
23256
  const withholdLimit = requestedLimit !== undefined && (reordersAfterQuery || narrowsAfterQuery);
23139
23257
  const scanCeiling = cloud && (withholdLimit || requestedLimit === undefined) ? Math.max(requestedLimit ?? 0, listScanLimit()) : undefined;
23258
+ const probeLimit = !withholdLimit && requestedLimit !== undefined ? requestedLimit + 1 : undefined;
23140
23259
  const serverFilter = (() => {
23141
23260
  const base = withholdLimit ? (() => {
23142
23261
  const { limit: _dropped, ...rest } = filter;
23143
23262
  return rest;
23144
23263
  })() : filter;
23264
+ if (probeLimit !== undefined)
23265
+ return { ...base, limit: probeLimit };
23145
23266
  return scanCeiling === undefined ? base : { ...base, limit: scanCeiling };
23146
23267
  })();
23147
23268
  let tasks = cloud ? await cloudListTasks(cloud, serverFilter) : listTasks(serverFilter);
@@ -23190,8 +23311,21 @@ function registerTaskCommands(program2) {
23190
23311
  return 0;
23191
23312
  });
23192
23313
  }
23193
- if (withholdLimit && requestedLimit !== undefined)
23194
- 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
+ }
23195
23329
  if (rosterPromise && assignedFilter) {
23196
23330
  try {
23197
23331
  const roster = await rosterPromise;
@@ -25291,7 +25425,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
25291
25425
  }
25292
25426
  }
25293
25427
  };
25294
- applyRows("projects", "projects", PROJECT_COLUMNS, snapshot.projects, "updated_at");
25428
+ applyRows("projects", "projects", PROJECT_COLUMNS, orderProjectsParentFirst(snapshot.projects), "updated_at");
25295
25429
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
25296
25430
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
25297
25431
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
@@ -25522,6 +25656,7 @@ var init_sqlite_snapshot = __esm(() => {
25522
25656
  "task_list_id",
25523
25657
  "task_prefix",
25524
25658
  "task_counter",
25659
+ "parent_id",
25525
25660
  "created_at",
25526
25661
  "updated_at",
25527
25662
  "machine_id",
@@ -28526,7 +28661,7 @@ function addSourceOnce(projectId, type, name, uri, metadata, db) {
28526
28661
  function bootstrapProject(options = {}, db) {
28527
28662
  const d = db || getDatabase();
28528
28663
  const discovery = discoverProjectWorkspace(options.path);
28529
- const taskListSlug = options.taskListSlug || `todos-${slugify(options.name || discovery.projectName)}`;
28664
+ const taskListSlug = options.taskListSlug || slugify(options.name || discovery.projectName);
28530
28665
  if (options.dryRun) {
28531
28666
  return {
28532
28667
  dryRun: true,
@@ -30896,7 +31031,7 @@ function registerProjectCommands(program2) {
30896
31031
  }
30897
31032
  }
30898
31033
  });
30899
- 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) => {
30900
31035
  const globalOpts = program2.opts();
30901
31036
  const cloud = getTodosCloudClient();
30902
31037
  if (opts.ensureTaskList && opts.rollbackTaskList) {
@@ -31013,10 +31148,21 @@ function registerProjectCommands(program2) {
31013
31148
  if (opts.add) {
31014
31149
  const projectPath = resolve13(opts.add);
31015
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
+ }
31016
31156
  const existing = cloud ? (await cloudListProjects(cloud)).find((project2) => project2.path === projectPath) : getProjectByPath(projectPath);
31017
31157
  let project;
31018
31158
  if (existing) {
31019
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
+ }
31020
31166
  if (opts.taskListId) {
31021
31167
  if (cloud && existing.task_list_id !== opts.taskListId) {
31022
31168
  handleError(new Error("Remote project task-list slug changes require project-rename"));
@@ -31025,7 +31171,7 @@ function registerProjectCommands(program2) {
31025
31171
  project = renameProject(existing.id, { new_slug: opts.taskListId }).project;
31026
31172
  }
31027
31173
  } else {
31028
- 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 };
31029
31175
  project = cloud ? await cloudCreateProject(cloud, input) : createProject(input);
31030
31176
  }
31031
31177
  if (!cloud) {
@@ -31042,6 +31188,8 @@ function registerProjectCommands(program2) {
31042
31188
  console.log(chalk5.green(`Project registered: ${project.name} (${project.path})`));
31043
31189
  if (project.task_list_id)
31044
31190
  console.log(chalk5.dim(` Task list: ${project.task_list_id}`));
31191
+ if (parentId)
31192
+ console.log(chalk5.dim(` Parent: ${parentId}`));
31045
31193
  }
31046
31194
  return;
31047
31195
  }
@@ -32533,8 +32681,12 @@ class PostgresJsonRecordStore {
32533
32681
  OR ($11::text <> $2
32534
32682
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
32535
32683
  AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
32536
- (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
32537
- ($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,
32538
32690
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
32539
32691
  ), guarded AS (
32540
32692
  SELECT
@@ -32557,7 +32709,6 @@ class PostgresJsonRecordStore {
32557
32709
  AND guarded.version_matches
32558
32710
  AND guarded.parent_found
32559
32711
  AND guarded.parent_acyclic
32560
- AND guarded.all_plans_found
32561
32712
  AND guarded.target_plan_found
32562
32713
  AND NOT guarded.project_conflict
32563
32714
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
@@ -32573,7 +32724,7 @@ class PostgresJsonRecordStore {
32573
32724
  RETURNING payload
32574
32725
  )
32575
32726
  SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
32576
- guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
32727
+ guarded.membership_changed, guarded.target_plan_found, guarded.project_conflict,
32577
32728
  (SELECT payload FROM stored) AS payload,
32578
32729
  (SELECT payload FROM locked_task) AS current_payload
32579
32730
  FROM guarded`, [
@@ -32605,7 +32756,7 @@ class PostgresJsonRecordStore {
32605
32756
  if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
32606
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`);
32607
32758
  }
32608
- if (!row?.all_plans_found || !row.target_plan_found) {
32759
+ if (!row?.target_plan_found) {
32609
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 });
32610
32761
  }
32611
32762
  if (row.project_conflict) {
@@ -33814,17 +33965,26 @@ async function getChangedSince(since, filters, store) {
33814
33965
  async function createProject2(input, store, context) {
33815
33966
  const timestamp2 = new Date().toISOString();
33816
33967
  const derivedSlug = slugifyRaw(input.name);
33817
- 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);
33818
33969
  if (!derivedSlug || !taskListId)
33819
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
+ }
33820
33979
  const project = {
33821
- id: randomUUID4(),
33980
+ id,
33822
33981
  name: input.name,
33823
33982
  path: input.path,
33824
33983
  description: input.description ?? null,
33825
33984
  task_list_id: taskListId,
33826
33985
  task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
33827
33986
  task_counter: 0,
33987
+ parent_id: parentId,
33828
33988
  created_at: timestamp2,
33829
33989
  updated_at: timestamp2,
33830
33990
  machine_id: store.machineId(context),
@@ -33832,12 +33992,38 @@ async function createProject2(input, store, context) {
33832
33992
  };
33833
33993
  return store.upsert("projects", project, context);
33834
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
+ }
33835
34010
  async function updateProject2(id, input, store) {
33836
34011
  if ("task_list_id" in input) {
33837
34012
  throw new Error("task_list_id cannot be changed by updateProject; use renameProject for an atomic canonical rename");
33838
34013
  }
33839
34014
  const project = await requireRecord("projects", id, store);
33840
- 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
+ };
33841
34027
  return store.upsert("projects", updated);
33842
34028
  }
33843
34029
  async function createPlan2(input, store, context) {
@@ -35229,7 +35415,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35229
35415
  }
35230
35416
  async createProject(input) {
35231
35417
  const derivedSlug = normalizeSlug(input.name);
35232
- 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);
35233
35419
  if (!derivedSlug || !taskListId) {
35234
35420
  throw new Error("Project name and task-list slug must be non-empty");
35235
35421
  }
@@ -35241,6 +35427,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35241
35427
  task_list_id: taskListId,
35242
35428
  task_prefix: input.task_prefix ?? this.availableProjectPrefix(input.name),
35243
35429
  task_counter: 0,
35430
+ parent_id: input.parent_id ?? null,
35244
35431
  created_at: now(),
35245
35432
  updated_at: now(),
35246
35433
  machine_id: currentStorageMachineId(this.db)
@@ -35251,14 +35438,15 @@ class StagedSqliteTodosProjectRegistrationTransaction {
35251
35438
  try {
35252
35439
  const result = this.db.run(`INSERT INTO projects (
35253
35440
  id, name, path, description, task_list_id, task_prefix,
35254
- task_counter, created_at, updated_at, machine_id
35255
- ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`, [
35441
+ task_counter, parent_id, created_at, updated_at, machine_id
35442
+ ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [
35256
35443
  project.id,
35257
35444
  project.name,
35258
35445
  project.path,
35259
35446
  project.description,
35260
35447
  project.task_list_id,
35261
35448
  project.task_prefix,
35449
+ project.parent_id,
35262
35450
  project.created_at,
35263
35451
  project.updated_at,
35264
35452
  project.machine_id ?? null
@@ -35655,7 +35843,10 @@ function taskListSlug(projectSlug) {
35655
35843
  if (!slug || slug !== projectSlug) {
35656
35844
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "project_slug must be canonical kebab-case");
35657
35845
  }
35658
- return `todos-${slug}`;
35846
+ return slug;
35847
+ }
35848
+ function legacyTaskListSlug(projectSlug) {
35849
+ return `todos-${normalizeSlug(projectSlug)}`;
35659
35850
  }
35660
35851
  function deterministicTaskPrefix(projectSlug) {
35661
35852
  const letters = projectSlug.replace(/[^a-z0-9]/gi, "").toUpperCase();
@@ -36174,6 +36365,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36174
36365
  created_by_operation: false
36175
36366
  };
36176
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
+ }
36177
36374
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
36178
36375
  }
36179
36376
  await this.fault("before_object_write", request);
@@ -36210,6 +36407,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36210
36407
  }
36211
36408
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
36212
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
+ }
36213
36419
  await this.fault("before_object_write", request);
36214
36420
  const taskList = await transaction.createTaskList({
36215
36421
  name: request.project_name,
@@ -51899,7 +52105,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51899
52105
  path: { type: "string", minLength: 1 },
51900
52106
  description: { type: "string" },
51901
52107
  task_list_id: { type: "string", minLength: 1, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
51902
- task_prefix: { type: "string", minLength: 1 }
52108
+ task_prefix: { type: "string", minLength: 1 },
52109
+ parent_id: { type: "string", minLength: 1 }
51903
52110
  }
51904
52111
  },
51905
52112
  UpdateProjectInput: {
@@ -51909,7 +52116,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
51909
52116
  properties: {
51910
52117
  name: { type: "string", minLength: 1 },
51911
52118
  path: { type: "string", minLength: 1 },
51912
- description: { type: "string", nullable: true }
52119
+ description: { type: "string", nullable: true },
52120
+ parent_id: { type: "string", minLength: 1, nullable: true }
51913
52121
  }
51914
52122
  },
51915
52123
  RenameProjectInput: {
@@ -54208,6 +54416,7 @@ var init_openapi = __esm(() => {
54208
54416
  task_list_id: { type: "string", nullable: true },
54209
54417
  task_prefix: { type: "string", nullable: true },
54210
54418
  task_counter: { type: "number" },
54419
+ parent_id: { type: "string", nullable: true },
54211
54420
  created_at: { type: "string" },
54212
54421
  updated_at: { type: "string" }
54213
54422
  }
@@ -55278,7 +55487,7 @@ function validateProjectPatch(value) {
55278
55487
  if (!value || typeof value !== "object" || Array.isArray(value))
55279
55488
  return { ok: false, message: "project patch must be an object" };
55280
55489
  const body2 = value;
55281
- const allowed = new Set(["name", "path", "description"]);
55490
+ const allowed = new Set(["name", "path", "description", "parent_id"]);
55282
55491
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
55283
55492
  if (unknown)
55284
55493
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -55290,13 +55499,15 @@ function validateProjectPatch(value) {
55290
55499
  return { ok: false, message: "path must be a non-empty string" };
55291
55500
  if (body2["description"] !== undefined && body2["description"] !== null && typeof body2["description"] !== "string")
55292
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" };
55293
55504
  return { ok: true, patch: body2 };
55294
55505
  }
55295
55506
  function validateProjectCreate(value) {
55296
55507
  if (!value || typeof value !== "object" || Array.isArray(value))
55297
55508
  return { ok: false, message: "project body must be an object" };
55298
55509
  const body2 = value;
55299
- 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"]);
55300
55511
  const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
55301
55512
  if (unknown)
55302
55513
  return { ok: false, message: `unknown project field: ${unknown}` };
@@ -55314,6 +55525,9 @@ function validateProjectCreate(value) {
55314
55525
  if (body2["task_prefix"] !== undefined && (typeof body2["task_prefix"] !== "string" || !body2["task_prefix"].trim())) {
55315
55526
  return { ok: false, message: "task_prefix must be a non-empty string" };
55316
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
+ }
55317
55531
  return { ok: true, input: body2 };
55318
55532
  }
55319
55533
  function validatePlanCreate(value) {
@@ -62029,6 +62243,7 @@ function registerTaskProjectTools(server, ctx) {
62029
62243
  name: exports_external.string().describe("Project name"),
62030
62244
  path: exports_external.string().describe("Unique filesystem path for the project"),
62031
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"),
62032
62247
  status: exports_external.enum(["active", "completed", "on_hold", "archived"]).optional(),
62033
62248
  short_id: exports_external.string().nullable().optional().describe("Short ID (auto-generated if omitted)"),
62034
62249
  metadata: exports_external.record(exports_external.unknown()).optional()
@@ -69579,6 +69794,7 @@ function createAgentProjectDemoBundle() {
69579
69794
  task_list_id: ids.list,
69580
69795
  task_prefix: "DEMO",
69581
69796
  task_counter: 4,
69797
+ parent_id: null,
69582
69798
  created_at: createdAt,
69583
69799
  updated_at: completedAt
69584
69800
  });
@@ -82446,7 +82662,7 @@ No task claimed (nothing available).`));
82446
82662
  verdict_exit_code: verdict2.verdict,
82447
82663
  fail_on_findings: verdict2.fail_on_findings,
82448
82664
  dry_run: true,
82449
- mode: "remote-http",
82665
+ mode: "http",
82450
82666
  authority: { v1_base_url: cloud.baseUrl, local_fallback: false },
82451
82667
  routes: {
82452
82668
  stats: true,
@@ -82668,7 +82884,7 @@ Findings`));
82668
82884
  checks.push({ name: "Version", ok: true, message: `v${getPackageVersion3()} \xB7 remote HTTP client` });
82669
82885
  const ok = checks.every((check) => check.ok);
82670
82886
  if (opts.json || globalOpts.json) {
82671
- console.log(JSON.stringify({ ok, mode: "remote-http", checks }));
82887
+ console.log(JSON.stringify({ ok, mode: "http", checks }));
82672
82888
  } else {
82673
82889
  console.log(chalk9.bold(`todos health
82674
82890
  `));
@@ -89328,6 +89544,7 @@ var init_json_contracts = __esm(() => {
89328
89544
  task_list_id: nullableIdField,
89329
89545
  task_prefix: field(["string", "null"], "Optional task prefix.", true),
89330
89546
  task_counter: field("integer", "Monotonic project task counter."),
89547
+ parent_id: field(["string", "null"], "Optional parent project id; null means top-level.", true),
89331
89548
  created_at: isoDateField,
89332
89549
  updated_at: isoDateField
89333
89550
  },
@@ -93394,7 +93611,7 @@ function getNativeStorageStatus(env = process.env) {
93394
93611
  const remoteEnabled = isTodosPostgresBackend(config);
93395
93612
  const remoteFieldsConfigured = Boolean(config.database || config.objectStorage);
93396
93613
  if (!remoteEnabled && remoteFieldsConfigured) {
93397
- warnings.push(`the sqlite backend (${TODOS_STORAGE_ENV.mode}) ignores configured remote storage fields`);
93614
+ warnings.push(`the sqlite backend ignores configured remote storage fields`);
93398
93615
  }
93399
93616
  if (remoteEnabled && !config.objectStorage) {
93400
93617
  warnings.push(`${TODOS_STORAGE_ENV.s3Bucket} is not configured, so artifact sync will stay local`);
@@ -93487,7 +93704,7 @@ function storageEnvStatus(env) {
93487
93704
  function fallbackConfig(env) {
93488
93705
  let mode;
93489
93706
  try {
93490
- mode = parseStorageBackend(clean2(env[TODOS_STORAGE_ENV.mode]));
93707
+ mode = getTodosStorageBackend(env);
93491
93708
  } catch {
93492
93709
  mode = "sqlite";
93493
93710
  }