@hasna/todos 0.15.18 → 0.15.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/cli/cloud-router.d.ts +20 -1
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/query-commands.d.ts.map +1 -1
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/index.js +1306 -122
  7. package/dist/cli/stage-a.d.ts +21 -10
  8. package/dist/cli/stage-a.d.ts.map +1 -1
  9. package/dist/contracts.js +283 -16
  10. package/dist/db/audit.d.ts +8 -0
  11. package/dist/db/audit.d.ts.map +1 -1
  12. package/dist/db/plans.d.ts +4 -0
  13. package/dist/db/plans.d.ts.map +1 -1
  14. package/dist/db/task-lifecycle.d.ts +7 -1
  15. package/dist/db/task-lifecycle.d.ts.map +1 -1
  16. package/dist/db/tasks.d.ts +1 -1
  17. package/dist/db/tasks.d.ts.map +1 -1
  18. package/dist/index.js +599 -26
  19. package/dist/lib/cli-help.d.ts +3 -2
  20. package/dist/lib/cli-help.d.ts.map +1 -1
  21. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  22. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  23. package/dist/mcp/index.js +954 -46
  24. package/dist/mcp.js +3 -1
  25. package/dist/project-registration.js +4250 -3684
  26. package/dist/registry.js +283 -16
  27. package/dist/release-provenance.json +5 -5
  28. package/dist/sdk/index.js +7 -0
  29. package/dist/sdk/v1.generated.d.ts +40 -1
  30. package/dist/sdk/v1.generated.d.ts.map +1 -1
  31. package/dist/server/index.js +1287 -379
  32. package/dist/server/openapi.d.ts +232 -0
  33. package/dist/server/openapi.d.ts.map +1 -1
  34. package/dist/server/v1.d.ts.map +1 -1
  35. package/dist/storage/audit-history-import.d.ts +14 -0
  36. package/dist/storage/audit-history-import.d.ts.map +1 -0
  37. package/dist/storage/interfaces.d.ts +22 -1
  38. package/dist/storage/interfaces.d.ts.map +1 -1
  39. package/dist/storage/local-sqlite.d.ts.map +1 -1
  40. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  41. package/dist/storage/shadow.d.ts.map +1 -1
  42. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  43. package/dist/storage.js +596 -25
  44. package/dist/task-manifest.js +24 -1
  45. package/dist/types/index.d.ts +50 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/package.json +3 -1
@@ -70,7 +70,7 @@ var package_default;
70
70
  var init_package = __esm(() => {
71
71
  package_default = {
72
72
  name: "@hasna/todos",
73
- version: "0.15.18",
73
+ version: "0.15.20",
74
74
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
75
75
  type: "module",
76
76
  main: "dist/index.js",
@@ -141,6 +141,8 @@ var init_package = __esm(() => {
141
141
  "dev:mcp": "bun run src/mcp/index.ts",
142
142
  "dev:serve": "bun run src/server/index.ts",
143
143
  "verify:release": "bun run scripts/verify-public-release.ts --mode=review",
144
+ "verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
145
+ "issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
144
146
  prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
145
147
  postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
146
148
  },
@@ -861,7 +863,7 @@ function isBlockingDependencyStatus(status) {
861
863
  function isTerminalStatus(status) {
862
864
  return status === "completed" || status === "failed" || status === "cancelled";
863
865
  }
864
- var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
866
+ var TASK_STATUSES, TASK_PRIORITIES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DispatchNotFoundError;
865
867
  var init_types = __esm(() => {
866
868
  TASK_STATUSES = [
867
869
  "pending",
@@ -948,6 +950,19 @@ var init_types = __esm(() => {
948
950
  this.name = "ResourceConflictError";
949
951
  }
950
952
  };
953
+ PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
954
+ planId;
955
+ expectedUpdatedAt;
956
+ currentUpdatedAt;
957
+ static code = "PLAN_REVISION_CONFLICT";
958
+ constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
959
+ super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
960
+ this.planId = planId;
961
+ this.expectedUpdatedAt = expectedUpdatedAt;
962
+ this.currentUpdatedAt = currentUpdatedAt;
963
+ this.name = "PlanRevisionConflictError";
964
+ }
965
+ };
951
966
  PlanNotFoundError = class PlanNotFoundError extends Error {
952
967
  planId;
953
968
  static code = "PLAN_NOT_FOUND";
@@ -970,6 +985,16 @@ var init_types = __esm(() => {
970
985
  this.name = "LockError";
971
986
  }
972
987
  };
988
+ StaleLockHandoffError = class StaleLockHandoffError extends Error {
989
+ code;
990
+ details;
991
+ constructor(code, message, details = {}) {
992
+ super(message);
993
+ this.code = code;
994
+ this.details = details;
995
+ this.name = "StaleLockHandoffError";
996
+ }
997
+ };
973
998
  AgentNotFoundError = class AgentNotFoundError extends Error {
974
999
  agentId;
975
1000
  static code = "AGENT_NOT_FOUND";
@@ -1183,6 +1208,501 @@ var init_plan_project_link_contract = __esm(() => {
1183
1208
  };
1184
1209
  });
1185
1210
 
1211
+ // src/lib/config.ts
1212
+ import { existsSync as existsSync3 } from "fs";
1213
+ import { dirname, join as join3 } from "path";
1214
+ function getConfigPath() {
1215
+ return join3(getTodosGlobalDir(), "config.json");
1216
+ }
1217
+ function loadConfig() {
1218
+ if (cached)
1219
+ return cached;
1220
+ if (!existsSync3(getConfigPath())) {
1221
+ cached = {};
1222
+ return cached;
1223
+ }
1224
+ const config = readJsonFile(getConfigPath()) || {};
1225
+ if (typeof config.sync_agents === "string") {
1226
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1227
+ }
1228
+ cached = config;
1229
+ return cached;
1230
+ }
1231
+ function saveConfig(config) {
1232
+ const configPath = getConfigPath();
1233
+ ensureDir(dirname(configPath));
1234
+ writeJsonFile(configPath, config);
1235
+ cached = config;
1236
+ return config;
1237
+ }
1238
+ function getAgentPoolForProject(workingDir) {
1239
+ const config = loadConfig();
1240
+ if (workingDir && config.project_pools) {
1241
+ let bestKey = null;
1242
+ let bestLen = 0;
1243
+ for (const key of Object.keys(config.project_pools)) {
1244
+ if (workingDir.startsWith(key) && key.length > bestLen) {
1245
+ bestKey = key;
1246
+ bestLen = key.length;
1247
+ }
1248
+ }
1249
+ if (bestKey && config.project_pools[bestKey]) {
1250
+ return config.project_pools[bestKey];
1251
+ }
1252
+ }
1253
+ return config.agent_pool || null;
1254
+ }
1255
+ function getCompletionGuardConfig(projectPath) {
1256
+ const config = loadConfig();
1257
+ const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
1258
+ if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
1259
+ return { ...global, ...config.project_overrides[projectPath].completion_guard };
1260
+ }
1261
+ return global;
1262
+ }
1263
+ var cached = null, GUARD_DEFAULTS;
1264
+ var init_config2 = __esm(() => {
1265
+ init_sync_utils();
1266
+ GUARD_DEFAULTS = {
1267
+ enabled: false,
1268
+ min_work_seconds: 30,
1269
+ max_completions_per_window: 5,
1270
+ window_minutes: 10,
1271
+ cooldown_seconds: 60
1272
+ };
1273
+ });
1274
+
1275
+ // src/lib/redaction.ts
1276
+ function unique(values) {
1277
+ return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
1278
+ }
1279
+ function cloneRegex(regex) {
1280
+ return new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
1281
+ }
1282
+ function customPatterns() {
1283
+ return unique(loadConfig().secret_safety?.redaction_patterns).flatMap((pattern) => {
1284
+ try {
1285
+ return [{ name: `custom:${pattern}`, regex: new RegExp(pattern, "g") }];
1286
+ } catch {
1287
+ return [];
1288
+ }
1289
+ });
1290
+ }
1291
+ function secretPatterns() {
1292
+ return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
1293
+ }
1294
+ function isRedactionPlaceholderMatch(match) {
1295
+ const trimmed = match.trim();
1296
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(trimmed) || new RegExp(`=\\s*['"]?${REDACTION_PLACEHOLDER}['"]?$`).test(trimmed);
1297
+ }
1298
+ function isRedactionPlaceholderKey(key) {
1299
+ return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(key.trim());
1300
+ }
1301
+ function isSecretKey(key) {
1302
+ if (isRedactionPlaceholderKey(key))
1303
+ return false;
1304
+ if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
1305
+ return false;
1306
+ if (DEFAULT_SECRET_KEY_PATTERN.test(key))
1307
+ return true;
1308
+ return unique(loadConfig().secret_safety?.redaction_keys).some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()));
1309
+ }
1310
+ function redactEvidenceText(value) {
1311
+ let redacted = value;
1312
+ for (const pattern of secretPatterns()) {
1313
+ const regex = cloneRegex(pattern.regex);
1314
+ const replacement = pattern.replacement ?? "[REDACTED]";
1315
+ redacted = typeof replacement === "string" ? redacted.replace(regex, replacement) : redacted.replace(regex, replacement);
1316
+ }
1317
+ return redacted;
1318
+ }
1319
+ function redactValue(value) {
1320
+ if (typeof value === "string")
1321
+ return redactEvidenceText(value);
1322
+ if (Array.isArray(value))
1323
+ return value.map(redactValue);
1324
+ if (value && typeof value === "object") {
1325
+ const redacted = {};
1326
+ for (const [key, child] of Object.entries(value)) {
1327
+ if (isSecretKey(key)) {
1328
+ redacted[key] = "[REDACTED]";
1329
+ } else {
1330
+ redacted[key] = redactValue(child);
1331
+ }
1332
+ }
1333
+ return redacted;
1334
+ }
1335
+ return value;
1336
+ }
1337
+ function listSecretFindings(value) {
1338
+ const findings = [];
1339
+ for (const pattern of secretPatterns()) {
1340
+ const matches = value.match(cloneRegex(pattern.regex))?.filter((match) => !isRedactionPlaceholderMatch(match));
1341
+ if (matches?.length)
1342
+ findings.push({ pattern: pattern.name, count: matches.length });
1343
+ }
1344
+ return findings;
1345
+ }
1346
+ function getSecretSafetyConfig() {
1347
+ return {
1348
+ redaction_patterns: unique(loadConfig().secret_safety?.redaction_patterns),
1349
+ redaction_keys: unique(loadConfig().secret_safety?.redaction_keys)
1350
+ };
1351
+ }
1352
+ function upsertSecretSafetyConfig(input) {
1353
+ const config = loadConfig();
1354
+ const next = {
1355
+ redaction_patterns: unique([...config.secret_safety?.redaction_patterns || [], ...input.redaction_patterns || []]),
1356
+ redaction_keys: unique([...config.secret_safety?.redaction_keys || [], ...input.redaction_keys || []])
1357
+ };
1358
+ saveConfig({ ...config, secret_safety: next });
1359
+ return next;
1360
+ }
1361
+ var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
1362
+ var init_redaction = __esm(() => {
1363
+ init_config2();
1364
+ DEFAULT_SECRET_PATTERNS = [
1365
+ { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
1366
+ { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
1367
+ { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
1368
+ { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
1369
+ { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
1370
+ { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
1371
+ { name: "env-secret-assignment", regex: /\b([A-Za-z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*['"]?[^'"\s]{8,}/gi, replacement: "$1=[REDACTED]" },
1372
+ { name: "bearer-token", regex: /\b(bearer)\s+[A-Za-z0-9._~+/=-]{12,}/gi, replacement: "$1 [REDACTED]" }
1373
+ ];
1374
+ DEFAULT_SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password/i;
1375
+ NON_SECRET_USAGE_KEYS = new Set([
1376
+ "tokens",
1377
+ "total_tokens",
1378
+ "token_count",
1379
+ "input_tokens",
1380
+ "output_tokens",
1381
+ "prompt_tokens",
1382
+ "completion_tokens",
1383
+ "cost_tokens"
1384
+ ]);
1385
+ REDACTION_PLACEHOLDER = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
1386
+ });
1387
+
1388
+ // src/lib/secret-redaction.ts
1389
+ function isAllowlisted(text, match, allowlist) {
1390
+ const context = text.slice(Math.max(0, text.indexOf(match) - 20), text.indexOf(match) + match.length + 20);
1391
+ return allowlist.some((re) => re.test(context) || re.test(match));
1392
+ }
1393
+ function scanTextForSecrets(text, options = {}) {
1394
+ const allowlist = [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []];
1395
+ const matches = [];
1396
+ const patterns = [
1397
+ ...DEFAULT_PATTERNS,
1398
+ ...options.custom_patterns?.map((p, i) => ({ name: `custom_${i}`, pattern: p })) ?? []
1399
+ ];
1400
+ for (const { name, pattern, allowlist_ok } of patterns) {
1401
+ const re = new RegExp(pattern.source, pattern.flags);
1402
+ let m;
1403
+ while ((m = re.exec(text)) !== null) {
1404
+ const match = m[0];
1405
+ if (allowlist_ok && isAllowlisted(text, match, allowlist))
1406
+ continue;
1407
+ if (isAllowlisted(text, match, allowlist))
1408
+ continue;
1409
+ const line = text.slice(0, m.index).split(`
1410
+ `).length;
1411
+ matches.push({ pattern: name, match: match.slice(0, 12) + (match.length > 12 ? "\u2026" : ""), index: m.index, line });
1412
+ }
1413
+ }
1414
+ return {
1415
+ schema_version: SECRET_REDACTION_SCHEMA,
1416
+ clean: matches.length === 0,
1417
+ matches
1418
+ };
1419
+ }
1420
+ function redactText(text, options = {}) {
1421
+ const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER2;
1422
+ let out = text;
1423
+ for (const { pattern, allowlist_ok } of DEFAULT_PATTERNS) {
1424
+ out = out.replace(new RegExp(pattern.source, pattern.flags), (match) => {
1425
+ if (allowlist_ok && isAllowlisted(out, match, [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []])) {
1426
+ return match;
1427
+ }
1428
+ return placeholder;
1429
+ });
1430
+ }
1431
+ for (const custom of options.custom_patterns ?? []) {
1432
+ out = out.replace(custom, placeholder);
1433
+ }
1434
+ for (const fn of customRedactors) {
1435
+ out = fn(out);
1436
+ }
1437
+ return out;
1438
+ }
1439
+ function redactExportRecord(record) {
1440
+ const base = redactValue(record);
1441
+ for (const [key, value] of Object.entries(base)) {
1442
+ if (typeof value === "string") {
1443
+ base[key] = redactText(value);
1444
+ }
1445
+ }
1446
+ return base;
1447
+ }
1448
+ var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER2 = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
1449
+ var init_secret_redaction = __esm(() => {
1450
+ init_redaction();
1451
+ SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
1452
+ DEFAULT_PATTERNS = [
1453
+ { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
1454
+ { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
1455
+ { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
1456
+ { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
1457
+ { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
1458
+ { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
1459
+ { name: "private_key_block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
1460
+ { name: "generic_api_key", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?[a-zA-Z0-9\-._]{8,}['"]?/gi, allowlist_ok: true }
1461
+ ];
1462
+ DEFAULT_ALLOWLIST = [
1463
+ /\[REDACTED\]/,
1464
+ /example\.com/i,
1465
+ /your-api-key-here/i,
1466
+ /sk-test/i,
1467
+ /ghp_xxx/i
1468
+ ];
1469
+ customRedactors = [];
1470
+ });
1471
+
1472
+ // src/lib/prewrite-secrets.ts
1473
+ function mergeFindings(findings) {
1474
+ const byPattern = new Map;
1475
+ for (const finding of findings) {
1476
+ const existing = byPattern.get(finding.pattern);
1477
+ if (!existing) {
1478
+ byPattern.set(finding.pattern, {
1479
+ pattern: finding.pattern,
1480
+ count: finding.count,
1481
+ lines: Array.from(new Set(finding.lines)).sort((a, b) => a - b)
1482
+ });
1483
+ continue;
1484
+ }
1485
+ existing.count += finding.count;
1486
+ existing.lines = Array.from(new Set([...existing.lines, ...finding.lines])).sort((a, b) => a - b);
1487
+ }
1488
+ return [...byPattern.values()].sort((left, right) => left.pattern.localeCompare(right.pattern));
1489
+ }
1490
+ function redactPreWriteText(value) {
1491
+ return redactText(redactEvidenceText(value));
1492
+ }
1493
+ function scanPreWriteText(value, context = "text") {
1494
+ const redacted = redactPreWriteText(value);
1495
+ if (redacted === value) {
1496
+ return {
1497
+ schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
1498
+ clean: true,
1499
+ context,
1500
+ findings: []
1501
+ };
1502
+ }
1503
+ const structural = scanTextForSecrets(value).matches.map((match) => ({
1504
+ pattern: match.pattern,
1505
+ count: 1,
1506
+ lines: match.line ? [match.line] : []
1507
+ }));
1508
+ const configured = listSecretFindings(value).map((finding) => ({
1509
+ pattern: finding.pattern,
1510
+ count: finding.count,
1511
+ lines: []
1512
+ }));
1513
+ return {
1514
+ schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
1515
+ clean: false,
1516
+ context,
1517
+ findings: mergeFindings([...structural, ...configured])
1518
+ };
1519
+ }
1520
+ function assertPreWriteTextClean(value, context = "text") {
1521
+ const scan = scanPreWriteText(value, context);
1522
+ if (!scan.clean)
1523
+ throw new PreWriteSecretError(context, scan.findings);
1524
+ }
1525
+ function sanitizePreWriteText(value, context = "text", options = {}) {
1526
+ const mode = options.mode ?? "redact";
1527
+ if (mode === "block") {
1528
+ assertPreWriteTextClean(value, context);
1529
+ return value;
1530
+ }
1531
+ return redactPreWriteText(value);
1532
+ }
1533
+ function sanitizePreWriteValue(value, context = "value", options = {}) {
1534
+ if (typeof value === "string")
1535
+ return sanitizePreWriteText(value, context, options);
1536
+ if (Array.isArray(value)) {
1537
+ return value.map((item, index) => sanitizePreWriteValue(item, `${context}[${index}]`, options));
1538
+ }
1539
+ if (value && typeof value === "object") {
1540
+ const sanitized = {};
1541
+ for (const [key, child] of Object.entries(value)) {
1542
+ const safeKey = sanitizePreWriteText(key, `${context}.$key`, options);
1543
+ sanitized[safeKey] = sanitizePreWriteValue(child, `${context}.${safeKey}`, options);
1544
+ }
1545
+ return redactValue(sanitized);
1546
+ }
1547
+ return value;
1548
+ }
1549
+ var PREWRITE_SECRET_SCAN_SCHEMA, PreWriteSecretError;
1550
+ var init_prewrite_secrets = __esm(() => {
1551
+ init_redaction();
1552
+ init_secret_redaction();
1553
+ PREWRITE_SECRET_SCAN_SCHEMA = ["todos", "prewrite_secret_scan", "v1"].join(".");
1554
+ PreWriteSecretError = class PreWriteSecretError extends Error {
1555
+ context;
1556
+ findings;
1557
+ constructor(context, findings) {
1558
+ const summary = findings.map((finding) => `${finding.pattern}:${finding.count}`).join(", ");
1559
+ super(`Secret pattern detected before persistence in ${context}: ${summary}`);
1560
+ this.name = "PreWriteSecretError";
1561
+ this.context = context;
1562
+ this.findings = findings;
1563
+ }
1564
+ };
1565
+ });
1566
+
1567
+ // src/lib/stale-lock-handoff.ts
1568
+ function normalizeExactTaskId(value) {
1569
+ if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
1570
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_TASK_ID", "stale-lock handoff requires one exact full task UUID", { task_id: typeof value === "string" ? value : null });
1571
+ }
1572
+ return value.trim().toLowerCase();
1573
+ }
1574
+ function requireNonEmptyString(value, field) {
1575
+ if (typeof value !== "string" || !value.trim()) {
1576
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
1577
+ }
1578
+ const trimmed = value.trim();
1579
+ if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
1580
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
1581
+ }
1582
+ return trimmed;
1583
+ }
1584
+ function requireCanonicalLockVersion(value) {
1585
+ if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
1586
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must be the exact canonical locked_at timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)", { field: "expected_lock_version" });
1587
+ }
1588
+ const parsed = Date.parse(value);
1589
+ if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
1590
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
1591
+ }
1592
+ return value;
1593
+ }
1594
+ function requireStaleThreshold(value) {
1595
+ if (!Number.isSafeInteger(value) || Number(value) <= 0) {
1596
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
1597
+ }
1598
+ return Number(value);
1599
+ }
1600
+ function prepareStaleLockHandoff(input, options = {}) {
1601
+ const taskId = normalizeExactTaskId(input.task_id);
1602
+ const actor = requireNonEmptyString(input.actor, "actor");
1603
+ const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
1604
+ const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
1605
+ const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
1606
+ const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
1607
+ const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
1608
+ if (!reason) {
1609
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
1610
+ }
1611
+ if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
1612
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
1613
+ }
1614
+ if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
1615
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
1616
+ }
1617
+ const operationTimestamp = options.now ?? new Date().toISOString();
1618
+ if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
1619
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
1620
+ }
1621
+ const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
1622
+ return {
1623
+ task_id: taskId,
1624
+ actor,
1625
+ expected_holder: expectedHolder,
1626
+ expected_lock_version: expectedLockVersion,
1627
+ stale_after_seconds: staleAfterSeconds,
1628
+ new_holder: newHolder,
1629
+ reason,
1630
+ operation_timestamp: operationTimestamp,
1631
+ stale_cutoff: staleCutoff,
1632
+ receipt_id: options.receiptId ?? crypto.randomUUID()
1633
+ };
1634
+ }
1635
+ function buildStaleLockHandoffReceipt(input) {
1636
+ return {
1637
+ schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
1638
+ receipt_id: input.receipt_id,
1639
+ task_id: input.task_id,
1640
+ actor: input.actor,
1641
+ previous_holder: input.expected_holder,
1642
+ previous_lock_version: input.expected_lock_version,
1643
+ new_holder: input.new_holder,
1644
+ new_lock_version: input.operation_timestamp,
1645
+ stale_after_seconds: input.stale_after_seconds,
1646
+ stale_cutoff: input.stale_cutoff,
1647
+ reason: input.reason,
1648
+ created_at: input.operation_timestamp
1649
+ };
1650
+ }
1651
+ function staleLockHandoffHistory(receipt, machineId) {
1652
+ return {
1653
+ id: receipt.receipt_id,
1654
+ task_id: receipt.task_id,
1655
+ action: STALE_LOCK_HANDOFF_ACTION,
1656
+ field: STALE_LOCK_HANDOFF_FIELD,
1657
+ old_value: JSON.stringify({
1658
+ holder: receipt.previous_holder,
1659
+ lock_version: receipt.previous_lock_version
1660
+ }),
1661
+ new_value: JSON.stringify(receipt),
1662
+ agent_id: receipt.actor,
1663
+ created_at: receipt.created_at,
1664
+ machine_id: machineId
1665
+ };
1666
+ }
1667
+ function throwStaleLockHandoffConflict(task, input) {
1668
+ if (!task.locked_by || !task.locked_at) {
1669
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_LOCKED", `Task ${input.task_id} does not have a complete lock to hand off`, { task_id: input.task_id });
1670
+ }
1671
+ if (task.locked_at !== input.expected_lock_version) {
1672
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
1673
+ task_id: input.task_id,
1674
+ expected_lock_version: input.expected_lock_version,
1675
+ current_lock_version: task.locked_at
1676
+ });
1677
+ }
1678
+ if (task.locked_by !== input.expected_holder) {
1679
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
1680
+ task_id: input.task_id,
1681
+ expected_holder: input.expected_holder,
1682
+ current_holder: task.locked_by
1683
+ });
1684
+ }
1685
+ if (isTerminalStatus(task.status)) {
1686
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_TERMINAL", `Task ${input.task_id} is ${task.status} and cannot transfer a lock`, { task_id: input.task_id, status: task.status });
1687
+ }
1688
+ if (task.locked_at >= input.stale_cutoff) {
1689
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
1690
+ task_id: input.task_id,
1691
+ current_lock_version: task.locked_at,
1692
+ stale_cutoff: input.stale_cutoff
1693
+ });
1694
+ }
1695
+ throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
1696
+ }
1697
+ var STALE_LOCK_HANDOFF_SCHEMA_VERSION = "todos.stale-lock-handoff.v1", STALE_LOCK_HANDOFF_ACTION = "stale_lock_handoff", STALE_LOCK_HANDOFF_FIELD = "lock", EXACT_TASK_UUID_RE, CANONICAL_LOCK_VERSION_RE, MAX_REASON_LENGTH = 4096;
1698
+ var init_stale_lock_handoff = __esm(() => {
1699
+ init_types();
1700
+ init_creator_identity();
1701
+ init_prewrite_secrets();
1702
+ EXACT_TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1703
+ CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1704
+ });
1705
+
1186
1706
  // src/lib/slugs.ts
1187
1707
  function normalizeSlug(value) {
1188
1708
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1849,181 +2369,56 @@ var init_integrity = __esm(() => {
1849
2369
  };
1850
2370
  });
1851
2371
 
1852
- // src/lib/config.ts
1853
- import { existsSync as existsSync3 } from "fs";
1854
- import { dirname, join as join3 } from "path";
1855
- function getConfigPath() {
1856
- return join3(getTodosGlobalDir(), "config.json");
1857
- }
1858
- function loadConfig() {
1859
- if (cached)
1860
- return cached;
1861
- if (!existsSync3(getConfigPath())) {
1862
- cached = {};
1863
- return cached;
1864
- }
1865
- const config = readJsonFile(getConfigPath()) || {};
1866
- if (typeof config.sync_agents === "string") {
1867
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1868
- }
1869
- cached = config;
1870
- return cached;
1871
- }
1872
- function saveConfig(config) {
1873
- const configPath = getConfigPath();
1874
- ensureDir(dirname(configPath));
1875
- writeJsonFile(configPath, config);
1876
- cached = config;
1877
- return config;
1878
- }
1879
- function getAgentPoolForProject(workingDir) {
1880
- const config = loadConfig();
1881
- if (workingDir && config.project_pools) {
1882
- let bestKey = null;
1883
- let bestLen = 0;
1884
- for (const key of Object.keys(config.project_pools)) {
1885
- if (workingDir.startsWith(key) && key.length > bestLen) {
1886
- bestKey = key;
1887
- bestLen = key.length;
1888
- }
1889
- }
1890
- if (bestKey && config.project_pools[bestKey]) {
1891
- return config.project_pools[bestKey];
1892
- }
1893
- }
1894
- return config.agent_pool || null;
1895
- }
1896
- function getCompletionGuardConfig(projectPath) {
1897
- const config = loadConfig();
1898
- const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
1899
- if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
1900
- return { ...global, ...config.project_overrides[projectPath].completion_guard };
1901
- }
1902
- return global;
1903
- }
1904
- var cached = null, GUARD_DEFAULTS;
1905
- var init_config2 = __esm(() => {
1906
- init_sync_utils();
1907
- GUARD_DEFAULTS = {
1908
- enabled: false,
1909
- min_work_seconds: 30,
1910
- max_completions_per_window: 5,
1911
- window_minutes: 10,
1912
- cooldown_seconds: 60
1913
- };
1914
- });
1915
-
1916
- // src/lib/redaction.ts
1917
- function unique(values) {
1918
- return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
1919
- }
1920
- function cloneRegex(regex) {
1921
- return new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
1922
- }
1923
- function customPatterns() {
1924
- return unique(loadConfig().secret_safety?.redaction_patterns).flatMap((pattern) => {
1925
- try {
1926
- return [{ name: `custom:${pattern}`, regex: new RegExp(pattern, "g") }];
1927
- } catch {
1928
- return [];
1929
- }
2372
+ // src/storage/audit-history-import.ts
2373
+ function auditHistoryRowsAreFieldIdentical(left, right) {
2374
+ return AUDIT_HISTORY_FIELDS.every((field) => {
2375
+ const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
2376
+ const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
2377
+ return leftValue === rightValue;
1930
2378
  });
1931
2379
  }
1932
- function secretPatterns() {
1933
- return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
2380
+ function divergentAuditHistoryReplayError(id) {
2381
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
1934
2382
  }
1935
- function isRedactionPlaceholderMatch(match) {
1936
- const trimmed = match.trim();
1937
- return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(trimmed) || new RegExp(`=\\s*['"]?${REDACTION_PLACEHOLDER}['"]?$`).test(trimmed);
2383
+ function forbiddenAuditHistoryTombstoneError(id) {
2384
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
1938
2385
  }
1939
- function isRedactionPlaceholderKey(key) {
1940
- return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(key.trim());
1941
- }
1942
- function isSecretKey(key) {
1943
- if (isRedactionPlaceholderKey(key))
1944
- return false;
1945
- if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
1946
- return false;
1947
- if (DEFAULT_SECRET_KEY_PATTERN.test(key))
1948
- return true;
1949
- return unique(loadConfig().secret_safety?.redaction_keys).some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()));
1950
- }
1951
- function redactEvidenceText(value) {
1952
- let redacted = value;
1953
- for (const pattern of secretPatterns()) {
1954
- const regex = cloneRegex(pattern.regex);
1955
- const replacement = pattern.replacement ?? "[REDACTED]";
1956
- redacted = typeof replacement === "string" ? redacted.replace(regex, replacement) : redacted.replace(regex, replacement);
1957
- }
1958
- return redacted;
1959
- }
1960
- function redactValue(value) {
1961
- if (typeof value === "string")
1962
- return redactEvidenceText(value);
1963
- if (Array.isArray(value))
1964
- return value.map(redactValue);
1965
- if (value && typeof value === "object") {
1966
- const redacted = {};
1967
- for (const [key, child] of Object.entries(value)) {
1968
- if (isSecretKey(key)) {
1969
- redacted[key] = "[REDACTED]";
1970
- } else {
1971
- redacted[key] = redactValue(child);
1972
- }
1973
- }
1974
- return redacted;
2386
+ function parseAuditHistoryImportFailure(message) {
2387
+ const divergentPrefix = `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row `;
2388
+ const divergentSuffix = " differs from stored row";
2389
+ if (message.startsWith(divergentPrefix) && message.endsWith(divergentSuffix)) {
2390
+ return {
2391
+ code: AUDIT_HISTORY_DIVERGENT_REPLAY,
2392
+ auditHistoryId: message.slice(divergentPrefix.length, -divergentSuffix.length),
2393
+ conflict: true,
2394
+ status: 409
2395
+ };
1975
2396
  }
1976
- return value;
1977
- }
1978
- function listSecretFindings(value) {
1979
- const findings = [];
1980
- for (const pattern of secretPatterns()) {
1981
- const matches = value.match(cloneRegex(pattern.regex))?.filter((match) => !isRedactionPlaceholderMatch(match));
1982
- if (matches?.length)
1983
- findings.push({ pattern: pattern.name, count: matches.length });
2397
+ const tombstonePrefix = `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone `;
2398
+ const tombstoneSuffix = " is not allowed";
2399
+ if (message.startsWith(tombstonePrefix) && message.endsWith(tombstoneSuffix)) {
2400
+ return {
2401
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
2402
+ auditHistoryId: message.slice(tombstonePrefix.length, -tombstoneSuffix.length),
2403
+ conflict: false,
2404
+ status: 400
2405
+ };
1984
2406
  }
1985
- return findings;
1986
- }
1987
- function getSecretSafetyConfig() {
1988
- return {
1989
- redaction_patterns: unique(loadConfig().secret_safety?.redaction_patterns),
1990
- redaction_keys: unique(loadConfig().secret_safety?.redaction_keys)
1991
- };
1992
- }
1993
- function upsertSecretSafetyConfig(input) {
1994
- const config = loadConfig();
1995
- const next = {
1996
- redaction_patterns: unique([...config.secret_safety?.redaction_patterns || [], ...input.redaction_patterns || []]),
1997
- redaction_keys: unique([...config.secret_safety?.redaction_keys || [], ...input.redaction_keys || []])
1998
- };
1999
- saveConfig({ ...config, secret_safety: next });
2000
- return next;
2407
+ return null;
2001
2408
  }
2002
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
2003
- var init_redaction = __esm(() => {
2004
- init_config2();
2005
- DEFAULT_SECRET_PATTERNS = [
2006
- { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
2007
- { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
2008
- { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
2009
- { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
2010
- { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
2011
- { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
2012
- { name: "env-secret-assignment", regex: /\b([A-Za-z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)\s*=\s*['"]?[^'"\s]{8,}/gi, replacement: "$1=[REDACTED]" },
2013
- { name: "bearer-token", regex: /\b(bearer)\s+[A-Za-z0-9._~+/=-]{12,}/gi, replacement: "$1 [REDACTED]" }
2409
+ var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY", AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN", AUDIT_HISTORY_FIELDS;
2410
+ var init_audit_history_import = __esm(() => {
2411
+ AUDIT_HISTORY_FIELDS = [
2412
+ "id",
2413
+ "task_id",
2414
+ "action",
2415
+ "field",
2416
+ "old_value",
2417
+ "new_value",
2418
+ "agent_id",
2419
+ "created_at",
2420
+ "machine_id"
2014
2421
  ];
2015
- DEFAULT_SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password/i;
2016
- NON_SECRET_USAGE_KEYS = new Set([
2017
- "tokens",
2018
- "total_tokens",
2019
- "token_count",
2020
- "input_tokens",
2021
- "output_tokens",
2022
- "prompt_tokens",
2023
- "completion_tokens",
2024
- "cost_tokens"
2025
- ]);
2026
- REDACTION_PLACEHOLDER = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
2027
2422
  });
2028
2423
 
2029
2424
  // src/storage/postgres-adapter.ts
@@ -2056,6 +2451,7 @@ function createPostgresTodosStorageAdapter(options) {
2056
2451
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
2057
2452
  lock: (id, agentId) => lockTask(id, agentId, store),
2058
2453
  unlock: (id, agentId) => unlockTask(id, agentId, store),
2454
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
2059
2455
  getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
2060
2456
  },
2061
2457
  dependencies: {
@@ -2092,6 +2488,7 @@ function createPostgresTodosStorageAdapter(options) {
2092
2488
  get: (id) => store.get("plans", id),
2093
2489
  list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
2094
2490
  update: (id, input) => updatePlan(id, input, store),
2491
+ completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
2095
2492
  delete: (id, context) => store.deletePlan(id, context)
2096
2493
  },
2097
2494
  planProjectLinks: {
@@ -2201,6 +2598,91 @@ class PostgresJsonRecordStore {
2201
2598
  LIMIT 1`, [this.service, type, id]);
2202
2599
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
2203
2600
  }
2601
+ async handoffStaleLock(input, context = {}) {
2602
+ const prepared = prepareStaleLockHandoff(input);
2603
+ const receipt = buildStaleLockHandoffReceipt(prepared);
2604
+ const history = staleLockHandoffHistory(receipt, this.machineId(context));
2605
+ await this.ensureSchema();
2606
+ const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
2607
+ target AS MATERIALIZED (
2608
+ SELECT payload
2609
+ FROM ${this.tableName}
2610
+ WHERE service = $1
2611
+ AND object_type = 'tasks'
2612
+ AND object_id = $2
2613
+ AND deleted_at IS NULL
2614
+ FOR UPDATE
2615
+ ),
2616
+ updated AS (
2617
+ UPDATE ${this.tableName} AS task_record
2618
+ SET payload = jsonb_set(
2619
+ jsonb_set(
2620
+ jsonb_set(
2621
+ jsonb_set(
2622
+ task_record.payload,
2623
+ '{locked_by}',
2624
+ to_jsonb($6::text),
2625
+ true
2626
+ ),
2627
+ '{locked_at}',
2628
+ to_jsonb($7::text),
2629
+ true
2630
+ ),
2631
+ '{updated_at}',
2632
+ to_jsonb($7::text),
2633
+ true
2634
+ ),
2635
+ '{version}',
2636
+ to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
2637
+ true
2638
+ ),
2639
+ updated_at = $7::timestamptz,
2640
+ source_machine_id = $10,
2641
+ version = COALESCE(task_record.version, 0) + 1
2642
+ FROM target
2643
+ WHERE task_record.service = $1
2644
+ AND task_record.object_type = 'tasks'
2645
+ AND task_record.object_id = $2
2646
+ AND task_record.deleted_at IS NULL
2647
+ AND target.payload->>'locked_by' = $3
2648
+ AND target.payload->>'locked_at' = $4
2649
+ AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
2650
+ AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
2651
+ RETURNING task_record.payload
2652
+ ),
2653
+ audit AS (
2654
+ INSERT INTO ${this.tableName} (
2655
+ service, object_type, object_id, payload, updated_at,
2656
+ deleted_at, source_machine_id, version
2657
+ )
2658
+ SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
2659
+ NULL, $10, NULL
2660
+ FROM updated
2661
+ RETURNING payload
2662
+ )
2663
+ SELECT
2664
+ (SELECT payload FROM target) AS current_payload,
2665
+ (SELECT payload FROM updated) AS updated_payload,
2666
+ (SELECT payload FROM audit) AS audit_payload`, [
2667
+ this.service,
2668
+ prepared.task_id,
2669
+ prepared.expected_holder,
2670
+ prepared.expected_lock_version,
2671
+ prepared.stale_cutoff,
2672
+ prepared.new_holder,
2673
+ prepared.operation_timestamp,
2674
+ receipt.receipt_id,
2675
+ jsonbParam(history),
2676
+ this.machineId(context)
2677
+ ]);
2678
+ const row = result.rows[0];
2679
+ if (!row?.current_payload)
2680
+ throw new TaskNotFoundError(prepared.task_id);
2681
+ if (!row.updated_payload || !row.audit_payload) {
2682
+ throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
2683
+ }
2684
+ return receipt;
2685
+ }
2204
2686
  async list(type) {
2205
2687
  return (await this.listRecords(type)).map((record) => record.payload);
2206
2688
  }
@@ -2470,6 +2952,28 @@ class PostgresJsonRecordStore {
2470
2952
  }
2471
2953
  return value;
2472
2954
  }
2955
+ async insertImmutableAuditHistory(value, context = {}) {
2956
+ await this.ensureSchema();
2957
+ const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
2958
+ service, object_type, object_id, payload, updated_at,
2959
+ deleted_at, source_machine_id, version
2960
+ ) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
2961
+ ON CONFLICT (service, object_type, object_id) DO NOTHING
2962
+ RETURNING object_id`, [
2963
+ this.service,
2964
+ "audit_history",
2965
+ value.id,
2966
+ jsonbParam(value),
2967
+ value.created_at,
2968
+ context.requestId ?? this.sourceMachineId ?? null
2969
+ ]);
2970
+ if (inserted.rows.length > 0)
2971
+ return "inserted";
2972
+ const existing = await this.get("audit_history", value.id);
2973
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
2974
+ return "identical";
2975
+ throw new Error(divergentAuditHistoryReplayError(value.id));
2976
+ }
2473
2977
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
2474
2978
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
2475
2979
  if (planIds.length === 0)
@@ -2579,6 +3083,54 @@ class PostgresJsonRecordStore {
2579
3083
  throw new PlanNotFoundError(value.id);
2580
3084
  return payloadRecord2(row.payload);
2581
3085
  }
3086
+ async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
3087
+ await this.ensureSchema();
3088
+ const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
3089
+ SELECT date_trunc(
3090
+ 'milliseconds',
3091
+ GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
3092
+ ) AS completed_at
3093
+ ), stored AS (
3094
+ UPDATE ${this.tableName} AS record SET
3095
+ payload = record.payload || jsonb_build_object(
3096
+ 'status', 'completed',
3097
+ 'updated_at', to_char(
3098
+ next_clock.completed_at AT TIME ZONE 'UTC',
3099
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
3100
+ )
3101
+ ),
3102
+ updated_at = next_clock.completed_at,
3103
+ deleted_at = NULL,
3104
+ source_machine_id = COALESCE($4, record.source_machine_id),
3105
+ version = COALESCE(record.version, 0) + 1
3106
+ FROM next_clock
3107
+ WHERE record.service = $1
3108
+ AND record.object_type = 'plans'
3109
+ AND record.object_id = $2
3110
+ AND record.deleted_at IS NULL
3111
+ AND record.payload->>'updated_at' = $3::text
3112
+ AND record.payload->>'status' IS DISTINCT FROM 'completed'
3113
+ RETURNING record.payload
3114
+ )
3115
+ SELECT payload FROM stored`, [
3116
+ this.service,
3117
+ id,
3118
+ expectedUpdatedAt,
3119
+ context.requestId ?? this.sourceMachineId ?? null
3120
+ ]);
3121
+ const payload = result.rows[0]?.payload;
3122
+ if (payload)
3123
+ return { plan: payloadRecord2(payload), applied: true };
3124
+ const current = await this.get("plans", id);
3125
+ if (!current)
3126
+ throw new PlanNotFoundError(id);
3127
+ if (current.updated_at !== expectedUpdatedAt) {
3128
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
3129
+ }
3130
+ if (current.status === "completed")
3131
+ return { plan: current, applied: false };
3132
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
3133
+ }
2582
3134
  async createTemplateWithTasks(template, tasks, context = {}) {
2583
3135
  await this.ensureSchema();
2584
3136
  const records = [
@@ -3910,6 +4462,11 @@ async function importSnapshot(snapshot, store, context) {
3910
4462
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
3911
4463
  if (result.errors.length > 0)
3912
4464
  return result;
4465
+ const auditHistory = await preflightAuditHistoryImport(snapshot.auditHistory, snapshot.tombstones ?? [], store);
4466
+ result.errors.push(...auditHistory.errors);
4467
+ if (result.errors.length > 0)
4468
+ return result;
4469
+ result.skipped += auditHistory.identical;
3913
4470
  const entries = [
3914
4471
  ...snapshot.tasks.map((row) => ["tasks", row]),
3915
4472
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -3918,9 +4475,20 @@ async function importSnapshot(snapshot, store, context) {
3918
4475
  ...snapshot.agents.map((row) => ["agents", row]),
3919
4476
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
3920
4477
  ...snapshot.templates.map((row) => ["templates", row]),
3921
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
3922
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
4478
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
3923
4479
  ];
4480
+ for (const row of auditHistory.rowsToInsert) {
4481
+ try {
4482
+ const outcome = await store.insertImmutableAuditHistory(row, context);
4483
+ if (outcome === "inserted")
4484
+ result.inserted += 1;
4485
+ else
4486
+ result.skipped += 1;
4487
+ } catch (error) {
4488
+ result.errors.push(error instanceof Error ? error.message : String(error));
4489
+ return result;
4490
+ }
4491
+ }
3924
4492
  for (const [type, row] of entries) {
3925
4493
  try {
3926
4494
  const existing = await store.get(type, row.id);
@@ -3954,6 +4522,32 @@ async function importSnapshot(snapshot, store, context) {
3954
4522
  }
3955
4523
  return result;
3956
4524
  }
4525
+ async function preflightAuditHistoryImport(rows, tombstones, store) {
4526
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
4527
+ const rowsToInsert = [];
4528
+ const seen = new Map;
4529
+ let identical = 0;
4530
+ for (const row of rows) {
4531
+ const prior = seen.get(row.id);
4532
+ if (prior) {
4533
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
4534
+ identical += 1;
4535
+ else
4536
+ errors.push(divergentAuditHistoryReplayError(row.id));
4537
+ continue;
4538
+ }
4539
+ seen.set(row.id, row);
4540
+ const existing = await store.get("audit_history", row.id);
4541
+ if (!existing) {
4542
+ rowsToInsert.push(row);
4543
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
4544
+ identical += 1;
4545
+ } else {
4546
+ errors.push(divergentAuditHistoryReplayError(row.id));
4547
+ }
4548
+ }
4549
+ return { rowsToInsert, identical, errors };
4550
+ }
3957
4551
  async function requireRecord(type, id, store) {
3958
4552
  const record = await store.get(type, id);
3959
4553
  if (!record)
@@ -4060,9 +4654,11 @@ var init_postgres_adapter = __esm(() => {
4060
4654
  init_types();
4061
4655
  init_creator_identity();
4062
4656
  init_plan_project_link_contract();
4657
+ init_stale_lock_handoff();
4063
4658
  init_postgres_sync();
4064
4659
  init_integrity();
4065
4660
  init_redaction();
4661
+ init_audit_history_import();
4066
4662
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
4067
4663
  });
4068
4664
 
@@ -12771,185 +13367,6 @@ var init_shared_events = __esm(() => {
12771
13367
  init_event_emission_safety();
12772
13368
  });
12773
13369
 
12774
- // src/lib/secret-redaction.ts
12775
- function isAllowlisted(text, match, allowlist) {
12776
- const context = text.slice(Math.max(0, text.indexOf(match) - 20), text.indexOf(match) + match.length + 20);
12777
- return allowlist.some((re) => re.test(context) || re.test(match));
12778
- }
12779
- function scanTextForSecrets(text, options = {}) {
12780
- const allowlist = [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []];
12781
- const matches = [];
12782
- const patterns = [
12783
- ...DEFAULT_PATTERNS,
12784
- ...options.custom_patterns?.map((p, i) => ({ name: `custom_${i}`, pattern: p })) ?? []
12785
- ];
12786
- for (const { name, pattern, allowlist_ok } of patterns) {
12787
- const re = new RegExp(pattern.source, pattern.flags);
12788
- let m;
12789
- while ((m = re.exec(text)) !== null) {
12790
- const match = m[0];
12791
- if (allowlist_ok && isAllowlisted(text, match, allowlist))
12792
- continue;
12793
- if (isAllowlisted(text, match, allowlist))
12794
- continue;
12795
- const line = text.slice(0, m.index).split(`
12796
- `).length;
12797
- matches.push({ pattern: name, match: match.slice(0, 12) + (match.length > 12 ? "\u2026" : ""), index: m.index, line });
12798
- }
12799
- }
12800
- return {
12801
- schema_version: SECRET_REDACTION_SCHEMA,
12802
- clean: matches.length === 0,
12803
- matches
12804
- };
12805
- }
12806
- function redactText(text, options = {}) {
12807
- const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER2;
12808
- let out = text;
12809
- for (const { pattern, allowlist_ok } of DEFAULT_PATTERNS) {
12810
- out = out.replace(new RegExp(pattern.source, pattern.flags), (match) => {
12811
- if (allowlist_ok && isAllowlisted(out, match, [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []])) {
12812
- return match;
12813
- }
12814
- return placeholder;
12815
- });
12816
- }
12817
- for (const custom of options.custom_patterns ?? []) {
12818
- out = out.replace(custom, placeholder);
12819
- }
12820
- for (const fn of customRedactors) {
12821
- out = fn(out);
12822
- }
12823
- return out;
12824
- }
12825
- function redactExportRecord(record) {
12826
- const base = redactValue(record);
12827
- for (const [key, value] of Object.entries(base)) {
12828
- if (typeof value === "string") {
12829
- base[key] = redactText(value);
12830
- }
12831
- }
12832
- return base;
12833
- }
12834
- var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER2 = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
12835
- var init_secret_redaction = __esm(() => {
12836
- init_redaction();
12837
- SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
12838
- DEFAULT_PATTERNS = [
12839
- { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
12840
- { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
12841
- { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
12842
- { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
12843
- { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
12844
- { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
12845
- { name: "private_key_block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
12846
- { name: "generic_api_key", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?[a-zA-Z0-9\-._]{8,}['"]?/gi, allowlist_ok: true }
12847
- ];
12848
- DEFAULT_ALLOWLIST = [
12849
- /\[REDACTED\]/,
12850
- /example\.com/i,
12851
- /your-api-key-here/i,
12852
- /sk-test/i,
12853
- /ghp_xxx/i
12854
- ];
12855
- customRedactors = [];
12856
- });
12857
-
12858
- // src/lib/prewrite-secrets.ts
12859
- function mergeFindings(findings) {
12860
- const byPattern = new Map;
12861
- for (const finding of findings) {
12862
- const existing = byPattern.get(finding.pattern);
12863
- if (!existing) {
12864
- byPattern.set(finding.pattern, {
12865
- pattern: finding.pattern,
12866
- count: finding.count,
12867
- lines: Array.from(new Set(finding.lines)).sort((a, b) => a - b)
12868
- });
12869
- continue;
12870
- }
12871
- existing.count += finding.count;
12872
- existing.lines = Array.from(new Set([...existing.lines, ...finding.lines])).sort((a, b) => a - b);
12873
- }
12874
- return [...byPattern.values()].sort((left, right) => left.pattern.localeCompare(right.pattern));
12875
- }
12876
- function redactPreWriteText(value) {
12877
- return redactText(redactEvidenceText(value));
12878
- }
12879
- function scanPreWriteText(value, context = "text") {
12880
- const redacted = redactPreWriteText(value);
12881
- if (redacted === value) {
12882
- return {
12883
- schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
12884
- clean: true,
12885
- context,
12886
- findings: []
12887
- };
12888
- }
12889
- const structural = scanTextForSecrets(value).matches.map((match) => ({
12890
- pattern: match.pattern,
12891
- count: 1,
12892
- lines: match.line ? [match.line] : []
12893
- }));
12894
- const configured = listSecretFindings(value).map((finding) => ({
12895
- pattern: finding.pattern,
12896
- count: finding.count,
12897
- lines: []
12898
- }));
12899
- return {
12900
- schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
12901
- clean: false,
12902
- context,
12903
- findings: mergeFindings([...structural, ...configured])
12904
- };
12905
- }
12906
- function assertPreWriteTextClean(value, context = "text") {
12907
- const scan = scanPreWriteText(value, context);
12908
- if (!scan.clean)
12909
- throw new PreWriteSecretError(context, scan.findings);
12910
- }
12911
- function sanitizePreWriteText(value, context = "text", options = {}) {
12912
- const mode = options.mode ?? "redact";
12913
- if (mode === "block") {
12914
- assertPreWriteTextClean(value, context);
12915
- return value;
12916
- }
12917
- return redactPreWriteText(value);
12918
- }
12919
- function sanitizePreWriteValue(value, context = "value", options = {}) {
12920
- if (typeof value === "string")
12921
- return sanitizePreWriteText(value, context, options);
12922
- if (Array.isArray(value)) {
12923
- return value.map((item, index) => sanitizePreWriteValue(item, `${context}[${index}]`, options));
12924
- }
12925
- if (value && typeof value === "object") {
12926
- const sanitized = {};
12927
- for (const [key, child] of Object.entries(value)) {
12928
- const safeKey = sanitizePreWriteText(key, `${context}.$key`, options);
12929
- sanitized[safeKey] = sanitizePreWriteValue(child, `${context}.${safeKey}`, options);
12930
- }
12931
- return redactValue(sanitized);
12932
- }
12933
- return value;
12934
- }
12935
- var PREWRITE_SECRET_SCAN_SCHEMA, PreWriteSecretError;
12936
- var init_prewrite_secrets = __esm(() => {
12937
- init_redaction();
12938
- init_secret_redaction();
12939
- PREWRITE_SECRET_SCAN_SCHEMA = ["todos", "prewrite_secret_scan", "v1"].join(".");
12940
- PreWriteSecretError = class PreWriteSecretError extends Error {
12941
- context;
12942
- findings;
12943
- constructor(context, findings) {
12944
- const summary = findings.map((finding) => `${finding.pattern}:${finding.count}`).join(", ");
12945
- super(`Secret pattern detected before persistence in ${context}: ${summary}`);
12946
- this.name = "PreWriteSecretError";
12947
- this.context = context;
12948
- this.findings = findings;
12949
- }
12950
- };
12951
- });
12952
-
12953
13370
  // src/lib/activity-audit.ts
12954
13371
  var exports_activity_audit = {};
12955
13372
  __export(exports_activity_audit, {
@@ -13142,28 +13559,55 @@ var init_activity_audit = __esm(() => {
13142
13559
  function sanitizeHistoryValue(value, context) {
13143
13560
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
13144
13561
  }
13145
- function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, db) {
13562
+ function insertTaskHistory(entry2, db) {
13146
13563
  const d = db || getDatabase();
13147
- const id = uuid();
13148
- const timestamp2 = now();
13149
- const machineId = currentStorageMachineId(d);
13150
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
13151
- const safeNewValue = sanitizeHistoryValue(newValue, "task_history.new_value");
13564
+ const safeEntry = {
13565
+ ...entry2,
13566
+ field: entry2.field || null,
13567
+ old_value: sanitizeHistoryValue(entry2.old_value, "task_history.old_value"),
13568
+ new_value: sanitizeHistoryValue(entry2.new_value, "task_history.new_value"),
13569
+ agent_id: entry2.agent_id || null,
13570
+ machine_id: entry2.machine_id ?? currentStorageMachineId(d)
13571
+ };
13152
13572
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
13153
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, taskId, action, field || null, safeOldValue, safeNewValue, agentId || null, timestamp2, machineId]);
13573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
13574
+ safeEntry.id,
13575
+ safeEntry.task_id,
13576
+ safeEntry.action,
13577
+ safeEntry.field,
13578
+ safeEntry.old_value,
13579
+ safeEntry.new_value,
13580
+ safeEntry.agent_id,
13581
+ safeEntry.created_at,
13582
+ safeEntry.machine_id ?? null
13583
+ ]);
13154
13584
  try {
13155
13585
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
13156
13586
  logActivity2({
13157
13587
  entity_type: "task",
13158
- entity_id: taskId,
13159
- action,
13160
- field,
13161
- old_value: safeOldValue,
13162
- new_value: safeNewValue,
13163
- actor_id: agentId ?? undefined
13588
+ entity_id: safeEntry.task_id,
13589
+ action: safeEntry.action,
13590
+ field: safeEntry.field ?? undefined,
13591
+ old_value: safeEntry.old_value,
13592
+ new_value: safeEntry.new_value,
13593
+ actor_id: safeEntry.agent_id ?? undefined
13164
13594
  }, d);
13165
13595
  } catch {}
13166
- return { id, task_id: taskId, action, field: field || null, old_value: safeOldValue, new_value: safeNewValue, agent_id: agentId || null, created_at: timestamp2, machine_id: machineId };
13596
+ return safeEntry;
13597
+ }
13598
+ function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, db) {
13599
+ const d = db || getDatabase();
13600
+ return insertTaskHistory({
13601
+ id: uuid(),
13602
+ task_id: taskId,
13603
+ action,
13604
+ field: field || null,
13605
+ old_value: oldValue ?? null,
13606
+ new_value: newValue ?? null,
13607
+ agent_id: agentId || null,
13608
+ created_at: now(),
13609
+ machine_id: currentStorageMachineId(d)
13610
+ }, d);
13167
13611
  }
13168
13612
  function getTaskHistory(taskId, db) {
13169
13613
  const d = db || getDatabase();
@@ -14130,6 +14574,7 @@ __export(exports_task_lifecycle, {
14130
14574
  startTask: () => startTask2,
14131
14575
  spawnNextRecurrence: () => spawnNextRecurrence,
14132
14576
  lockTask: () => lockTask2,
14577
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
14133
14578
  getTasksChangedSince: () => getTasksChangedSince,
14134
14579
  getTaskLockStatus: () => getTaskLockStatus,
14135
14580
  getStaleTasks: () => getStaleTasks,
@@ -14398,6 +14843,38 @@ function unlockTask2(id, agentId, db) {
14398
14843
  WHERE id = ?`, [timestamp2, id]);
14399
14844
  return true;
14400
14845
  }
14846
+ function handoffStaleTaskLock(input, db) {
14847
+ const d = db || getDatabase();
14848
+ const prepared = prepareStaleLockHandoff(input);
14849
+ const receipt = buildStaleLockHandoffReceipt(prepared);
14850
+ const history = staleLockHandoffHistory(receipt, null);
14851
+ const transfer = d.transaction(() => {
14852
+ const result = d.run(`UPDATE tasks
14853
+ SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
14854
+ WHERE id = ?
14855
+ AND locked_by = ?
14856
+ AND locked_at = ?
14857
+ AND julianday(locked_at) < julianday(?)
14858
+ AND status NOT IN ('completed', 'failed', 'cancelled')`, [
14859
+ prepared.new_holder,
14860
+ prepared.operation_timestamp,
14861
+ prepared.operation_timestamp,
14862
+ prepared.task_id,
14863
+ prepared.expected_holder,
14864
+ prepared.expected_lock_version,
14865
+ prepared.stale_cutoff
14866
+ ]);
14867
+ if (result.changes === 0) {
14868
+ const current = getTask(prepared.task_id, d);
14869
+ if (!current)
14870
+ throw new TaskNotFoundError(prepared.task_id);
14871
+ throwStaleLockHandoffConflict(current, prepared);
14872
+ }
14873
+ insertTaskHistory(history, d);
14874
+ });
14875
+ transfer();
14876
+ return receipt;
14877
+ }
14401
14878
  function getTaskLockStatus(id, db) {
14402
14879
  const d = db || getDatabase();
14403
14880
  const task = getTask(id, d);
@@ -14698,6 +15175,7 @@ var init_task_lifecycle = __esm(() => {
14698
15175
  init_task_crud();
14699
15176
  init_task_graph();
14700
15177
  init_prewrite_secrets();
15178
+ init_stale_lock_handoff();
14701
15179
  });
14702
15180
 
14703
15181
  // src/db/task-crud.ts
@@ -16250,6 +16728,48 @@ function updatePlan2(id, input, db) {
16250
16728
  return updatePlanStored(id, input, d);
16251
16729
  })();
16252
16730
  }
16731
+ function nextPlanCompletionTimestamp(expectedUpdatedAt) {
16732
+ const expected = Date.parse(expectedUpdatedAt);
16733
+ const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
16734
+ return new Date(Math.max(Date.now(), minimum)).toISOString();
16735
+ }
16736
+ function completePlanAtRevision(id, expectedUpdatedAt, db) {
16737
+ const d = db || getDatabase();
16738
+ return d.transaction(() => {
16739
+ guardPlanRowsSqlite([id], d);
16740
+ const plan = getPlan(id, d);
16741
+ if (!plan)
16742
+ throw new PlanNotFoundError(id);
16743
+ if (plan.updated_at !== expectedUpdatedAt) {
16744
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
16745
+ }
16746
+ if (plan.status === "completed")
16747
+ return { plan, applied: false };
16748
+ const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
16749
+ const result = d.run(`UPDATE plans
16750
+ SET status = 'completed', updated_at = ?
16751
+ WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
16752
+ if (result.changes !== 1) {
16753
+ const current = getPlan(id, d);
16754
+ if (!current)
16755
+ throw new PlanNotFoundError(id);
16756
+ throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
16757
+ }
16758
+ const completed = getPlan(id, d);
16759
+ emitLocalEventHooksQuiet({
16760
+ type: "plan.updated",
16761
+ payload: {
16762
+ id,
16763
+ old_status: plan.status,
16764
+ new_status: completed.status,
16765
+ name: completed.name,
16766
+ project_id: completed.project_id
16767
+ },
16768
+ databasePath: databasePathFromDatabase(d)
16769
+ });
16770
+ return { plan: completed, applied: true };
16771
+ })();
16772
+ }
16253
16773
  function deletePlan(id, db) {
16254
16774
  const d = db || getDatabase();
16255
16775
  const plan = getPlan(id, d);
@@ -18228,6 +18748,7 @@ __export(exports_tasks, {
18228
18748
  insertTaskTags: () => insertTaskTags,
18229
18749
  importTaskBoardBundle: () => importTaskBoardBundle,
18230
18750
  importCalendarIcs: () => importCalendarIcs,
18751
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
18231
18752
  getTimeReport: () => getTimeReport,
18232
18753
  getTimeLogs: () => getTimeLogs,
18233
18754
  getTasksChangedSince: () => getTasksChangedSince,
@@ -19200,9 +19721,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
19200
19721
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
19201
19722
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
19202
19723
  }
19724
+ const auditImport = preflightAuditHistoryImport2(d, snapshot.auditHistory, snapshot.tombstones ?? []);
19725
+ result.errors.push(...auditImport.errors);
19203
19726
  if (result.errors.length > 0)
19204
19727
  return result;
19205
- const applyRows = (objectType, table, columns, rows, updateClockColumn, afterUpsert) => {
19728
+ result.skipped += auditImport.identicalReplayCount;
19729
+ const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
19206
19730
  for (const row of rows) {
19207
19731
  try {
19208
19732
  const record = asRecord(row);
@@ -19211,7 +19735,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
19211
19735
  result.skipped += 1;
19212
19736
  continue;
19213
19737
  }
19214
- const state = upsertById(d, table, columns, record, updateClockColumn);
19738
+ const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
19215
19739
  if (state === "inserted")
19216
19740
  result.inserted += 1;
19217
19741
  else if (state === "updated")
@@ -19228,19 +19752,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
19228
19752
  applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
19229
19753
  applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
19230
19754
  applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
19231
- applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
19755
+ applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
19232
19756
  applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
19233
19757
  applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
19234
- applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", (row, changed) => {
19758
+ applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", true, (row, changed) => {
19235
19759
  if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
19236
19760
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
19237
19761
  }
19238
19762
  });
19239
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
19763
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
19240
19764
  applyTombstones(d, snapshot.tombstones ?? [], result);
19241
19765
  return result;
19242
19766
  }
19243
- function upsertById(db, table, columns, row, updateClockColumn) {
19767
+ function preflightAuditHistoryImport2(db, rows, tombstones) {
19768
+ const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
19769
+ const rowsToInsert = [];
19770
+ const seen = new Map;
19771
+ let identicalReplayCount = 0;
19772
+ for (const rawRow of rows) {
19773
+ try {
19774
+ const row = asRecord(rawRow);
19775
+ if (typeof row.id !== "string" || !row.id) {
19776
+ throw new Error("task_history row is missing id");
19777
+ }
19778
+ const prior = seen.get(row.id);
19779
+ if (prior) {
19780
+ if (auditHistoryRowsAreFieldIdentical(prior, row))
19781
+ identicalReplayCount += 1;
19782
+ else
19783
+ errors.push(divergentAuditHistoryReplayError(row.id));
19784
+ continue;
19785
+ }
19786
+ seen.set(row.id, row);
19787
+ const existing = getAuditHistoryById(db, row.id);
19788
+ if (!existing) {
19789
+ rowsToInsert.push(row);
19790
+ } else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
19791
+ identicalReplayCount += 1;
19792
+ } else {
19793
+ errors.push(divergentAuditHistoryReplayError(row.id));
19794
+ }
19795
+ } catch (error) {
19796
+ errors.push(error instanceof Error ? error.message : String(error));
19797
+ }
19798
+ }
19799
+ return { rowsToInsert, identicalReplayCount, errors };
19800
+ }
19801
+ function insertAuditHistoryRows(db, rows, result) {
19802
+ for (const rawRow of rows) {
19803
+ try {
19804
+ const row = asRecord(rawRow);
19805
+ const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
19806
+ if (!presentColumns.includes("id"))
19807
+ presentColumns.unshift("id");
19808
+ const placeholders = presentColumns.map(() => "?").join(", ");
19809
+ const values = presentColumns.map((column) => valueForColumn(column, row[column]));
19810
+ const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
19811
+ if (changes > 0) {
19812
+ result.inserted += 1;
19813
+ continue;
19814
+ }
19815
+ const existing = getAuditHistoryById(db, String(row["id"]));
19816
+ if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
19817
+ result.skipped += 1;
19818
+ } else {
19819
+ result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
19820
+ }
19821
+ } catch (error) {
19822
+ result.errors.push(error instanceof Error ? error.message : String(error));
19823
+ }
19824
+ }
19825
+ }
19826
+ function getAuditHistoryById(db, id) {
19827
+ return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
19828
+ }
19829
+ function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
19244
19830
  const id = row["id"];
19245
19831
  if (typeof id !== "string" || !id)
19246
19832
  throw new Error(`${table} row is missing id`);
@@ -19252,7 +19838,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
19252
19838
  const values = presentColumns.map((column) => valueForColumn(column, row[column]));
19253
19839
  const updateColumns = presentColumns.filter((column) => column !== "id");
19254
19840
  const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
19255
- const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
19841
+ const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
19256
19842
  const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
19257
19843
  ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
19258
19844
  const changes = db.run(sql, values).changes;
@@ -19337,7 +19923,7 @@ function tableForTombstone(objectType) {
19337
19923
  return "task_templates";
19338
19924
  if (objectType === "template_tasks")
19339
19925
  return "template_tasks";
19340
- return "task_history";
19926
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
19341
19927
  }
19342
19928
  function listRows(db, table, columns) {
19343
19929
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -19384,6 +19970,7 @@ var init_sqlite_snapshot = __esm(() => {
19384
19970
  init_tasks();
19385
19971
  init_templates();
19386
19972
  init_storage_tombstones();
19973
+ init_audit_history_import();
19387
19974
  PROJECT_COLUMNS = [
19388
19975
  "id",
19389
19976
  "name",
@@ -19648,6 +20235,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19648
20235
  unlockTask2(id, agentId, database());
19649
20236
  return true;
19650
20237
  },
20238
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
19651
20239
  delete: (id) => deleteTask(id, database()),
19652
20240
  start: (id, agentId) => startTask2(id, agentId, database()),
19653
20241
  complete: (id, agentId, options2) => completeTask2(id, agentId, database(), options2),
@@ -19671,6 +20259,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19671
20259
  get: (id) => getPlan(id, database()),
19672
20260
  list: (projectId) => listPlans(projectId, database()),
19673
20261
  update: (id, input) => updatePlan2(id, input, database()),
20262
+ completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
19674
20263
  delete: (id) => deletePlan(id, database())
19675
20264
  },
19676
20265
  planProjectLinks: {
@@ -29025,6 +29614,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
29025
29614
  ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
29026
29615
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
29027
29616
  TaskComment: taskCommentSchema,
29617
+ StaleLockHandoffInput: staleLockHandoffInputSchema,
29618
+ StaleLockHandoffReceipt: staleLockHandoffReceiptSchema,
29028
29619
  TaskGitRef: taskGitRefSchema,
29029
29620
  Plan: planSchema,
29030
29621
  PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
@@ -30232,6 +30823,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30232
30823
  }
30233
30824
  }
30234
30825
  },
30826
+ "/v1/tasks/{id}/stale-lock-handoff": {
30827
+ post: {
30828
+ operationId: "handoffStaleTaskLock",
30829
+ summary: "Atomically transfer one exact stale task lock",
30830
+ description: "Compares one full task UUID, current holder, and exact locked_at version, verifies the lock is strictly older than the supplied threshold, then transfers it directly and writes an immutable task-history receipt in the same backend transaction.",
30831
+ parameters: [
30832
+ {
30833
+ name: "id",
30834
+ in: "path",
30835
+ required: true,
30836
+ schema: { type: "string", format: "uuid" },
30837
+ description: "Exact full task UUID. Short ids and prefixes are rejected."
30838
+ }
30839
+ ],
30840
+ requestBody: {
30841
+ required: true,
30842
+ content: {
30843
+ "application/json": {
30844
+ schema: { $ref: "#/components/schemas/StaleLockHandoffInput" }
30845
+ }
30846
+ }
30847
+ },
30848
+ responses: {
30849
+ "200": {
30850
+ content: {
30851
+ "application/json": {
30852
+ schema: {
30853
+ type: "object",
30854
+ additionalProperties: false,
30855
+ required: ["receipt"],
30856
+ properties: {
30857
+ receipt: { $ref: "#/components/schemas/StaleLockHandoffReceipt" }
30858
+ }
30859
+ }
30860
+ }
30861
+ }
30862
+ },
30863
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
30864
+ "403": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
30865
+ "404": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
30866
+ "409": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
30867
+ "501": { content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
30868
+ }
30869
+ }
30870
+ },
30235
30871
  "/v1/tasks/{id}/refs": {
30236
30872
  get: {
30237
30873
  operationId: "listTaskGitRefs",
@@ -30738,8 +31374,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30738
31374
  "/v1/import": {
30739
31375
  post: {
30740
31376
  operationId: "importSnapshot",
30741
- summary: "Bulk-ingest a full or partial snapshot (idempotent upsert by id)",
30742
- 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.",
31377
+ summary: "Bulk-ingest a snapshot or atomically complete one observed plan",
31378
+ 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.",
30743
31379
  requestBody: {
30744
31380
  required: true,
30745
31381
  content: {
@@ -30758,7 +31394,22 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30758
31394
  templates: { type: "array", items: { type: "object" } },
30759
31395
  templateTasks: { type: "array", items: { $ref: "#/components/schemas/TemplateTask" } },
30760
31396
  auditHistory: { type: "array", items: { type: "object" } },
30761
- tombstones: { type: "array", items: { type: "object" } }
31397
+ tombstones: { type: "array", items: { type: "object" } },
31398
+ planCompletions: {
31399
+ type: "array",
31400
+ minItems: 1,
31401
+ maxItems: 1,
31402
+ items: {
31403
+ type: "object",
31404
+ additionalProperties: false,
31405
+ required: ["id", "expected_updated_at", "status"],
31406
+ properties: {
31407
+ id: { type: "string" },
31408
+ expected_updated_at: { type: "string", format: "date-time" },
31409
+ status: { type: "string", enum: ["completed"] }
31410
+ }
31411
+ }
31412
+ }
30762
31413
  }
30763
31414
  }
30764
31415
  }
@@ -30781,6 +31432,26 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30781
31432
  skipped: { type: "number" },
30782
31433
  errors: { type: "array", items: { type: "string" } }
30783
31434
  }
31435
+ },
31436
+ planCompletions: {
31437
+ type: "array",
31438
+ items: {
31439
+ type: "object",
31440
+ required: [
31441
+ "id",
31442
+ "status",
31443
+ "expected_updated_at",
31444
+ "result_updated_at",
31445
+ "applied"
31446
+ ],
31447
+ properties: {
31448
+ id: { type: "string" },
31449
+ status: { type: "string", enum: ["completed"] },
31450
+ expected_updated_at: { type: "string", format: "date-time" },
31451
+ result_updated_at: { type: "string", format: "date-time" },
31452
+ applied: { type: "boolean" }
31453
+ }
31454
+ }
30784
31455
  }
30785
31456
  }
30786
31457
  }
@@ -30793,7 +31464,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30793
31464
  }
30794
31465
  };
30795
31466
  }
30796
- var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
31467
+ var taskSchema, projectSchema, taskManifestBindingLookupRequestSchema, taskManifestBindingLookupResultSchema, taskManifestBindingLookupResponseSchema, taskListSchema, projectTaskListEnsureReceiptSchema, projectTaskListEnsureResultSchema, projectTaskListRollbackResultSchema, taskCommentSchema, staleLockHandoffInputSchema, staleLockHandoffReceiptSchema, taskGitRefSchema, planSchema, planProjectLinkReceiptSchema, planProjectLinkResultSchema, planProjectLinkRollbackResultSchema, templateTaskSchema, templateSchema, templateVariableSchema, createTemplateTaskInputSchema;
30797
31468
  var init_openapi = __esm(() => {
30798
31469
  init_package_version();
30799
31470
  init_types();
@@ -30812,6 +31483,8 @@ var init_openapi = __esm(() => {
30812
31483
  reason: { type: "string", nullable: true },
30813
31484
  tags: { type: "array", items: { type: "string" } },
30814
31485
  version: { type: "number" },
31486
+ locked_by: { type: "string", nullable: true },
31487
+ locked_at: { type: "string", format: "date-time", nullable: true },
30815
31488
  created_at: { type: "string" },
30816
31489
  updated_at: { type: "string" }
30817
31490
  }
@@ -30976,6 +31649,69 @@ var init_openapi = __esm(() => {
30976
31649
  created_at: { type: "string", format: "date-time" }
30977
31650
  }
30978
31651
  };
31652
+ staleLockHandoffInputSchema = {
31653
+ type: "object",
31654
+ additionalProperties: false,
31655
+ required: [
31656
+ "expected_holder",
31657
+ "expected_lock_version",
31658
+ "stale_after_seconds",
31659
+ "new_holder",
31660
+ "reason"
31661
+ ],
31662
+ properties: {
31663
+ expected_holder: { type: "string", minLength: 1 },
31664
+ expected_lock_version: {
31665
+ type: "string",
31666
+ format: "date-time",
31667
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$",
31668
+ description: "Exact authoritative locked_at token read from the task; no default or normalization is applied."
31669
+ },
31670
+ stale_after_seconds: {
31671
+ type: "integer",
31672
+ minimum: 1,
31673
+ description: "Lock age threshold supplied by the caller. The lock must be strictly older at the CAS instant."
31674
+ },
31675
+ new_holder: {
31676
+ type: "string",
31677
+ minLength: 1,
31678
+ description: "Must match the agent bound to the authenticated API key."
31679
+ },
31680
+ reason: { type: "string", minLength: 1, maxLength: 4096 }
31681
+ }
31682
+ };
31683
+ staleLockHandoffReceiptSchema = {
31684
+ type: "object",
31685
+ additionalProperties: false,
31686
+ required: [
31687
+ "schema_version",
31688
+ "receipt_id",
31689
+ "task_id",
31690
+ "actor",
31691
+ "previous_holder",
31692
+ "previous_lock_version",
31693
+ "new_holder",
31694
+ "new_lock_version",
31695
+ "stale_after_seconds",
31696
+ "stale_cutoff",
31697
+ "reason",
31698
+ "created_at"
31699
+ ],
31700
+ properties: {
31701
+ schema_version: { type: "string", enum: ["todos.stale-lock-handoff.v1"] },
31702
+ receipt_id: { type: "string", format: "uuid" },
31703
+ task_id: { type: "string", format: "uuid" },
31704
+ actor: { type: "string" },
31705
+ previous_holder: { type: "string" },
31706
+ previous_lock_version: { type: "string", format: "date-time" },
31707
+ new_holder: { type: "string" },
31708
+ new_lock_version: { type: "string", format: "date-time" },
31709
+ stale_after_seconds: { type: "integer", minimum: 1 },
31710
+ stale_cutoff: { type: "string", format: "date-time" },
31711
+ reason: { type: "string" },
31712
+ created_at: { type: "string", format: "date-time" }
31713
+ }
31714
+ };
30979
31715
  taskGitRefSchema = {
30980
31716
  type: "object",
30981
31717
  additionalProperties: false,
@@ -31977,6 +32713,65 @@ function normalizeImportSnapshot(raw) {
31977
32713
  function countSnapshotRecords(s) {
31978
32714
  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);
31979
32715
  }
32716
+ function validatePlanCompletionImports(raw) {
32717
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
32718
+ return { present: false, operations: [] };
32719
+ }
32720
+ const body2 = raw;
32721
+ if (!Object.prototype.hasOwnProperty.call(body2, "planCompletions")) {
32722
+ return { present: false, operations: [] };
32723
+ }
32724
+ if (!Array.isArray(body2["planCompletions"]) || body2["planCompletions"].length !== 1) {
32725
+ return {
32726
+ present: true,
32727
+ operations: [],
32728
+ error: "planCompletions must contain exactly one completion operation"
32729
+ };
32730
+ }
32731
+ const operation = body2["planCompletions"][0];
32732
+ if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
32733
+ return { present: true, operations: [], error: "plan completion must be an object" };
32734
+ }
32735
+ const record = operation;
32736
+ const allowed = new Set(["id", "expected_updated_at", "status"]);
32737
+ const unknown = Object.keys(record).find((key2) => !allowed.has(key2));
32738
+ if (unknown) {
32739
+ return { present: true, operations: [], error: `unknown plan completion field: ${unknown}` };
32740
+ }
32741
+ if (typeof record["id"] !== "string" || !record["id"].trim()) {
32742
+ return { present: true, operations: [], error: "plan completion id must be a non-empty string" };
32743
+ }
32744
+ if (record["status"] !== "completed") {
32745
+ return { present: true, operations: [], error: "plan completion status must be completed" };
32746
+ }
32747
+ const expectedUpdatedAt = typeof record["expected_updated_at"] === "string" ? record["expected_updated_at"] : "";
32748
+ const timestampMatch = RFC3339_DATE_TIME.exec(expectedUpdatedAt);
32749
+ const parsedTimestamp = Date.parse(expectedUpdatedAt);
32750
+ if (!timestampMatch || Number.isNaN(parsedTimestamp)) {
32751
+ return {
32752
+ present: true,
32753
+ operations: [],
32754
+ error: "plan completion expected_updated_at must be an RFC 3339 date-time with an explicit offset"
32755
+ };
32756
+ }
32757
+ const [, year, month, day] = timestampMatch;
32758
+ const calendarProbe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
32759
+ if (calendarProbe.getUTCFullYear() !== Number(year) || calendarProbe.getUTCMonth() !== Number(month) - 1 || calendarProbe.getUTCDate() !== Number(day)) {
32760
+ return {
32761
+ present: true,
32762
+ operations: [],
32763
+ error: "plan completion expected_updated_at names a date that does not exist"
32764
+ };
32765
+ }
32766
+ return {
32767
+ present: true,
32768
+ operations: [{
32769
+ id: record["id"],
32770
+ expected_updated_at: expectedUpdatedAt,
32771
+ status: "completed"
32772
+ }]
32773
+ };
32774
+ }
31980
32775
  async function handleV1Request(req, url, dependencies = {}) {
31981
32776
  const path = url.pathname;
31982
32777
  if (path !== "/v1" && !path.startsWith("/v1/"))
@@ -32158,6 +32953,45 @@ async function handleV1Request(req, url, dependencies = {}) {
32158
32953
  return error(405, `method ${method} not allowed on /v1/tasks`);
32159
32954
  }
32160
32955
  if (action) {
32956
+ if (action === "stale-lock-handoff") {
32957
+ if (method !== "POST") {
32958
+ return error(405, "method must be POST on /v1/tasks/:id/stale-lock-handoff");
32959
+ }
32960
+ const exactId = normalizeExactTaskId(id);
32961
+ if (!principal.agent) {
32962
+ return error(403, "stale-lock handoff requires an authenticated agent-bound key", {
32963
+ code: "STALE_LOCK_HANDOFF_ACTOR_MISMATCH"
32964
+ });
32965
+ }
32966
+ if (typeof store.tasks.handoffStaleLock !== "function") {
32967
+ return error(501, "stale-lock handoff is not supported by this storage backend");
32968
+ }
32969
+ const body3 = await readJson3(req) ?? {};
32970
+ const allowed = new Set([
32971
+ "expected_holder",
32972
+ "expected_lock_version",
32973
+ "stale_after_seconds",
32974
+ "new_holder",
32975
+ "reason"
32976
+ ]);
32977
+ const unknown = Object.keys(body3).find((key2) => !allowed.has(key2));
32978
+ if (unknown) {
32979
+ return error(400, `unknown stale-lock handoff field: ${unknown}`, {
32980
+ code: "STALE_LOCK_HANDOFF_INVALID_INPUT",
32981
+ field: unknown
32982
+ });
32983
+ }
32984
+ const receipt = await store.tasks.handoffStaleLock({
32985
+ task_id: exactId,
32986
+ actor: principal.agent,
32987
+ expected_holder: body3.expected_holder,
32988
+ expected_lock_version: body3.expected_lock_version,
32989
+ stale_after_seconds: body3.stale_after_seconds,
32990
+ new_holder: body3.new_holder,
32991
+ reason: body3.reason
32992
+ }, contextFromPrincipal(principal));
32993
+ return json5({ receipt });
32994
+ }
32161
32995
  if (action === "comments") {
32162
32996
  if (method === "GET") {
32163
32997
  if (!await store.tasks.get(id))
@@ -32913,10 +33747,57 @@ async function handleV1Request(req, url, dependencies = {}) {
32913
33747
  return error(400, "invalid JSON body");
32914
33748
  const snapshot = normalizeImportSnapshot(raw);
32915
33749
  const received = countSnapshotRecords(snapshot);
33750
+ const completionImports = validatePlanCompletionImports(raw);
33751
+ if (completionImports.present) {
33752
+ if (completionImports.error)
33753
+ return error(400, completionImports.error);
33754
+ if (received !== 0) {
33755
+ return error(400, "planCompletions cannot be combined with snapshot record arrays");
33756
+ }
33757
+ if (typeof store.plans.completeAtRevision !== "function") {
33758
+ return error(501, "atomic plan completion is not supported by this storage backend");
33759
+ }
33760
+ const operation = completionImports.operations[0];
33761
+ const completed = await store.plans.completeAtRevision(operation.id, operation.expected_updated_at, contextFromPrincipal(principal));
33762
+ return json5({
33763
+ result: {
33764
+ inserted: 0,
33765
+ updated: completed.applied ? 1 : 0,
33766
+ deleted: 0,
33767
+ skipped: completed.applied ? 0 : 1,
33768
+ errors: []
33769
+ },
33770
+ received: 1,
33771
+ planCompletions: [{
33772
+ id: operation.id,
33773
+ status: "completed",
33774
+ expected_updated_at: operation.expected_updated_at,
33775
+ result_updated_at: completed.plan.updated_at,
33776
+ applied: completed.applied
33777
+ }]
33778
+ });
33779
+ }
32916
33780
  if (received === 0) {
32917
33781
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
32918
33782
  }
33783
+ const forbiddenAuditTombstone = (snapshot.tombstones ?? []).find((tombstone) => tombstone.object_type === "audit_history");
33784
+ if (forbiddenAuditTombstone) {
33785
+ return error(400, forbiddenAuditHistoryTombstoneError(forbiddenAuditTombstone.object_id), {
33786
+ code: AUDIT_HISTORY_TOMBSTONE_FORBIDDEN,
33787
+ conflict: false,
33788
+ audit_history_id: forbiddenAuditTombstone.object_id
33789
+ });
33790
+ }
32919
33791
  const result = await store.sync.importSnapshot(snapshot, contextFromPrincipal(principal));
33792
+ const auditFailureMessage = result.errors.find((message) => parseAuditHistoryImportFailure(message) !== null);
33793
+ if (auditFailureMessage) {
33794
+ const failure = parseAuditHistoryImportFailure(auditFailureMessage);
33795
+ return error(failure.status, auditFailureMessage, {
33796
+ code: failure.code,
33797
+ conflict: failure.conflict,
33798
+ audit_history_id: failure.auditHistoryId
33799
+ });
33800
+ }
32920
33801
  return json5({ result, received });
32921
33802
  }
32922
33803
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
@@ -32939,6 +33820,26 @@ async function handleV1Request(req, url, dependencies = {}) {
32939
33820
  if (e instanceof TaskNotFoundError) {
32940
33821
  return error(404, e.message, { code: TaskNotFoundError.code });
32941
33822
  }
33823
+ if (e instanceof StaleLockHandoffError) {
33824
+ const status2 = e.code === "STALE_LOCK_HANDOFF_INVALID_TASK_ID" || e.code === "STALE_LOCK_HANDOFF_INVALID_INPUT" ? 400 : e.code === "STALE_LOCK_HANDOFF_ACTOR_MISMATCH" ? 403 : 409;
33825
+ return error(status2, e.message, {
33826
+ code: e.code,
33827
+ conflict: status2 === 409,
33828
+ ...e.details
33829
+ });
33830
+ }
33831
+ if (e instanceof PlanNotFoundError) {
33832
+ return error(404, e.message, { code: PlanNotFoundError.code });
33833
+ }
33834
+ if (e instanceof PlanRevisionConflictError) {
33835
+ return error(409, e.message, {
33836
+ code: PlanRevisionConflictError.code,
33837
+ conflict: true,
33838
+ plan_id: e.planId,
33839
+ expected_updated_at: e.expectedUpdatedAt,
33840
+ current_updated_at: e.currentUpdatedAt
33841
+ });
33842
+ }
32942
33843
  if (e instanceof LockError)
32943
33844
  return error(409, e.message, { code: LockError.code });
32944
33845
  if (e instanceof TaskNotStartableError) {
@@ -32961,6 +33862,8 @@ var init_v1 = __esm(() => {
32961
33862
  init_redaction();
32962
33863
  init_project_task_list_ensure();
32963
33864
  init_plan_project_link();
33865
+ init_stale_lock_handoff();
33866
+ init_audit_history_import();
32964
33867
  JSON_HEADERS4 = { "Content-Type": "application/json" };
32965
33868
  RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
32966
33869
  });
@@ -63726,6 +64629,7 @@ var init_http_client = __esm(() => {
63726
64629
  });
63727
64630
 
63728
64631
  // src/cli/cloud-router.ts
64632
+ import { randomUUID as randomUUID4 } from "crypto";
63729
64633
  import { resolve as resolvePath } from "path";
63730
64634
  function cleanMode(value) {
63731
64635
  const normalized = value?.trim().toLowerCase();
@@ -64128,7 +65032,10 @@ async function cloudGetTask(client, id) {
64128
65032
  }
64129
65033
  async function cloudCreateTask(client, input) {
64130
65034
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
64131
- const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.create("tasks", input, { retry: false }), ["PARENT_TASK_NOT_FOUND"]));
65035
+ const created = unwrapTask(await requiredRemoteRoute(client, "/v1/tasks", () => client.transport.post("/tasks", input, {
65036
+ idempotencyKey: randomUUID4(),
65037
+ retry: false
65038
+ }), ["PARENT_TASK_NOT_FOUND"]));
64132
65039
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
64133
65040
  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");
64134
65041
  }
@@ -64139,7 +65046,7 @@ async function cloudCreateTask(client, input) {
64139
65046
  return persisted;
64140
65047
  }
64141
65048
  async function cloudUpdateTask(client, id, patch) {
64142
- return unwrapTask(await client.update("tasks", id, patch));
65049
+ return unwrapTask(await client.transport.patch(`/tasks/${encodeURIComponent(id)}`, patch));
64143
65050
  }
64144
65051
  async function cloudDeleteTask(client, id) {
64145
65052
  try {
@@ -64350,7 +65257,7 @@ async function cloudResolveTaskListRef(client, ref, projectId) {
64350
65257
  return input.toLowerCase();
64351
65258
  return (await cloudResolveTaskList(client, ref, projectId)).id;
64352
65259
  }
64353
- var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
65260
+ var UUID_RE, TRANSPORT_TOKENS, completionCapabilityCache, retryCapabilityCache, gitRefCapabilityCache, SERVER_MODE_CANDIDATES, cachedServerMode = null, PRIORITY_RANK, listTagsCapabilityCache;
64354
65261
  var init_cloud_router = __esm(() => {
64355
65262
  init_storage();
64356
65263
  init_mode();
@@ -64369,6 +65276,7 @@ var init_cloud_router = __esm(() => {
64369
65276
  hybrid: "http"
64370
65277
  };
64371
65278
  completionCapabilityCache = new Map;
65279
+ retryCapabilityCache = new Map;
64372
65280
  gitRefCapabilityCache = new Map;
64373
65281
  SERVER_MODE_CANDIDATES = ["postgres", "cloud", "self_hosted"];
64374
65282
  PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };