@hasna/todos 0.15.18 → 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);
@@ -21368,7 +21423,7 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
21368
21423
  return input.toLowerCase();
21369
21424
  return (await cloudResolveTaskList(client, ref, projectId)).id;
21370
21425
  }
21371
- var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21426
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
21372
21427
  var init_cloud_router = __esm(() => {
21373
21428
  init_types();
21374
21429
  init_redaction();
@@ -21385,6 +21440,7 @@ var init_cloud_router = __esm(() => {
21385
21440
  hybrid: "http"
21386
21441
  };
21387
21442
  completionCapabilityCache = new Map;
21443
+ retryCapabilityCache = new Map;
21388
21444
  gitRefCapabilityCache = new Map;
21389
21445
  SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
21390
21446
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
@@ -35481,7 +35537,7 @@ var package_default;
35481
35537
  var init_package = __esm(() => {
35482
35538
  package_default = {
35483
35539
  name: "@hasna/todos",
35484
- version: "0.15.18",
35540
+ version: "0.15.19",
35485
35541
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
35486
35542
  type: "module",
35487
35543
  main: "dist/index.js",
@@ -45077,7 +45133,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45077
45133
  }
45078
45134
  if (result.errors.length > 0)
45079
45135
  return result;
45080
- const applyRows = (objectType2, table, columns, rows, updateClockColumn, afterUpsert) => {
45136
+ const applyRows = (objectType2, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
45081
45137
  for (const row of rows) {
45082
45138
  try {
45083
45139
  const record = asRecord2(row);
@@ -45086,7 +45142,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45086
45142
  result.skipped += 1;
45087
45143
  continue;
45088
45144
  }
45089
- const state = upsertById(d, table, columns, record, updateClockColumn);
45145
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
45090
45146
  if (state === "inserted")
45091
45147
  result.inserted += 1;
45092
45148
  else if (state === "updated")
@@ -45103,10 +45159,10 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45103
45159
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
45104
45160
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
45105
45161
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
45106
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
45162
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
45107
45163
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
45108
45164
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
45109
- 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) => {
45110
45166
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
45111
45167
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
45112
45168
  }
@@ -45115,7 +45171,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
45115
45171
  applyTombstones(d, snapshot.tombstones ?? [], result);
45116
45172
  return result;
45117
45173
  }
45118
- function upsertById(db, table, columns, row, updateClockColumn) {
45174
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
45119
45175
  const id = row["id"];
45120
45176
  if (typeof id !== "string" || !id)
45121
45177
  throw new Error(`${table} row is missing id`);
@@ -45127,7 +45183,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
45127
45183
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
45128
45184
  const updateColumns = presentColumns.filter((column) => column !== "id");
45129
45185
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
45130
- 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}` : "";
45131
45187
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})
45132
45188
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders2})`;
45133
45189
  const changes = db.run(sql, values).changes;
@@ -45546,6 +45602,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
45546
45602
  get: (id) => getPlan(id, database()),
45547
45603
  list: (projectId) => listPlans(projectId, database()),
45548
45604
  update: (id, input) => updatePlan(id, input, database()),
45605
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
45549
45606
  delete: (id) => deletePlan(id, database())
45550
45607
  },
45551
45608
  planProjectLinks: {
@@ -46506,6 +46563,7 @@ function createPostgresTodosStorageAdapter(options) {
46506
46563
  get: (id) => store.get("plans", id),
46507
46564
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
46508
46565
  update: (id, input) => updatePlan2(id, input, store),
46566
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
46509
46567
  delete: (id, context) => store.deletePlan(id, context)
46510
46568
  },
46511
46569
  planProjectLinks: {
@@ -46993,6 +47051,54 @@ class PostgresJsonRecordStore {
46993
47051
  throw new PlanNotFoundError(value.id);
46994
47052
  return payloadRecord2(row.payload);
46995
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
+ }
46996
47102
  async createTemplateWithTasks(template, tasks, context = {}) {
46997
47103
  await this.ensureSchema();
46998
47104
  const records = [
@@ -55534,8 +55640,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55534
55640
  "/v1/import": {
55535
55641
  post: {
55536
55642
  operationId: "importSnapshot",
55537
- summary: "Bulk-ingest a full or partial snapshot (idempotent upsert by id)",
55538
- 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.",
55539
55645
  requestBody: {
55540
55646
  required: true,
55541
55647
  content: {
@@ -55554,7 +55660,22 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55554
55660
  templates: { type: "array", items: { type: "object" } },
55555
55661
  templateTasks: { type: "array", items: { $ref: "#/components/schemas/TemplateTask" } },
55556
55662
  auditHistory: { type: "array", items: { type: "object" } },
55557
- 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
+ }
55558
55679
  }
55559
55680
  }
55560
55681
  }
@@ -55577,6 +55698,26 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
55577
55698
  skipped: { type: "number" },
55578
55699
  errors: { type: "array", items: { type: "string" } }
55579
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
+ }
55580
55721
  }
55581
55722
  }
55582
55723
  }
@@ -56773,6 +56914,65 @@ function normalizeImportSnapshot(raw) {
56773
56914
  function countSnapshotRecords(s) {
56774
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);
56775
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
+ }
56776
56976
  async function handleV1Request(req, url, dependencies = {}) {
56777
56977
  const path = url.pathname;
56778
56978
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -57709,6 +57909,36 @@ async function handleV1Request(req, url, dependencies = {}) {
57709
57909
  return error(400, "invalid JSON body");
57710
57910
  const snapshot = normalizeImportSnapshot(raw);
57711
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
+ }
57712
57942
  if (received === 0) {
57713
57943
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
57714
57944
  }
@@ -57735,6 +57965,18 @@ async function handleV1Request(req, url, dependencies = {}) {
57735
57965
  if (e instanceof TaskNotFoundError) {
57736
57966
  return error(404, e.message, { code: TaskNotFoundError.code });
57737
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
+ }
57738
57980
  if (e instanceof LockError)
57739
57981
  return error(409, e.message, { code: LockError.code });
57740
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.18",
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.18",
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: {