@hasna/todos 0.15.15 → 0.15.17

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.15",
2126
+ version: "0.15.17",
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",
@@ -5805,10 +5805,72 @@ async function requireTagsFilterCapability(client) {
5805
5805
  throw new Error(`REMOTE_TAGS_FILTER_UNSUPPORTED: configured Todos authority ${authority} does not advertise the tags ` + "query param on GET /v1/tasks; deploy the current @hasna/todos /v1 server to filter by tag; " + "no unfiltered task read was issued");
5806
5806
  }
5807
5807
  }
5808
- async function requestCloudTaskPage(client, filter) {
5808
+ function parseCloudTaskTotal(raw) {
5809
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
5810
+ return;
5811
+ const total = raw.total;
5812
+ return typeof total === "number" && Number.isSafeInteger(total) && total >= 0 ? total : undefined;
5813
+ }
5814
+ async function requestRawCloudTaskPage(client, filter) {
5809
5815
  const res = await requiredRemoteRoute(client, "/v1/tasks", () => client.list("tasks", { query: toListQuery(filter) }));
5810
5816
  const envelope = res.raw;
5811
- return Array.isArray(envelope?.tasks) ? envelope.tasks : res.items;
5817
+ return {
5818
+ tasks: Array.isArray(envelope?.tasks) ? envelope.tasks : res.items,
5819
+ total: parseCloudTaskTotal(res.raw)
5820
+ };
5821
+ }
5822
+ function cloudTaskListFilterError(code, taskListId, detail) {
5823
+ return new Error(`${code}: hosted authority returned rows outside requested task_list_id ${taskListId}; ${detail}; refusing an incomplete exact-list result`);
5824
+ }
5825
+ async function requestCloudTaskPage(client, filter) {
5826
+ const firstPage = await requestRawCloudTaskPage(client, filter);
5827
+ const tasks = firstPage.tasks;
5828
+ if (filter.task_list_id === undefined)
5829
+ return tasks;
5830
+ const taskListId = filter.task_list_id;
5831
+ if (tasks.every((task) => task.task_list_id === taskListId))
5832
+ return tasks;
5833
+ const scanLimit = filter.limit;
5834
+ const startOffset = filter.offset ?? 0;
5835
+ if (startOffset !== 0 || scanLimit === undefined || !Number.isSafeInteger(scanLimit) || scanLimit <= 0 || firstPage.total === undefined) {
5836
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, "the response does not provide complete bounded total/offset pagination evidence");
5837
+ }
5838
+ const total = firstPage.total;
5839
+ if (total > scanLimit) {
5840
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_INCOMPLETE", taskListId, `reported total ${total} exceeds bounded scan limit ${scanLimit}`);
5841
+ }
5842
+ if (tasks.length > total) {
5843
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, `the first page contains ${tasks.length} rows but reports total ${total}`);
5844
+ }
5845
+ const seenTaskIds = new Set;
5846
+ for (const task of tasks) {
5847
+ if (seenTaskIds.has(task.id)) {
5848
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, `the first page repeats task id ${task.id}`);
5849
+ }
5850
+ seenTaskIds.add(task.id);
5851
+ }
5852
+ while (tasks.length < total) {
5853
+ const remaining = total - tasks.length;
5854
+ const page = await requestRawCloudTaskPage(client, {
5855
+ ...filter,
5856
+ offset: tasks.length,
5857
+ limit: Math.min(scanLimit - tasks.length, remaining)
5858
+ });
5859
+ if (page.total !== total) {
5860
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, `pagination total changed from ${total} to ${String(page.total)}`);
5861
+ }
5862
+ if (page.tasks.length === 0 || tasks.length + page.tasks.length > total) {
5863
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, "pagination did not make bounded progress toward the reported total");
5864
+ }
5865
+ for (const task of page.tasks) {
5866
+ if (seenTaskIds.has(task.id)) {
5867
+ throw cloudTaskListFilterError("REMOTE_TASK_LIST_FILTER_UNSUPPORTED", taskListId, `pagination repeats task id ${task.id}`);
5868
+ }
5869
+ seenTaskIds.add(task.id);
5870
+ }
5871
+ tasks.push(...page.tasks);
5872
+ }
5873
+ return tasks.filter((task) => task.task_list_id === taskListId);
5812
5874
  }
5813
5875
  async function cloudListTasks(client, filter = {}) {
5814
5876
  if (filter.tags?.length)
@@ -5873,12 +5935,10 @@ async function cloudGetTask(client, id) {
5873
5935
  }
5874
5936
  async function cloudCreateTask(client, input) {
5875
5937
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
5876
- const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => expectedParentId === null ? client.create("tasks", input) : client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
5938
+ const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
5877
5939
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
5878
5940
  throw new Error(`REMOTE_API_INCOMPATIBLE: configured Todos authority ${remoteAuthorityBase(client)} returned a task create ` + "response without a stored task id; no success row or local SQLite fallback is permitted");
5879
5941
  }
5880
- if (expectedParentId === null)
5881
- return created;
5882
5942
  const persisted = await cloudGetTask(client, created.id);
5883
5943
  if (!persisted || persisted.id !== created.id || (persisted.parent_id ?? null) !== expectedParentId) {
5884
5944
  throw new Error(`TASK_CREATE_PERSISTENCE_UNVERIFIED: configured Todos authority ${remoteAuthorityBase(client)} accepted ` + `POST /v1/tasks but authoritative GET /v1/tasks/${encodeURIComponent(created.id)} did not return the same ` + "stored task id and parent_id; no success row or local SQLite fallback is permitted");
@@ -5944,6 +6004,23 @@ async function cloudTaskAction(client, id, action, body = {}) {
5944
6004
  const raw = await client.transport.post(`/tasks/${encodeURIComponent(id)}/${action}`, body);
5945
6005
  return unwrapTask(raw);
5946
6006
  }
6007
+ async function cloudFailTask(client, id, body = {}) {
6008
+ const route = `/v1/tasks/${encodeURIComponent(id)}/fail`;
6009
+ const raw = await requiredRemoteRoute(client, route, () => client.transport.post(`/tasks/${encodeURIComponent(id)}/fail`, body));
6010
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
6011
+ throw new Error(`REMOTE_API_INCOMPATIBLE: ${route} returned an invalid failure response envelope`);
6012
+ }
6013
+ const result = raw["result"];
6014
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
6015
+ throw new Error(`REMOTE_API_INCOMPATIBLE: ${route} did not return result.task`);
6016
+ }
6017
+ const task = result["task"];
6018
+ const retryTask = result["retryTask"];
6019
+ if (!task || typeof task !== "object" || Array.isArray(task) || retryTask !== undefined && (!retryTask || typeof retryTask !== "object" || Array.isArray(retryTask))) {
6020
+ throw new Error(`REMOTE_API_INCOMPATIBLE: ${route} returned an invalid failure result`);
6021
+ }
6022
+ return result;
6023
+ }
5947
6024
  function resolveOpenApiSchema(document, schema) {
5948
6025
  if (!schema || typeof schema !== "object" || Array.isArray(schema))
5949
6026
  return null;
@@ -7469,6 +7546,7 @@ var init_stage_a = __esm(() => {
7469
7546
  "delegate",
7470
7547
  "delete",
7471
7548
  "deps",
7549
+ "fail",
7472
7550
  "doctor",
7473
7551
  "done",
7474
7552
  "find-commit",
@@ -16520,8 +16598,8 @@ function failTask(id, agentId, reason, options, db) {
16520
16598
  const safeMeta = sanitizePreWriteValue(meta, "task.failure.metadata");
16521
16599
  const timestamp2 = now();
16522
16600
  const failTx = d.transaction(() => {
16523
- const res = d.run(`UPDATE tasks SET status = 'failed', locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
16524
- WHERE id = ? AND version = ?`, [JSON.stringify(safeMeta), timestamp2, id, task.version]);
16601
+ const res = d.run(`UPDATE tasks SET status = 'failed', reason = ?, locked_by = NULL, locked_at = NULL, metadata = ?, version = version + 1, updated_at = ?
16602
+ WHERE id = ? AND version = ?`, [safeReason, JSON.stringify(safeMeta), timestamp2, id, task.version]);
16525
16603
  if (res.changes === 0) {
16526
16604
  const current = getTask(id, d);
16527
16605
  throw new VersionConflictError(id, task.version, current?.version ?? -1);
@@ -16533,6 +16611,7 @@ function failTask(id, agentId, reason, options, db) {
16533
16611
  status: "failed",
16534
16612
  locked_by: null,
16535
16613
  locked_at: null,
16614
+ reason: safeReason,
16536
16615
  metadata: safeMeta,
16537
16616
  version: task.version + 1,
16538
16617
  updated_at: timestamp2
@@ -21519,6 +21598,10 @@ function formatHumanComment(comment) {
21519
21598
  const agent = comment.agent_id ? chalk3.cyan(`[${escapeTerminalControls(comment.agent_id)}] `) : "";
21520
21599
  return ` ${agent}${chalk3.dim(escapeTerminalControls(comment.created_at))}: ${escapeTerminalControls(comment.content)}`;
21521
21600
  }
21601
+ function printTaskCreatedReceipt(task) {
21602
+ console.log(chalk3.green(`Task created: ${escapeTerminalControls(task.id)}`));
21603
+ console.log(formatTaskLine(task));
21604
+ }
21522
21605
  async function cloudDetailRelations(cloud, id) {
21523
21606
  try {
21524
21607
  return await cloudGetTaskRelations(cloud, id);
@@ -21834,8 +21917,7 @@ function registerTaskCommands(program2) {
21834
21917
  if (globalOpts.json) {
21835
21918
  output(task3, true);
21836
21919
  } else {
21837
- console.log(chalk3.green("Task created:"));
21838
- console.log(formatTaskLine(task3));
21920
+ printTaskCreatedReceipt(task3);
21839
21921
  }
21840
21922
  return;
21841
21923
  }
@@ -21882,8 +21964,7 @@ function registerTaskCommands(program2) {
21882
21964
  if (globalOpts.json) {
21883
21965
  output(task2, true);
21884
21966
  } else {
21885
- console.log(chalk3.green("Task created:"));
21886
- console.log(formatTaskLine(task2));
21967
+ printTaskCreatedReceipt(task2);
21887
21968
  }
21888
21969
  });
21889
21970
  const task = program2.command("task").description("Task subcommands for deterministic automation");
@@ -43364,6 +43445,22 @@ class PostgresTodosTaskManifestBackend {
43364
43445
  async markOutboxDelivered(outboxId, deliveredAt) {
43365
43446
  await this.ensureSchema();
43366
43447
  await this.client.transaction(async (tx) => {
43448
+ const owned = await tx.query(`SELECT r.operation_id
43449
+ FROM todos_task_manifest_outbox o
43450
+ JOIN todos_task_manifest_receipts r
43451
+ ON r.receipt_id = o.apply_receipt_id
43452
+ WHERE r.tenant_id = $1
43453
+ AND r.authority = 'todos'
43454
+ AND r.route = 'todos.task-manifest.v1'
43455
+ AND r.schema_version = 1
43456
+ AND r.kind = 'apply'
43457
+ AND o.id = $2
43458
+ LIMIT 1`, [this.tenantId, outboxId]);
43459
+ const operationId = owned.rows[0]?.operation_id;
43460
+ if (operationId == null) {
43461
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
43462
+ }
43463
+ await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${String(operationId)}`]);
43367
43464
  const result = await tx.query(`UPDATE todos_task_manifest_outbox
43368
43465
  SET status = 'delivered', delivered_at = $1, attempts = attempts + 1
43369
43466
  WHERE id = $2 AND status = 'pending'
@@ -43371,10 +43468,28 @@ class PostgresTodosTaskManifestBackend {
43371
43468
  SELECT 1 FROM todos_task_manifest_receipts r
43372
43469
  WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
43373
43470
  AND r.tenant_id = $3
43471
+ AND r.authority = 'todos'
43472
+ AND r.route = 'todos.task-manifest.v1'
43473
+ AND r.schema_version = 1
43474
+ AND r.kind = 'apply'
43374
43475
  )
43375
43476
  RETURNING id`, [deliveredAt, outboxId, this.tenantId]);
43376
- if (!result.rows[0])
43377
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
43477
+ if (result.rows[0])
43478
+ return;
43479
+ const existing = await tx.query(`SELECT o.status
43480
+ FROM todos_task_manifest_outbox o
43481
+ JOIN todos_task_manifest_receipts r
43482
+ ON r.receipt_id = o.apply_receipt_id
43483
+ WHERE r.tenant_id = $1
43484
+ AND r.authority = 'todos'
43485
+ AND r.route = 'todos.task-manifest.v1'
43486
+ AND r.schema_version = 1
43487
+ AND r.kind = 'apply'
43488
+ AND o.id = $2
43489
+ LIMIT 1`, [this.tenantId, outboxId]);
43490
+ if (existing.rows[0]?.status === "delivered")
43491
+ return;
43492
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Pending outbox row not found: ${outboxId}`);
43378
43493
  });
43379
43494
  }
43380
43495
  async compensate(input, receipt, compensationReceiptId, requestDigest, now4) {
@@ -43406,7 +43521,13 @@ class PostgresTodosTaskManifestBackend {
43406
43521
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
43407
43522
  const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
43408
43523
  JOIN todos_task_manifest_receipts r ON r.receipt_id = o.apply_receipt_id
43409
- WHERE r.tenant_id = $1 AND o.apply_receipt_id = $2 AND o.status = 'delivered'
43524
+ WHERE r.tenant_id = $1
43525
+ AND r.authority = 'todos'
43526
+ AND r.route = 'todos.task-manifest.v1'
43527
+ AND r.schema_version = 1
43528
+ AND r.kind = 'apply'
43529
+ AND o.apply_receipt_id = $2
43530
+ AND o.status = 'delivered'
43410
43531
  LIMIT 1`, [this.tenantId, input.receipt_id]);
43411
43532
  if (delivered.rows[0])
43412
43533
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
@@ -43419,15 +43540,20 @@ class PostgresTodosTaskManifestBackend {
43419
43540
  },
43420
43541
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
43421
43542
  ];
43422
- const outboxRows = await tx.query(`SELECT id, topic, payload, payload_digest, status, attempts, delivered_at
43423
- FROM todos_task_manifest_outbox
43424
- WHERE apply_receipt_id = $1
43543
+ const outboxRows = await tx.query(`SELECT o.id, o.topic, o.payload, o.payload_digest, o.status, o.attempts, o.delivered_at
43544
+ FROM todos_task_manifest_outbox o
43545
+ WHERE o.apply_receipt_id = $1
43425
43546
  AND EXISTS (
43426
43547
  SELECT 1 FROM todos_task_manifest_receipts r
43427
- WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
43548
+ WHERE r.receipt_id = o.apply_receipt_id
43428
43549
  AND r.tenant_id = $2
43550
+ AND r.authority = 'todos'
43551
+ AND r.route = 'todos.task-manifest.v1'
43552
+ AND r.schema_version = 1
43553
+ AND r.kind = 'apply'
43429
43554
  )
43430
- ORDER BY id`, [input.receipt_id, this.tenantId]);
43555
+ ORDER BY o.id
43556
+ FOR UPDATE OF o`, [input.receipt_id, this.tenantId]);
43431
43557
  if (outboxRows.rows.length !== expectedEffects.length) {
43432
43558
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: outbox changed since apply");
43433
43559
  }
@@ -43527,14 +43653,23 @@ class PostgresTodosTaskManifestBackend {
43527
43653
  }
43528
43654
  if (stored.rows.length !== managedIds.length)
43529
43655
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: managed graph is incomplete");
43530
- await tx.query(`UPDATE todos_task_manifest_outbox
43656
+ const cancelled = await tx.query(`UPDATE todos_task_manifest_outbox
43531
43657
  SET status = 'cancelled'
43532
43658
  WHERE apply_receipt_id = $1 AND status = 'pending'
43533
43659
  AND EXISTS (
43534
43660
  SELECT 1 FROM todos_task_manifest_receipts r
43535
43661
  WHERE r.receipt_id = todos_task_manifest_outbox.apply_receipt_id
43536
43662
  AND r.tenant_id = $2
43537
- )`, [input.receipt_id, this.tenantId]);
43663
+ AND r.authority = 'todos'
43664
+ AND r.route = 'todos.task-manifest.v1'
43665
+ AND r.schema_version = 1
43666
+ AND r.kind = 'apply'
43667
+ )
43668
+ RETURNING id`, [input.receipt_id, this.tenantId]);
43669
+ const cancelledIds = new Set(cancelled.rows.map((row) => String(row.id)));
43670
+ if (cancelledIds.size !== applyResult.outbox_ids.length || applyResult.outbox_ids.some((id) => !cancelledIds.has(id))) {
43671
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: failed to cancel every expected outbox row");
43672
+ }
43538
43673
  const typedIds = [
43539
43674
  ["dependencies", applyResult.graph.dependency_ids],
43540
43675
  ["comments", applyResult.graph.comment_ids],
@@ -43717,6 +43852,7 @@ class PackageOwnedTodosTaskManifestAuthority {
43717
43852
  deterministic_ids: true,
43718
43853
  immutable_receipts: true,
43719
43854
  transactional_outbox: true,
43855
+ idempotent_outbox_delivery: true,
43720
43856
  exact_bounded_readback: true,
43721
43857
  conditional_compensation: true,
43722
43858
  transcript_safe: false,
@@ -46166,8 +46302,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
46166
46302
  properties: {
46167
46303
  title: { type: "string" },
46168
46304
  description: { type: "string", nullable: true },
46169
- status: { type: "string" },
46170
- priority: { type: "string" },
46305
+ status: { type: "string", enum: [...TASK_STATUSES] },
46306
+ priority: { type: "string", enum: [...TASK_PRIORITIES] },
46171
46307
  project_id: { type: "string" },
46172
46308
  parent_id: { type: "string" },
46173
46309
  plan_id: { type: "string" },
@@ -46181,8 +46317,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
46181
46317
  properties: {
46182
46318
  title: { type: "string" },
46183
46319
  description: { type: "string" },
46184
- status: { type: "string" },
46185
- priority: { type: "string" },
46320
+ status: { type: "string", enum: [...TASK_STATUSES] },
46321
+ priority: { type: "string", enum: [...TASK_PRIORITIES] },
46186
46322
  assigned_to: { type: "string" },
46187
46323
  project_id: { type: "string", nullable: true },
46188
46324
  plan_id: { type: "string", nullable: true },
@@ -46203,6 +46339,24 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
46203
46339
  confidence: { type: "number", minimum: 0, maximum: 1 }
46204
46340
  }
46205
46341
  },
46342
+ FailTaskInput: {
46343
+ type: "object",
46344
+ additionalProperties: false,
46345
+ properties: {
46346
+ agent_id: { type: "string", minLength: 1 },
46347
+ reason: { type: "string" },
46348
+ retry: { type: "boolean" }
46349
+ }
46350
+ },
46351
+ TaskFailureResult: {
46352
+ type: "object",
46353
+ additionalProperties: false,
46354
+ required: ["task"],
46355
+ properties: {
46356
+ task: { $ref: "#/components/schemas/Task" },
46357
+ retryTask: { $ref: "#/components/schemas/Task" }
46358
+ }
46359
+ },
46206
46360
  CreateProjectInput: {
46207
46361
  type: "object",
46208
46362
  additionalProperties: false,
@@ -47446,6 +47600,30 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
47446
47600
  responses: { "200": { content: { "application/json": { schema: { type: "object", properties: { task: { $ref: "#/components/schemas/Task" } } } } } } }
47447
47601
  }
47448
47602
  },
47603
+ "/v1/tasks/{id}/fail": {
47604
+ post: {
47605
+ operationId: "failTask",
47606
+ summary: "Fail a task with an optional reason and retry copy",
47607
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
47608
+ requestBody: {
47609
+ required: false,
47610
+ content: { "application/json": { schema: { $ref: "#/components/schemas/FailTaskInput" } } }
47611
+ },
47612
+ responses: {
47613
+ "200": {
47614
+ content: {
47615
+ "application/json": {
47616
+ schema: {
47617
+ type: "object",
47618
+ required: ["result"],
47619
+ properties: { result: { $ref: "#/components/schemas/TaskFailureResult" } }
47620
+ }
47621
+ }
47622
+ }
47623
+ }
47624
+ }
47625
+ }
47626
+ },
47449
47627
  "/v1/projects": {
47450
47628
  get: {
47451
47629
  operationId: "listProjects",
@@ -47887,12 +48065,13 @@ var init_openapi = __esm(() => {
47887
48065
  id: { type: "string" },
47888
48066
  title: { type: "string" },
47889
48067
  description: { type: "string" },
47890
- status: { type: "string" },
47891
- priority: { type: "string" },
48068
+ status: { type: "string", enum: [...TASK_STATUSES] },
48069
+ priority: { type: "string", enum: [...TASK_PRIORITIES] },
47892
48070
  project_id: { type: "string", nullable: true },
47893
48071
  parent_id: { type: "string", nullable: true },
47894
48072
  assigned_to: { type: "string", nullable: true },
47895
48073
  agent_id: { type: "string", nullable: true },
48074
+ reason: { type: "string", nullable: true },
47896
48075
  tags: { type: "array", items: { type: "string" } },
47897
48076
  version: { type: "number" },
47898
48077
  created_at: { type: "string" },
@@ -48425,6 +48604,44 @@ function validateTaskCompletion(value) {
48425
48604
  }
48426
48605
  };
48427
48606
  }
48607
+ function validateTaskFailure(value) {
48608
+ if (!value || typeof value !== "object" || Array.isArray(value))
48609
+ return { ok: false, message: "failure body must be an object" };
48610
+ const body2 = value;
48611
+ const allowed = new Set(["agent_id", "reason", "retry"]);
48612
+ const unknown = Object.keys(body2).find((key2) => !allowed.has(key2));
48613
+ if (unknown)
48614
+ return { ok: false, message: `unknown failure field: ${unknown}` };
48615
+ if (body2.agent_id !== undefined && (typeof body2.agent_id !== "string" || !body2.agent_id.trim())) {
48616
+ return { ok: false, message: "agent_id must be a non-empty string" };
48617
+ }
48618
+ if (body2.reason !== undefined && typeof body2.reason !== "string")
48619
+ return { ok: false, message: "reason must be a string" };
48620
+ if (body2.retry !== undefined && typeof body2.retry !== "boolean")
48621
+ return { ok: false, message: "retry must be a boolean" };
48622
+ return {
48623
+ ok: true,
48624
+ ...typeof body2.agent_id === "string" ? { agentId: body2.agent_id } : {},
48625
+ reason: typeof body2.reason === "string" && body2.reason ? body2.reason : "Unknown failure",
48626
+ retry: body2.retry === true
48627
+ };
48628
+ }
48629
+ function validateTaskPatchVocabulary(value) {
48630
+ if (!value || typeof value !== "object" || Array.isArray(value))
48631
+ return { ok: false, message: "task patch must be an object" };
48632
+ const body2 = value;
48633
+ for (const [name, vocabulary] of [["status", TASK_STATUSES], ["priority", TASK_PRIORITIES]]) {
48634
+ const raw = body2[name];
48635
+ if (raw === undefined)
48636
+ continue;
48637
+ if (typeof raw !== "string")
48638
+ return { ok: false, message: `${name} must be a string. Allowed values: ${vocabulary.join(", ")}.` };
48639
+ const parsed = resolveEnumVocabulary(raw, { name, vocabulary, allowList: false });
48640
+ if (!parsed.ok)
48641
+ return { ok: false, message: parsed.message };
48642
+ }
48643
+ return { ok: true, patch: body2 };
48644
+ }
48428
48645
  function validateProjectPatch(value) {
48429
48646
  if (!value || typeof value !== "object" || Array.isArray(value))
48430
48647
  return { ok: false, message: "project patch must be an object" };
@@ -49105,7 +49322,12 @@ async function handleV1Request(req, url, dependencies = {}) {
49105
49322
  });
49106
49323
  }
49107
49324
  if (action === "fail" && method === "POST") {
49108
- return json5({ result: await store.tasks.fail(id, agentId, typeof body2.reason === "string" ? body2.reason : "failed", {}) });
49325
+ const parsed = validateTaskFailure(actionJson.value);
49326
+ if (!parsed.ok)
49327
+ return error(400, parsed.message);
49328
+ return json5({
49329
+ result: await store.tasks.fail(id, parsed.agentId || principal.agent || "todos-serve", parsed.reason, { retry: parsed.retry }, contextFromPrincipal(principal, body2))
49330
+ });
49109
49331
  }
49110
49332
  if (action === "claim" && method === "POST") {
49111
49333
  return json5({ task: await store.tasks.claimNext(agentId, {}) });
@@ -49134,9 +49356,13 @@ async function handleV1Request(req, url, dependencies = {}) {
49134
49356
  return task2 ? json5({ task: task2 }) : error(404, "task not found");
49135
49357
  }
49136
49358
  if (method === "PATCH" || method === "PUT") {
49137
- const body2 = await readJson3(req);
49138
- if (!body2)
49359
+ const rawBody = await readJson3(req);
49360
+ if (!rawBody)
49139
49361
  return error(400, "invalid JSON body");
49362
+ const validated = validateTaskPatchVocabulary(rawBody);
49363
+ if (!validated.ok)
49364
+ return error(400, validated.message);
49365
+ const body2 = validated.patch;
49140
49366
  const current = await store.tasks.get(id);
49141
49367
  if (!current)
49142
49368
  return error(404, "task not found");
@@ -75035,12 +75261,20 @@ Blocked:`));
75035
75261
  program2.command("fail <id>").description("Mark a task as failed with optional reason and retry").option("--reason <text>", "Why it failed").option("--agent <id>", "Agent reporting the failure").option("--retry", "Auto-create a retry copy").option("-j, --json", "Output as JSON").action(async (id, opts) => {
75036
75262
  const globalOpts = program2.opts();
75037
75263
  const json6 = opts.json || globalOpts.json;
75038
- const db = getDatabase();
75039
- const resolvedId = resolvePartialId(db, "tasks", id);
75040
- if (!resolvedId) {
75041
- handleError(new Error(`Task not found: ${id}`));
75042
- }
75043
- const result = failTask(resolvedId, opts.agent, opts.reason, { retry: opts.retry }, db);
75264
+ const agentId = opts.agent || globalOpts.agent;
75265
+ const cloud = getTodosCloudClient();
75266
+ const result = cloud ? await cloudFailTask(cloud, await resolveTaskIdForCommand(id, cloud), {
75267
+ ...agentId ? { agent_id: agentId } : {},
75268
+ ...opts.reason !== undefined ? { reason: opts.reason } : {},
75269
+ ...opts.retry ? { retry: true } : {}
75270
+ }) : (() => {
75271
+ const db = getDatabase();
75272
+ const resolvedId = resolvePartialId(db, "tasks", id);
75273
+ if (!resolvedId) {
75274
+ handleError(new Error(`Task not found: ${id}`));
75275
+ }
75276
+ return failTask(resolvedId, agentId, opts.reason, { retry: opts.retry }, db);
75277
+ })();
75044
75278
  if (json6) {
75045
75279
  console.log(JSON.stringify(result, null, 2));
75046
75280
  return;
@@ -77512,6 +77746,207 @@ var init_query_commands = __esm(() => {
77512
77746
  init_types();
77513
77747
  });
77514
77748
 
77749
+ // src/lib/goal-workflow.ts
77750
+ var GOAL_COMMAND_RECIPES;
77751
+ var init_goal_workflow = __esm(() => {
77752
+ init_plans();
77753
+ init_tasks();
77754
+ init_tasks();
77755
+ init_comments();
77756
+ init_handoffs();
77757
+ init_database();
77758
+ GOAL_COMMAND_RECIPES = [
77759
+ {
77760
+ host: "codex|claude-code|takumi|mcp",
77761
+ command: "/goal execute <plan-name>",
77762
+ description: "Claim and start the next ready step for a goal plan",
77763
+ equivalent_cli: "todos goal execute <plan-name> --agent <name>"
77764
+ },
77765
+ {
77766
+ host: "codex|claude-code|takumi|mcp",
77767
+ command: '/goal create "<goal text>" --steps step1,step2,step3',
77768
+ description: "Create a plan and decompose into sequential tasks",
77769
+ equivalent_cli: 'todos goal create "<goal>" --step "step1" --step "step2"'
77770
+ },
77771
+ {
77772
+ host: "codex|claude-code|takumi|mcp",
77773
+ command: "/goal status <plan-name>",
77774
+ description: "Show plan progress and current step",
77775
+ equivalent_cli: "todos goal status <plan-name>"
77776
+ },
77777
+ {
77778
+ host: "codex|claude-code|takumi|mcp",
77779
+ command: "/goal handoff <plan-name>",
77780
+ description: "Produce JSON/Markdown handoff packet for session transfer",
77781
+ equivalent_cli: "todos goal handoff <plan-name> --format md"
77782
+ }
77783
+ ];
77784
+ });
77785
+
77786
+ // src/lib/agent-adapter-docs.ts
77787
+ var ADAPTER_DOCS_SCHEMA_VERSION = "todos.agent_adapter_docs.v1", MCP_REGISTRABLE_CLI_AGENTS, SHARED_TASK_CONTRACT, SHARED_VERIFICATION, SHARED_HANDOFF, SHARED_WORKFLOW, AGENT_ADAPTER_DOCS;
77788
+ var init_agent_adapter_docs = __esm(() => {
77789
+ init_goal_workflow();
77790
+ MCP_REGISTRABLE_CLI_AGENTS = ["claude", "codex", "gemini", "cursor", "takumi"];
77791
+ SHARED_TASK_CONTRACT = {
77792
+ claim: "todos claim <agent-name> # atomic: find + lock + start best pending task",
77793
+ progress: 'todos log-progress <task-id> "Investigating..." [--pct 50]',
77794
+ complete: 'todos done <task-id> --commit-hash <hash> --notes "All tests pass"',
77795
+ fail: 'todos fail <task-id> --reason "..." [--retry]',
77796
+ evidence_fields: ["commit_hash", "notes", "attach_ids", "verification_record_id"]
77797
+ };
77798
+ SHARED_VERIFICATION = {
77799
+ run: "todos verify run --provider shell --task <id>",
77800
+ attach: "todos verify attach --task <id> --path ./test-output.log",
77801
+ mcp: "run_verification"
77802
+ };
77803
+ SHARED_HANDOFF = {
77804
+ goal: "todos goal handoff <plan-name> --format md",
77805
+ session: 'todos handoff --create --agent <name> --summary "..."',
77806
+ mcp: "format_goal_handoff"
77807
+ };
77808
+ SHARED_WORKFLOW = [
77809
+ { step: 1, title: "Install @hasna/todos", cli: "bun install -g @hasna/todos", notes: "Use bun, not npm, for @hasna packages" },
77810
+ { step: 2, title: "Register MCP server", cli: "todos mcp --register <host>", mcp: "bootstrap" },
77811
+ { step: 3, title: "Register agent identity", cli: "todos init <agent-name>", mcp: "register_agent" },
77812
+ { step: 4, title: "Claim work", cli: "todos claim <agent-name>", mcp: "claim_next_task" },
77813
+ { step: 5, title: "Log progress", cli: 'todos log-progress <id> "..."', mcp: "add_comment" },
77814
+ { step: 6, title: "Attach verification evidence", cli: "todos verify run --task <id>", mcp: "run_verification" },
77815
+ { step: 7, title: "Complete with evidence", cli: "todos done <id> --commit-hash HEAD", mcp: "complete_task" }
77816
+ ];
77817
+ AGENT_ADAPTER_DOCS = {
77818
+ codex: {
77819
+ schema_version: ADAPTER_DOCS_SCHEMA_VERSION,
77820
+ host: "codex",
77821
+ display_name: "OpenAI Codex CLI",
77822
+ install: {
77823
+ bun: "bun install -g @hasna/todos",
77824
+ verify: "todos --version && which todos-mcp"
77825
+ },
77826
+ mcp: {
77827
+ register_cli: "todos mcp --register codex",
77828
+ unregister_cli: "todos mcp --unregister codex",
77829
+ config_path: "~/.codex/config.toml",
77830
+ recommended_profile: "minimal",
77831
+ env: ["TODOS_PROFILE=minimal", "TODOS_DB_PATH=.todos/todos.db"]
77832
+ },
77833
+ goal_commands: GOAL_COMMAND_RECIPES,
77834
+ workflow: SHARED_WORKFLOW.map((s) => s.step === 2 ? { ...s, cli: "todos mcp --register codex" } : s),
77835
+ task_contract: { ...SHARED_TASK_CONTRACT },
77836
+ verification: { ...SHARED_VERIFICATION },
77837
+ handoff: { ...SHARED_HANDOFF },
77838
+ failure_modes: [
77839
+ {
77840
+ symptom: "MCP tools not visible in Codex",
77841
+ cause: "Missing [mcp_servers.todos] block in ~/.codex/config.toml",
77842
+ recovery: "Run `todos mcp --register codex` and restart Codex"
77843
+ },
77844
+ {
77845
+ symptom: "VersionConflictError on complete_task",
77846
+ cause: "Passed stale version to update_task instead of complete_task",
77847
+ recovery: "Use `complete_task` or CLI `todos done` \u2014 do not pass version manually"
77848
+ },
77849
+ {
77850
+ symptom: "No claimable tasks",
77851
+ cause: "Queue empty, dependencies blocking, or task locked by another agent",
77852
+ recovery: "Run `todos status --explain-blocked` or MCP get_status with explain_blocked"
77853
+ }
77854
+ ],
77855
+ examples: [
77856
+ { title: "Session start", command: "todos claim codex-agent && todos status" },
77857
+ { title: "Goal plan", command: 'todos goal create "Ship feature" --step "Implement" --step "Test"' },
77858
+ { title: "Queue agent run", command: "todos runs queue --adapter codex --task <id>" }
77859
+ ]
77860
+ },
77861
+ "claude-code": {
77862
+ schema_version: ADAPTER_DOCS_SCHEMA_VERSION,
77863
+ host: "claude-code",
77864
+ display_name: "Claude Code",
77865
+ install: {
77866
+ bun: "bun install -g @hasna/todos",
77867
+ verify: "todos --version && claude mcp list | grep todos"
77868
+ },
77869
+ mcp: {
77870
+ register_cli: "todos mcp --register claude",
77871
+ unregister_cli: "todos mcp --unregister claude",
77872
+ config_path: "Managed via `claude mcp` (project or user scope)",
77873
+ recommended_profile: "minimal",
77874
+ env: ["TODOS_PROFILE=minimal", "TODOS_AUTO_PROJECT=true"]
77875
+ },
77876
+ goal_commands: GOAL_COMMAND_RECIPES,
77877
+ workflow: SHARED_WORKFLOW.map((s) => s.step === 2 ? { ...s, cli: "todos mcp --register claude" } : s),
77878
+ task_contract: { ...SHARED_TASK_CONTRACT },
77879
+ verification: { ...SHARED_VERIFICATION },
77880
+ handoff: { ...SHARED_HANDOFF },
77881
+ failure_modes: [
77882
+ {
77883
+ symptom: "claude mcp add fails",
77884
+ cause: "Claude Code CLI not installed or not on PATH",
77885
+ recovery: "Install Claude Code, then run the printed `claude mcp add` command manually"
77886
+ },
77887
+ {
77888
+ symptom: "LockError on start",
77889
+ cause: "Task locked by another agent within 30-minute window",
77890
+ recovery: "Use `todos stale` to find abandoned locks or wait for lock expiry"
77891
+ },
77892
+ {
77893
+ symptom: "CompletionGuardError",
77894
+ cause: "Required checklist or approval gate not satisfied",
77895
+ recovery: "Run `todos approvals list --task <id>` and complete gates first"
77896
+ }
77897
+ ],
77898
+ examples: [
77899
+ { title: "Global MCP registration", command: "todos mcp --register claude --global" },
77900
+ { title: "Execute goal step", command: "todos goal execute my-plan --agent claude-agent" },
77901
+ { title: "Link git traceability", command: "todos trace link <task-id> --branch feature/x --commit abc123" }
77902
+ ]
77903
+ },
77904
+ takumi: {
77905
+ schema_version: ADAPTER_DOCS_SCHEMA_VERSION,
77906
+ host: "takumi",
77907
+ display_name: "Takumi",
77908
+ install: {
77909
+ bun: "bun install -g @hasna/todos",
77910
+ verify: "todos --version && takumi mcp list 2>/dev/null | grep todos || true"
77911
+ },
77912
+ mcp: {
77913
+ register_cli: "todos mcp --register takumi",
77914
+ unregister_cli: "todos mcp --unregister takumi",
77915
+ config_path: "~/.takumi.json (project-scoped mcpServers) or local Takumi MCP config",
77916
+ recommended_profile: "minimal",
77917
+ env: ["TODOS_PROFILE=minimal", "TODOS_DB_PATH=.todos/todos.db"]
77918
+ },
77919
+ goal_commands: GOAL_COMMAND_RECIPES,
77920
+ workflow: SHARED_WORKFLOW.map((s) => s.step === 2 ? { ...s, cli: "todos mcp --register takumi" } : s),
77921
+ task_contract: { ...SHARED_TASK_CONTRACT },
77922
+ verification: { ...SHARED_VERIFICATION },
77923
+ handoff: { ...SHARED_HANDOFF },
77924
+ failure_modes: [
77925
+ {
77926
+ symptom: "todos MCP missing in Takumi",
77927
+ cause: "MCP server not added to project or user scope",
77928
+ recovery: "Run `takumi mcp add --scope project todos -- todos-mcp` or `todos mcp --register takumi`"
77929
+ },
77930
+ {
77931
+ symptom: "Wrong database / empty task list",
77932
+ cause: "TODOS_DB_PATH not set; cwd differs from git project root",
77933
+ recovery: "Set TODOS_DB_PATH=.todos/todos.db or run `todos bootstrap` in project root"
77934
+ },
77935
+ {
77936
+ symptom: "Agent run queue stuck",
77937
+ cause: "Run claimed but never completed/failed",
77938
+ recovery: "Run `todos runs list --status running` then complete or fail the run"
77939
+ }
77940
+ ],
77941
+ examples: [
77942
+ { title: "Manual Takumi MCP add", command: "takumi mcp add --scope project todos -- todos-mcp" },
77943
+ { title: "Legacy tmux handoff", command: "todos dispatch agents:0 --tasks <id>" },
77944
+ { title: "Handoff on session end", command: "todos goal handoff release-v2 --format md --agent takumi-agent" }
77945
+ ]
77946
+ }
77947
+ };
77948
+ });
77949
+
77515
77950
  // src/cli/commands/mcp-hooks-commands.ts
77516
77951
  var exports_mcp_hooks_commands = {};
77517
77952
  __export(exports_mcp_hooks_commands, {
@@ -77671,6 +78106,28 @@ function unregisterGemini() {
77671
78106
  writeJsonFile2(configPath, config);
77672
78107
  console.log(chalk10.green(`Gemini CLI: unregistered from ${configPath}`));
77673
78108
  }
78109
+ function registerTakumi(binPath, global) {
78110
+ const scope = global ? "user" : "project";
78111
+ const cmd = `takumi mcp add --scope ${scope} todos -- ${binPath} --stdio`;
78112
+ try {
78113
+ execSync3(cmd, { stdio: "pipe" });
78114
+ console.log(chalk10.green(`Takumi (${scope}): registered via 'takumi mcp add'`));
78115
+ } catch {
78116
+ console.log(chalk10.yellow(`Takumi: could not auto-register. Run this command manually:`));
78117
+ console.log(chalk10.cyan(` ${cmd}`));
78118
+ }
78119
+ }
78120
+ function unregisterTakumi(global) {
78121
+ const scope = global ? "user" : "project";
78122
+ const cmd = `takumi mcp remove --scope ${scope} todos`;
78123
+ try {
78124
+ execSync3(cmd, { stdio: "pipe" });
78125
+ console.log(chalk10.green(`Takumi (${scope}): removed todos MCP server`));
78126
+ } catch {
78127
+ console.log(chalk10.yellow(`Takumi: could not auto-remove. Run manually:`));
78128
+ console.log(chalk10.cyan(` ${cmd}`));
78129
+ }
78130
+ }
77674
78131
  function cursorConfigPath(global) {
77675
78132
  return global ? join27(HOME2, ".cursor", "mcp.json") : join27(process.cwd(), ".cursor", "mcp.json");
77676
78133
  }
@@ -77701,7 +78158,7 @@ function unregisterCursor(global) {
77701
78158
  console.log(chalk10.green(`Cursor (${global ? "user" : "project"}): unregistered from ${configPath}`));
77702
78159
  }
77703
78160
  function registerMcp(agent, global) {
77704
- const agents = agent === "all" ? ["claude", "codex", "gemini", "cursor"] : [agent];
78161
+ const agents = agent === "all" ? [...MCP_REGISTRABLE_CLI_AGENTS] : [agent];
77705
78162
  const binPath = getMcpBinaryPath();
77706
78163
  for (const a of agents) {
77707
78164
  switch (a) {
@@ -77717,13 +78174,16 @@ function registerMcp(agent, global) {
77717
78174
  case "cursor":
77718
78175
  registerCursor(binPath, global);
77719
78176
  break;
78177
+ case "takumi":
78178
+ registerTakumi(binPath, global);
78179
+ break;
77720
78180
  default:
77721
- console.error(chalk10.red(`Unknown agent: ${a}. Use: claude, codex, gemini, cursor, all`));
78181
+ console.error(chalk10.red(`Unknown agent: ${a}. Use: ${MCP_AGENT_CHOICES}`));
77722
78182
  }
77723
78183
  }
77724
78184
  }
77725
78185
  function unregisterMcp(agent, global) {
77726
- const agents = agent === "all" ? ["claude", "codex", "gemini", "cursor"] : [agent];
78186
+ const agents = agent === "all" ? [...MCP_REGISTRABLE_CLI_AGENTS] : [agent];
77727
78187
  for (const a of agents) {
77728
78188
  switch (a) {
77729
78189
  case "claude":
@@ -77738,8 +78198,11 @@ function unregisterMcp(agent, global) {
77738
78198
  case "cursor":
77739
78199
  unregisterCursor(global);
77740
78200
  break;
78201
+ case "takumi":
78202
+ unregisterTakumi(global);
78203
+ break;
77741
78204
  default:
77742
- console.error(chalk10.red(`Unknown agent: ${a}. Use: claude, codex, gemini, cursor, all`));
78205
+ console.error(chalk10.red(`Unknown agent: ${a}. Use: ${MCP_AGENT_CHOICES}`));
77743
78206
  }
77744
78207
  }
77745
78208
  }
@@ -77811,7 +78274,7 @@ exit 0
77811
78274
  console.log(chalk10.green(`Claude Code hooks configured in: ${settingsPath}`));
77812
78275
  console.log(chalk10.dim("Task list ID auto-detected from project."));
77813
78276
  });
77814
- program2.command("mcp").description("Start MCP server (stdio)").option("--register <agent>", "Register MCP server with an agent (claude, codex, gemini, cursor, all)").option("--unregister <agent>", "Unregister MCP server from an agent (claude, codex, gemini, cursor, all)").option("-g, --global", "Register/unregister globally (user-level) instead of project-level").action(async (opts) => {
78277
+ program2.command("mcp").description("Start MCP server (stdio)").option("--register <agent>", `Register MCP server with an agent (${MCP_AGENT_CHOICES})`).option("--unregister <agent>", `Unregister MCP server from an agent (${MCP_AGENT_CHOICES})`).option("-g, --global", "Register/unregister globally (user-level) instead of project-level").action(async (opts) => {
77815
78278
  if (opts.register) {
77816
78279
  registerMcp(opts.register, opts.global);
77817
78280
  return;
@@ -78764,13 +79227,15 @@ $(dirname "$0")/../../scripts/post-commit-hook.sh
78764
79227
  }
78765
79228
  });
78766
79229
  }
78767
- var HOME2;
79230
+ var HOME2, MCP_AGENT_CHOICES;
78768
79231
  var init_mcp_hooks_commands = __esm(() => {
78769
79232
  init_tasks();
78770
79233
  init_helpers();
78771
79234
  init_cloud_router();
78772
79235
  init_sync_utils();
79236
+ init_agent_adapter_docs();
78773
79237
  HOME2 = getHomeDir();
79238
+ MCP_AGENT_CHOICES = `${MCP_REGISTRABLE_CLI_AGENTS.join(", ")}, all`;
78774
79239
  });
78775
79240
 
78776
79241
  // src/cli/commands/dispatch.tsx