@hasna/todos 0.15.17 → 0.15.19

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/mcp/index.js CHANGED
@@ -47,7 +47,7 @@ function isBlockingDependencyStatus(status) {
47
47
  function isTerminalStatus(status) {
48
48
  return status === "completed" || status === "failed" || status === "cancelled";
49
49
  }
50
- var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
50
+ var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
51
51
  var init_types = __esm(() => {
52
52
  TASK_STATUSES = [
53
53
  "pending",
@@ -134,6 +134,19 @@ var init_types = __esm(() => {
134
134
  this.name = "ResourceConflictError";
135
135
  }
136
136
  };
137
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
138
+ planId;
139
+ expectedUpdatedAt;
140
+ currentUpdatedAt;
141
+ static code = "PLAN_REVISION_CONFLICT";
142
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
143
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
144
+ this.planId = planId;
145
+ this.expectedUpdatedAt = expectedUpdatedAt;
146
+ this.currentUpdatedAt = currentUpdatedAt;
147
+ this.name = "PlanRevisionConflictError";
148
+ }
149
+ };
137
150
  PlanNotFoundError = class PlanNotFoundError extends Error {
138
151
  planId;
139
152
  static code = "PLAN_NOT_FOUND";
@@ -15309,6 +15322,48 @@ function updatePlan(id, input, db) {
15309
15322
  return updatePlanStored(id, input, d);
15310
15323
  })();
15311
15324
  }
15325
+ function nextPlanCompletionTimestamp(expectedUpdatedAt) {
15326
+ const expected = Date.parse(expectedUpdatedAt);
15327
+ const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
15328
+ return new Date(Math.max(Date.now(), minimum)).toISOString();
15329
+ }
15330
+ function completePlanAtRevision(id, expectedUpdatedAt, db) {
15331
+ const d = db || getDatabase();
15332
+ return d.transaction(() => {
15333
+ guardPlanRowsSqlite([id], d);
15334
+ const plan = getPlan(id, d);
15335
+ if (!plan)
15336
+ throw new PlanNotFoundError(id);
15337
+ if (plan.updated_at !== expectedUpdatedAt) {
15338
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
15339
+ }
15340
+ if (plan.status === "completed")
15341
+ return { plan, applied: false };
15342
+ const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
15343
+ const result = d.run(`UPDATE plans
15344
+ SET status = 'completed', updated_at = ?
15345
+ WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
15346
+ if (result.changes !== 1) {
15347
+ const current = getPlan(id, d);
15348
+ if (!current)
15349
+ throw new PlanNotFoundError(id);
15350
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
15351
+ }
15352
+ const completed = getPlan(id, d);
15353
+ emitLocalEventHooksQuiet({
15354
+ type: "plan.updated",
15355
+ payload: {
15356
+ id,
15357
+ old_status: plan.status,
15358
+ new_status: completed.status,
15359
+ name: completed.name,
15360
+ project_id: completed.project_id
15361
+ },
15362
+ databasePath: databasePathFromDatabase(d)
15363
+ });
15364
+ return { plan: completed, applied: true };
15365
+ })();
15366
+ }
15312
15367
  function deletePlan(id, db) {
15313
15368
  const d = db || getDatabase();
15314
15369
  const plan = getPlan(id, d);
@@ -21322,7 +21377,7 @@ function resolveTaskListFromCandidates(lists, input) {
21322
21377
  ];
21323
21378
  for (const matches of matchGroups) {
21324
21379
  if (matches.length === 1)
21325
- return matches[0].id;
21380
+ return matches[0];
21326
21381
  if (matches.length > 1) {
21327
21382
  throw new Error(`Task list reference is ambiguous: "${input}"`);
21328
21383
  }
@@ -21335,27 +21390,26 @@ async function legacyProjectTaskLists(client, projectId) {
21335
21390
  return [];
21336
21391
  return (await cloudListTaskLists(client)).filter((list) => list.project_id == null && list.slug === project.task_list_id);
21337
21392
  }
21338
- async function cloudResolveTaskListRef(client, ref, projectId) {
21393
+ async function cloudResolveTaskList(client, ref, projectId) {
21339
21394
  const input = ref.trim();
21340
21395
  const normalizedIdRef = input.toLowerCase();
21341
- if (UUID_RE.test(input) && !projectId)
21342
- return normalizedIdRef;
21343
- if (UUID_RE.test(input) && projectId) {
21396
+ if (UUID_RE.test(input)) {
21344
21397
  const direct = await cloudGetTaskList(client, normalizedIdRef);
21345
21398
  if (direct?.id?.toLowerCase() === normalizedIdRef) {
21346
- if (direct.project_id === projectId)
21347
- return direct.id;
21399
+ if (!projectId || direct.project_id === projectId)
21400
+ return direct;
21348
21401
  if (direct.project_id == null) {
21349
21402
  const project = await cloudGetProjectById(client, projectId);
21350
21403
  if (project?.task_list_id === direct.slug)
21351
- return direct.id;
21404
+ return direct;
21352
21405
  }
21353
21406
  throw new Error(`Task list not found: "${input}"`);
21354
21407
  }
21355
21408
  }
21356
21409
  const scopedMatch = resolveTaskListFromCandidates(await cloudListTaskLists(client, projectId), input);
21357
- if (scopedMatch)
21410
+ if (scopedMatch && (!projectId || scopedMatch.project_id === projectId)) {
21358
21411
  return scopedMatch;
21412
+ }
21359
21413
  if (projectId) {
21360
21414
  const legacyMatch = resolveTaskListFromCandidates(await legacyProjectTaskLists(client, projectId), input);
21361
21415
  if (legacyMatch)
@@ -21363,7 +21417,13 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
21363
21417
  }
21364
21418
  throw new Error(`Task list not found: "${input}"`);
21365
21419
  }
21366
- var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21420
+ async function cloudResolveTaskListRef(client, ref, projectId) {
21421
+ const input = ref.trim();
21422
+ if (UUID_RE.test(input) && !projectId)
21423
+ return input.toLowerCase();
21424
+ return (await cloudResolveTaskList(client, ref, projectId)).id;
21425
+ }
21426
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21367
21427
  var init_cloud_router = __esm(() => {
21368
21428
  init_types();
21369
21429
  init_redaction();
@@ -21380,6 +21440,7 @@ var init_cloud_router = __esm(() => {
21380
21440
  hybrid: "http"
21381
21441
  };
21382
21442
  completionCapabilityCache = new Map;
21443
+ retryCapabilityCache = new Map;
21383
21444
  gitRefCapabilityCache = new Map;
21384
21445
  SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
21385
21446
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -35476,7 +35537,7 @@ var package_default;
35476
35537
  var init_package = __esm(() => {
35477
35538
  package_default = {
35478
35539
  name: "@hasna/todos",
35479
- version: "0.15.17",
35540
+ version: "0.15.19",
35480
35541
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35481
35542
  type: "module",
35482
35543
  main: "dist/index.js",
@@ -45072,7 +45133,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45072
45133
  }
45073
45134
  if (result.errors.length > 0)
45074
45135
  return result;
45075
- const applyRows = (objectType2, table, columns, rows, updateClockColumn, afterUpsert) => {
45136
+ const applyRows = (objectType2, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
45076
45137
  for (const row of rows) {
45077
45138
  try {
45078
45139
  const record = asRecord2(row);
@@ -45081,7 +45142,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45081
45142
  result.skipped += 1;
45082
45143
  continue;
45083
45144
  }
45084
- const state = upsertById(d, table, columns, record, updateClockColumn);
45145
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
45085
45146
  if (state === "inserted")
45086
45147
  result.inserted += 1;
45087
45148
  else if (state === "updated")
@@ -45098,10 +45159,10 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45098
45159
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
45099
45160
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
45100
45161
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
45101
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
45162
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
45102
45163
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
45103
45164
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
45104
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", (row, changed) => {
45165
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", true, (row, changed) => {
45105
45166
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
45106
45167
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
45107
45168
  }
@@ -45110,7 +45171,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45110
45171
  applyTombstones(d, snapshot.tombstones ?? [], result);
45111
45172
  return result;
45112
45173
  }
45113
- function upsertById(db, table, columns, row, updateClockColumn) {
45174
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
45114
45175
  const id = row["id"];
45115
45176
  if (typeof id !== "string" || !id)
45116
45177
  throw new Error(`${table} row is missing id`);
@@ -45122,7 +45183,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
45122
45183
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
45123
45184
  const updateColumns = presentColumns.filter((column) => column !== "id");
45124
45185
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
45125
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
45186
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
45126
45187
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})
45127
45188
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})`;
45128
45189
  const changes = db.run(sql, values).changes;
@@ -45541,6 +45602,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45541
45602
  get: (id) => getPlan(id, database()),
45542
45603
  list: (projectId) => listPlans(projectId, database()),
45543
45604
  update: (id, input) => updatePlan(id, input, database()),
45605
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
45544
45606
  delete: (id) => deletePlan(id, database())
45545
45607
  },
45546
45608
  planProjectLinks: {
@@ -46501,6 +46563,7 @@ function createPostgresTodosStorageAdapter(options) {
46501
46563
  get: (id) => store.get("plans", id),
46502
46564
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
46503
46565
  update: (id, input) => updatePlan2(id, input, store),
46566
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
46504
46567
  delete: (id, context) => store.deletePlan(id, context)
46505
46568
  },
46506
46569
  planProjectLinks: {
@@ -46988,6 +47051,54 @@ class PostgresJsonRecordStore {
46988
47051
  throw new PlanNotFoundError(value.id);
46989
47052
  return payloadRecord2(row.payload);
46990
47053
  }
47054
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
47055
+ await this.ensureSchema();
47056
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
47057
+ SELECT date_trunc(
47058
+ 'milliseconds',
47059
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
47060
+ ) AS completed_at
47061
+ ), stored AS (
47062
+ UPDATE ${this.tableName} AS record SET
47063
+ payload = record.payload || jsonb_build_object(
47064
+ 'status', 'completed',
47065
+ 'updated_at', to_char(
47066
+ next_clock.completed_at AT TIME ZONE 'UTC',
47067
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
47068
+ )
47069
+ ),
47070
+ updated_at = next_clock.completed_at,
47071
+ deleted_at = NULL,
47072
+ source_machine_id = COALESCE($4, record.source_machine_id),
47073
+ version = COALESCE(record.version, 0) + 1
47074
+ FROM next_clock
47075
+ WHERE record.service = $1
47076
+ AND record.object_type = 'plans'
47077
+ AND record.object_id = $2
47078
+ AND record.deleted_at IS NULL
47079
+ AND record.payload->>'updated_at' = $3::text
47080
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
47081
+ RETURNING record.payload
47082
+ )
47083
+ SELECT payload FROM stored`, [
47084
+ this.service,
47085
+ id,
47086
+ expectedUpdatedAt,
47087
+ context.requestId ?? this.sourceMachineId ?? null
47088
+ ]);
47089
+ const payload = result.rows[0]?.payload;
47090
+ if (payload)
47091
+ return { plan: payloadRecord2(payload), applied: true };
47092
+ const current = await this.get("plans", id);
47093
+ if (!current)
47094
+ throw new PlanNotFoundError(id);
47095
+ if (current.updated_at !== expectedUpdatedAt) {
47096
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
47097
+ }
47098
+ if (current.status === "completed")
47099
+ return { plan: current, applied: false };
47100
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
47101
+ }
46991
47102
  async createTemplateWithTasks(template, tasks, context = {}) {
46992
47103
  await this.ensureSchema();
46993
47104
  const records = [
@@ -55529,8 +55640,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55529
55640
  "/v1/import": {
55530
55641
  post: {
55531
55642
  operationId: "importSnapshot",
55532
- summary: "Bulk-ingest a full or partial snapshot (idempotent upsert by id)",
55533
- description: "Upserts every record carried in the body by primary key. All record arrays are optional and default to []; a caller may backfill a single object type (e.g. just tasks) or a complete snapshot. Re-posting the same rows never duplicates. Requires the todos:write scope.",
55643
+ summary: "Bulk-ingest a snapshot or atomically complete one observed plan",
55644
+ description: "Upserts every snapshot record by primary key, or accepts exactly one planCompletions operation that changes only plan status under an expected_updated_at CAS. Snapshot records and planCompletions are mutually exclusive. Requires the todos:write scope.",
55534
55645
  requestBody: {
55535
55646
  required: true,
55536
55647
  content: {
@@ -55549,7 +55660,22 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55549
55660
  templates: { type: "array", items: { type: "object" } },
55550
55661
  templateTasks: { type: "array", items: { $ref: "#/components/schemas/TemplateTask" } },
55551
55662
  auditHistory: { type: "array", items: { type: "object" } },
55552
- tombstones: { type: "array", items: { type: "object" } }
55663
+ tombstones: { type: "array", items: { type: "object" } },
55664
+ planCompletions: {
55665
+ type: "array",
55666
+ minItems: 1,
55667
+ maxItems: 1,
55668
+ items: {
55669
+ type: "object",
55670
+ additionalProperties: false,
55671
+ required: ["id", "expected_updated_at", "status"],
55672
+ properties: {
55673
+ id: { type: "string" },
55674
+ expected_updated_at: { type: "string", format: "date-time" },
55675
+ status: { type: "string", enum: ["completed"] }
55676
+ }
55677
+ }
55678
+ }
55553
55679
  }
55554
55680
  }
55555
55681
  }
@@ -55572,6 +55698,26 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55572
55698
  skipped: { type: "number" },
55573
55699
  errors: { type: "array", items: { type: "string" } }
55574
55700
  }
55701
+ },
55702
+ planCompletions: {
55703
+ type: "array",
55704
+ items: {
55705
+ type: "object",
55706
+ required: [
55707
+ "id",
55708
+ "status",
55709
+ "expected_updated_at",
55710
+ "result_updated_at",
55711
+ "applied"
55712
+ ],
55713
+ properties: {
55714
+ id: { type: "string" },
55715
+ status: { type: "string", enum: ["completed"] },
55716
+ expected_updated_at: { type: "string", format: "date-time" },
55717
+ result_updated_at: { type: "string", format: "date-time" },
55718
+ applied: { type: "boolean" }
55719
+ }
55720
+ }
55575
55721
  }
55576
55722
  }
55577
55723
  }
@@ -56768,6 +56914,65 @@ function normalizeImportSnapshot(raw) {
56768
56914
  function countSnapshotRecords(s) {
56769
56915
  return s.tasks.length + s.projects.length + (s.projectMachinePaths?.length ?? 0) + s.plans.length + s.agents.length + s.taskLists.length + s.templates.length + s.templateTasks.length + s.auditHistory.length + (s.tombstones?.length ?? 0);
56770
56916
  }
56917
+ function validatePlanCompletionImports(raw) {
56918
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
56919
+ return { present: false, operations: [] };
56920
+ }
56921
+ const body2 = raw;
56922
+ if (!Object.prototype.hasOwnProperty.call(body2, "planCompletions")) {
56923
+ return { present: false, operations: [] };
56924
+ }
56925
+ if (!Array.isArray(body2["planCompletions"]) || body2["planCompletions"].length !== 1) {
56926
+ return {
56927
+ present: true,
56928
+ operations: [],
56929
+ error: "planCompletions must contain exactly one completion operation"
56930
+ };
56931
+ }
56932
+ const operation = body2["planCompletions"][0];
56933
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
56934
+ return { present: true, operations: [], error: "plan completion must be an object" };
56935
+ }
56936
+ const record = operation;
56937
+ const allowed = new Set(["id", "expected_updated_at", "status"]);
56938
+ const unknown = Object.keys(record).find((key2) => !allowed.has(key2));
56939
+ if (unknown) {
56940
+ return { present: true, operations: [], error: `unknown plan completion field: ${unknown}` };
56941
+ }
56942
+ if (typeof record["id"] !== "string" || !record["id"].trim()) {
56943
+ return { present: true, operations: [], error: "plan completion id must be a non-empty string" };
56944
+ }
56945
+ if (record["status"] !== "completed") {
56946
+ return { present: true, operations: [], error: "plan completion status must be completed" };
56947
+ }
56948
+ const expectedUpdatedAt = typeof record["expected_updated_at"] === "string" ? record["expected_updated_at"] : "";
56949
+ const timestampMatch = RFC3339_DATE_TIME.exec(expectedUpdatedAt);
56950
+ const parsedTimestamp = Date.parse(expectedUpdatedAt);
56951
+ if (!timestampMatch || Number.isNaN(parsedTimestamp)) {
56952
+ return {
56953
+ present: true,
56954
+ operations: [],
56955
+ error: "plan completion expected_updated_at must be an RFC 3339 date-time with an explicit offset"
56956
+ };
56957
+ }
56958
+ const [, year, month, day] = timestampMatch;
56959
+ const calendarProbe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
56960
+ if (calendarProbe.getUTCFullYear() !== Number(year) || calendarProbe.getUTCMonth() !== Number(month) - 1 || calendarProbe.getUTCDate() !== Number(day)) {
56961
+ return {
56962
+ present: true,
56963
+ operations: [],
56964
+ error: "plan completion expected_updated_at names a date that does not exist"
56965
+ };
56966
+ }
56967
+ return {
56968
+ present: true,
56969
+ operations: [{
56970
+ id: record["id"],
56971
+ expected_updated_at: expectedUpdatedAt,
56972
+ status: "completed"
56973
+ }]
56974
+ };
56975
+ }
56771
56976
  async function handleV1Request(req, url, dependencies = {}) {
56772
56977
  const path = url.pathname;
56773
56978
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -57704,6 +57909,36 @@ async function handleV1Request(req, url, dependencies = {}) {
57704
57909
  return error(400, "invalid JSON body");
57705
57910
  const snapshot = normalizeImportSnapshot(raw);
57706
57911
  const received = countSnapshotRecords(snapshot);
57912
+ const completionImports = validatePlanCompletionImports(raw);
57913
+ if (completionImports.present) {
57914
+ if (completionImports.error)
57915
+ return error(400, completionImports.error);
57916
+ if (received !== 0) {
57917
+ return error(400, "planCompletions cannot be combined with snapshot record arrays");
57918
+ }
57919
+ if (typeof store.plans.completeAtRevision !== "function") {
57920
+ return error(501, "atomic plan completion is not supported by this storage backend");
57921
+ }
57922
+ const operation = completionImports.operations[0];
57923
+ const completed = await store.plans.completeAtRevision(operation.id, operation.expected_updated_at, contextFromPrincipal(principal));
57924
+ return json5({
57925
+ result: {
57926
+ inserted: 0,
57927
+ updated: completed.applied ? 1 : 0,
57928
+ deleted: 0,
57929
+ skipped: completed.applied ? 0 : 1,
57930
+ errors: []
57931
+ },
57932
+ received: 1,
57933
+ planCompletions: [{
57934
+ id: operation.id,
57935
+ status: "completed",
57936
+ expected_updated_at: operation.expected_updated_at,
57937
+ result_updated_at: completed.plan.updated_at,
57938
+ applied: completed.applied
57939
+ }]
57940
+ });
57941
+ }
57707
57942
  if (received === 0) {
57708
57943
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
57709
57944
  }
@@ -57730,6 +57965,18 @@ async function handleV1Request(req, url, dependencies = {}) {
57730
57965
  if (e instanceof TaskNotFoundError) {
57731
57966
  return error(404, e.message, { code: TaskNotFoundError.code });
57732
57967
  }
57968
+ if (e instanceof PlanNotFoundError) {
57969
+ return error(404, e.message, { code: PlanNotFoundError.code });
57970
+ }
57971
+ if (e instanceof PlanRevisionConflictError) {
57972
+ return error(409, e.message, {
57973
+ code: PlanRevisionConflictError.code,
57974
+ conflict: true,
57975
+ plan_id: e.planId,
57976
+ expected_updated_at: e.expectedUpdatedAt,
57977
+ current_updated_at: e.currentUpdatedAt
57978
+ });
57979
+ }
57733
57980
  if (e instanceof LockError)
57734
57981
  return error(409, e.message, { code: LockError.code });
57735
57982
  if (e instanceof TaskNotStartableError) {
package/dist/mcp.js CHANGED
@@ -41,7 +41,7 @@ var __require = import.meta.require;
41
41
  // package.json
42
42
  var package_default = {
43
43
  name: "@hasna/todos",
44
- version: "0.15.17",
44
+ version: "0.15.19",
45
45
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
46
46
  type: "module",
47
47
  main: "dist/index.js",
@@ -117,7 +117,7 @@ function isBlockingDependencyStatus(status) {
117
117
  function isTerminalStatus(status) {
118
118
  return status === "completed" || status === "failed" || status === "cancelled";
119
119
  }
120
- var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
120
+ var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
121
121
  var init_types = __esm(() => {
122
122
  TASK_STATUSES = [
123
123
  "pending",
@@ -205,6 +205,19 @@ var init_types = __esm(() => {
205
205
  this.name = "ResourceConflictError";
206
206
  }
207
207
  };
208
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
209
+ planId;
210
+ expectedUpdatedAt;
211
+ currentUpdatedAt;
212
+ static code = "PLAN_REVISION_CONFLICT";
213
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
214
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
215
+ this.planId = planId;
216
+ this.expectedUpdatedAt = expectedUpdatedAt;
217
+ this.currentUpdatedAt = currentUpdatedAt;
218
+ this.name = "PlanRevisionConflictError";
219
+ }
220
+ };
208
221
  PlanNotFoundError = class PlanNotFoundError extends Error {
209
222
  planId;
210
223
  static code = "PLAN_NOT_FOUND";
@@ -10521,6 +10534,48 @@ function updatePlan2(id, input, db) {
10521
10534
  return updatePlanStored(id, input, d);
10522
10535
  })();
10523
10536
  }
10537
+ function nextPlanCompletionTimestamp(expectedUpdatedAt) {
10538
+ const expected = Date.parse(expectedUpdatedAt);
10539
+ const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
10540
+ return new Date(Math.max(Date.now(), minimum)).toISOString();
10541
+ }
10542
+ function completePlanAtRevision(id, expectedUpdatedAt, db) {
10543
+ const d = db || getDatabase();
10544
+ return d.transaction(() => {
10545
+ guardPlanRowsSqlite([id], d);
10546
+ const plan = getPlan(id, d);
10547
+ if (!plan)
10548
+ throw new PlanNotFoundError(id);
10549
+ if (plan.updated_at !== expectedUpdatedAt) {
10550
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
10551
+ }
10552
+ if (plan.status === "completed")
10553
+ return { plan, applied: false };
10554
+ const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
10555
+ const result = d.run(`UPDATE plans
10556
+ SET status = 'completed', updated_at = ?
10557
+ WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
10558
+ if (result.changes !== 1) {
10559
+ const current = getPlan(id, d);
10560
+ if (!current)
10561
+ throw new PlanNotFoundError(id);
10562
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
10563
+ }
10564
+ const completed = getPlan(id, d);
10565
+ emitLocalEventHooksQuiet({
10566
+ type: "plan.updated",
10567
+ payload: {
10568
+ id,
10569
+ old_status: plan.status,
10570
+ new_status: completed.status,
10571
+ name: completed.name,
10572
+ project_id: completed.project_id
10573
+ },
10574
+ databasePath: databasePathFromDatabase(d)
10575
+ });
10576
+ return { plan: completed, applied: true };
10577
+ })();
10578
+ }
10524
10579
  function deletePlan(id, db) {
10525
10580
  const d = db || getDatabase();
10526
10581
  const plan = getPlan(id, d);
@@ -12383,7 +12438,7 @@ import { createHash as createHash5 } from "crypto";
12383
12438
  // package.json
12384
12439
  var package_default = {
12385
12440
  name: "@hasna/todos",
12386
- version: "0.15.17",
12441
+ version: "0.15.19",
12387
12442
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12388
12443
  type: "module",
12389
12444
  main: "dist/index.js",
@@ -13288,6 +13343,7 @@ function createPostgresTodosStorageAdapter(options) {
13288
13343
  get: (id) => store.get("plans", id),
13289
13344
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
13290
13345
  update: (id, input) => updatePlan(id, input, store),
13346
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
13291
13347
  delete: (id, context) => store.deletePlan(id, context)
13292
13348
  },
13293
13349
  planProjectLinks: {
@@ -13775,6 +13831,54 @@ class PostgresJsonRecordStore {
13775
13831
  throw new PlanNotFoundError(value.id);
13776
13832
  return payloadRecord2(row.payload);
13777
13833
  }
13834
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
13835
+ await this.ensureSchema();
13836
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
13837
+ SELECT date_trunc(
13838
+ 'milliseconds',
13839
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
13840
+ ) AS completed_at
13841
+ ), stored AS (
13842
+ UPDATE ${this.tableName} AS record SET
13843
+ payload = record.payload || jsonb_build_object(
13844
+ 'status', 'completed',
13845
+ 'updated_at', to_char(
13846
+ next_clock.completed_at AT TIME ZONE 'UTC',
13847
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
13848
+ )
13849
+ ),
13850
+ updated_at = next_clock.completed_at,
13851
+ deleted_at = NULL,
13852
+ source_machine_id = COALESCE($4, record.source_machine_id),
13853
+ version = COALESCE(record.version, 0) + 1
13854
+ FROM next_clock
13855
+ WHERE record.service = $1
13856
+ AND record.object_type = 'plans'
13857
+ AND record.object_id = $2
13858
+ AND record.deleted_at IS NULL
13859
+ AND record.payload->>'updated_at' = $3::text
13860
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
13861
+ RETURNING record.payload
13862
+ )
13863
+ SELECT payload FROM stored`, [
13864
+ this.service,
13865
+ id,
13866
+ expectedUpdatedAt,
13867
+ context.requestId ?? this.sourceMachineId ?? null
13868
+ ]);
13869
+ const payload = result.rows[0]?.payload;
13870
+ if (payload)
13871
+ return { plan: payloadRecord2(payload), applied: true };
13872
+ const current = await this.get("plans", id);
13873
+ if (!current)
13874
+ throw new PlanNotFoundError(id);
13875
+ if (current.updated_at !== expectedUpdatedAt) {
13876
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
13877
+ }
13878
+ if (current.status === "completed")
13879
+ return { plan: current, applied: false };
13880
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
13881
+ }
13778
13882
  async createTemplateWithTasks(template, tasks, context = {}) {
13779
13883
  await this.ensureSchema();
13780
13884
  const records = [
@@ -16879,7 +16983,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
16879
16983
  }
16880
16984
  if (result.errors.length > 0)
16881
16985
  return result;
16882
- const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
16986
+ const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
16883
16987
  for (const row of rows) {
16884
16988
  try {
16885
16989
  const record = asRecord(row);
@@ -16888,7 +16992,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
16888
16992
  result.skipped += 1;
16889
16993
  continue;
16890
16994
  }
16891
- const state = upsertById(d, table, columns, record, updateClockColumn);
16995
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
16892
16996
  if (state === "inserted")
16893
16997
  result.inserted += 1;
16894
16998
  else if (state === "updated")
@@ -16905,10 +17009,10 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
16905
17009
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
16906
17010
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
16907
17011
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
16908
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
17012
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
16909
17013
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
16910
17014
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
16911
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", (row, changed) => {
17015
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", true, (row, changed) => {
16912
17016
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
16913
17017
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
16914
17018
  }
@@ -16917,7 +17021,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
16917
17021
  applyTombstones(d, snapshot.tombstones ?? [], result);
16918
17022
  return result;
16919
17023
  }
16920
- function upsertById(db, table, columns, row, updateClockColumn) {
17024
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
16921
17025
  const id = row["id"];
16922
17026
  if (typeof id !== "string" || !id)
16923
17027
  throw new Error(`${table} row is missing id`);
@@ -16929,7 +17033,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
16929
17033
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
16930
17034
  const updateColumns = presentColumns.filter((column) => column !== "id");
16931
17035
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
16932
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
17036
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
16933
17037
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
16934
17038
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
16935
17039
  const changes = db.run(sql, values).changes;
@@ -17169,6 +17273,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
17169
17273
  get: (id) => getPlan(id, database()),
17170
17274
  list: (projectId) => listPlans(projectId, database()),
17171
17275
  update: (id, input) => updatePlan2(id, input, database()),
17276
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
17172
17277
  delete: (id) => deletePlan(id, database())
17173
17278
  },
17174
17279
  planProjectLinks: {