@hasna/todos 0.15.19 → 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 (43) 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/query-commands.d.ts.map +1 -1
  4. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  5. package/dist/cli/index.js +851 -47
  6. package/dist/cli/stage-a.d.ts +21 -10
  7. package/dist/cli/stage-a.d.ts.map +1 -1
  8. package/dist/contracts.js +228 -16
  9. package/dist/db/audit.d.ts +8 -0
  10. package/dist/db/audit.d.ts.map +1 -1
  11. package/dist/db/task-lifecycle.d.ts +7 -1
  12. package/dist/db/task-lifecycle.d.ts.map +1 -1
  13. package/dist/db/tasks.d.ts +1 -1
  14. package/dist/db/tasks.d.ts.map +1 -1
  15. package/dist/index.js +479 -20
  16. package/dist/lib/cli-help.d.ts +3 -2
  17. package/dist/lib/cli-help.d.ts.map +1 -1
  18. package/dist/lib/stale-lock-handoff.d.ts +25 -0
  19. package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
  20. package/dist/mcp/index.js +702 -36
  21. package/dist/mcp.js +3 -1
  22. package/dist/project-registration.js +1147 -686
  23. package/dist/registry.js +228 -16
  24. package/dist/release-provenance.json +5 -5
  25. package/dist/sdk/index.js +7 -0
  26. package/dist/sdk/v1.generated.d.ts +27 -0
  27. package/dist/sdk/v1.generated.d.ts.map +1 -1
  28. package/dist/server/index.js +1035 -369
  29. package/dist/server/openapi.d.ts +182 -0
  30. package/dist/server/openapi.d.ts.map +1 -1
  31. package/dist/server/v1.d.ts.map +1 -1
  32. package/dist/storage/audit-history-import.d.ts +14 -0
  33. package/dist/storage/audit-history-import.d.ts.map +1 -0
  34. package/dist/storage/interfaces.d.ts +12 -1
  35. package/dist/storage/interfaces.d.ts.map +1 -1
  36. package/dist/storage/local-sqlite.d.ts.map +1 -1
  37. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  38. package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
  39. package/dist/storage.js +476 -19
  40. package/dist/task-manifest.js +11 -1
  41. package/dist/types/index.d.ts +43 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. 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.19",
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, PlanRevisionConflictError, 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",
@@ -983,6 +985,16 @@ var init_types = __esm(() => {
983
985
  this.name = "LockError";
984
986
  }
985
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
+ };
986
998
  AgentNotFoundError = class AgentNotFoundError extends Error {
987
999
  agentId;
988
1000
  static code = "AGENT_NOT_FOUND";
@@ -1196,6 +1208,501 @@ var init_plan_project_link_contract = __esm(() => {
1196
1208
  };
1197
1209
  });
1198
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
+
1199
1706
  // src/lib/slugs.ts
1200
1707
  function normalizeSlug(value) {
1201
1708
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1862,181 +2369,56 @@ var init_integrity = __esm(() => {
1862
2369
  };
1863
2370
  });
1864
2371
 
1865
- // src/lib/config.ts
1866
- import { existsSync as existsSync3 } from "fs";
1867
- import { dirname, join as join3 } from "path";
1868
- function getConfigPath() {
1869
- return join3(getTodosGlobalDir(), "config.json");
1870
- }
1871
- function loadConfig() {
1872
- if (cached)
1873
- return cached;
1874
- if (!existsSync3(getConfigPath())) {
1875
- cached = {};
1876
- return cached;
1877
- }
1878
- const config = readJsonFile(getConfigPath()) || {};
1879
- if (typeof config.sync_agents === "string") {
1880
- config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
1881
- }
1882
- cached = config;
1883
- return cached;
1884
- }
1885
- function saveConfig(config) {
1886
- const configPath = getConfigPath();
1887
- ensureDir(dirname(configPath));
1888
- writeJsonFile(configPath, config);
1889
- cached = config;
1890
- return config;
1891
- }
1892
- function getAgentPoolForProject(workingDir) {
1893
- const config = loadConfig();
1894
- if (workingDir && config.project_pools) {
1895
- let bestKey = null;
1896
- let bestLen = 0;
1897
- for (const key of Object.keys(config.project_pools)) {
1898
- if (workingDir.startsWith(key) && key.length > bestLen) {
1899
- bestKey = key;
1900
- bestLen = key.length;
1901
- }
1902
- }
1903
- if (bestKey && config.project_pools[bestKey]) {
1904
- return config.project_pools[bestKey];
1905
- }
1906
- }
1907
- return config.agent_pool || null;
1908
- }
1909
- function getCompletionGuardConfig(projectPath) {
1910
- const config = loadConfig();
1911
- const global = { ...GUARD_DEFAULTS, ...config.completion_guard };
1912
- if (projectPath && config.project_overrides?.[projectPath]?.completion_guard) {
1913
- return { ...global, ...config.project_overrides[projectPath].completion_guard };
1914
- }
1915
- return global;
1916
- }
1917
- var cached = null, GUARD_DEFAULTS;
1918
- var init_config2 = __esm(() => {
1919
- init_sync_utils();
1920
- GUARD_DEFAULTS = {
1921
- enabled: false,
1922
- min_work_seconds: 30,
1923
- max_completions_per_window: 5,
1924
- window_minutes: 10,
1925
- cooldown_seconds: 60
1926
- };
1927
- });
1928
-
1929
- // src/lib/redaction.ts
1930
- function unique(values) {
1931
- return Array.from(new Set((values || []).map((value) => value.trim()).filter(Boolean)));
1932
- }
1933
- function cloneRegex(regex) {
1934
- return new RegExp(regex.source, regex.flags.includes("g") ? regex.flags : `${regex.flags}g`);
1935
- }
1936
- function customPatterns() {
1937
- return unique(loadConfig().secret_safety?.redaction_patterns).flatMap((pattern) => {
1938
- try {
1939
- return [{ name: `custom:${pattern}`, regex: new RegExp(pattern, "g") }];
1940
- } catch {
1941
- return [];
1942
- }
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;
1943
2378
  });
1944
2379
  }
1945
- function secretPatterns() {
1946
- return [...customPatterns(), ...DEFAULT_SECRET_PATTERNS];
1947
- }
1948
- function isRedactionPlaceholderMatch(match) {
1949
- const trimmed = match.trim();
1950
- return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(trimmed) || new RegExp(`=\\s*['"]?${REDACTION_PLACEHOLDER}['"]?$`).test(trimmed);
1951
- }
1952
- function isRedactionPlaceholderKey(key) {
1953
- return new RegExp(`^${REDACTION_PLACEHOLDER}$`).test(key.trim());
1954
- }
1955
- function isSecretKey(key) {
1956
- if (isRedactionPlaceholderKey(key))
1957
- return false;
1958
- if (NON_SECRET_USAGE_KEYS.has(key.toLowerCase()))
1959
- return false;
1960
- if (DEFAULT_SECRET_KEY_PATTERN.test(key))
1961
- return true;
1962
- return unique(loadConfig().secret_safety?.redaction_keys).some((pattern) => key.toLowerCase().includes(pattern.toLowerCase()));
2380
+ function divergentAuditHistoryReplayError(id) {
2381
+ return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
1963
2382
  }
1964
- function redactEvidenceText(value) {
1965
- let redacted = value;
1966
- for (const pattern of secretPatterns()) {
1967
- const regex = cloneRegex(pattern.regex);
1968
- const replacement = pattern.replacement ?? "[REDACTED]";
1969
- redacted = typeof replacement === "string" ? redacted.replace(regex, replacement) : redacted.replace(regex, replacement);
1970
- }
1971
- return redacted;
2383
+ function forbiddenAuditHistoryTombstoneError(id) {
2384
+ return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
1972
2385
  }
1973
- function redactValue(value) {
1974
- if (typeof value === "string")
1975
- return redactEvidenceText(value);
1976
- if (Array.isArray(value))
1977
- return value.map(redactValue);
1978
- if (value && typeof value === "object") {
1979
- const redacted = {};
1980
- for (const [key, child] of Object.entries(value)) {
1981
- if (isSecretKey(key)) {
1982
- redacted[key] = "[REDACTED]";
1983
- } else {
1984
- redacted[key] = redactValue(child);
1985
- }
1986
- }
1987
- 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
+ };
1988
2396
  }
1989
- return value;
1990
- }
1991
- function listSecretFindings(value) {
1992
- const findings = [];
1993
- for (const pattern of secretPatterns()) {
1994
- const matches = value.match(cloneRegex(pattern.regex))?.filter((match) => !isRedactionPlaceholderMatch(match));
1995
- if (matches?.length)
1996
- 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
+ };
1997
2406
  }
1998
- return findings;
1999
- }
2000
- function getSecretSafetyConfig() {
2001
- return {
2002
- redaction_patterns: unique(loadConfig().secret_safety?.redaction_patterns),
2003
- redaction_keys: unique(loadConfig().secret_safety?.redaction_keys)
2004
- };
2005
- }
2006
- function upsertSecretSafetyConfig(input) {
2007
- const config = loadConfig();
2008
- const next = {
2009
- redaction_patterns: unique([...config.secret_safety?.redaction_patterns || [], ...input.redaction_patterns || []]),
2010
- redaction_keys: unique([...config.secret_safety?.redaction_keys || [], ...input.redaction_keys || []])
2011
- };
2012
- saveConfig({ ...config, secret_safety: next });
2013
- return next;
2407
+ return null;
2014
2408
  }
2015
- var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS, REDACTION_PLACEHOLDER;
2016
- var init_redaction = __esm(() => {
2017
- init_config2();
2018
- DEFAULT_SECRET_PATTERNS = [
2019
- { name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
2020
- { name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
2021
- { name: "openai-token", regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g, replacement: "[REDACTED_TOKEN]" },
2022
- { name: "npm-token", regex: /\bnpm_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_NPM_TOKEN]" },
2023
- { name: "github-fine-grained-token", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
2024
- { name: "github-token", regex: /\bgh[opsu]_[A-Za-z0-9]{20,}\b/g, replacement: "[REDACTED_GITHUB_TOKEN]" },
2025
- { 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]" },
2026
- { 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"
2027
2421
  ];
2028
- DEFAULT_SECRET_KEY_PATTERN = /api[_-]?key|token|secret|password/i;
2029
- NON_SECRET_USAGE_KEYS = new Set([
2030
- "tokens",
2031
- "total_tokens",
2032
- "token_count",
2033
- "input_tokens",
2034
- "output_tokens",
2035
- "prompt_tokens",
2036
- "completion_tokens",
2037
- "cost_tokens"
2038
- ]);
2039
- REDACTION_PLACEHOLDER = String.raw`\[REDACTED(?:_[A-Z_]+)?\]`;
2040
2422
  });
2041
2423
 
2042
2424
  // src/storage/postgres-adapter.ts
@@ -2069,6 +2451,7 @@ function createPostgresTodosStorageAdapter(options) {
2069
2451
  getChangedSince: (since, filters) => getChangedSince(since, filters, store),
2070
2452
  lock: (id, agentId) => lockTask(id, agentId, store),
2071
2453
  unlock: (id, agentId) => unlockTask(id, agentId, store),
2454
+ handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
2072
2455
  getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
2073
2456
  },
2074
2457
  dependencies: {
@@ -2215,6 +2598,91 @@ class PostgresJsonRecordStore {
2215
2598
  LIMIT 1`, [this.service, type, id]);
2216
2599
  return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
2217
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
+ }
2218
2686
  async list(type) {
2219
2687
  return (await this.listRecords(type)).map((record) => record.payload);
2220
2688
  }
@@ -2484,6 +2952,28 @@ class PostgresJsonRecordStore {
2484
2952
  }
2485
2953
  return value;
2486
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
+ }
2487
2977
  async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
2488
2978
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
2489
2979
  if (planIds.length === 0)
@@ -3972,6 +4462,11 @@ async function importSnapshot(snapshot, store, context) {
3972
4462
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
3973
4463
  if (result.errors.length > 0)
3974
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;
3975
4470
  const entries = [
3976
4471
  ...snapshot.tasks.map((row) => ["tasks", row]),
3977
4472
  ...snapshot.projects.map((row) => ["projects", row]),
@@ -3980,9 +4475,20 @@ async function importSnapshot(snapshot, store, context) {
3980
4475
  ...snapshot.agents.map((row) => ["agents", row]),
3981
4476
  ...snapshot.taskLists.map((row) => ["task_lists", row]),
3982
4477
  ...snapshot.templates.map((row) => ["templates", row]),
3983
- ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row]),
3984
- ...snapshot.auditHistory.map((row) => ["audit_history", row])
4478
+ ...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
3985
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
+ }
3986
4492
  for (const [type, row] of entries) {
3987
4493
  try {
3988
4494
  const existing = await store.get(type, row.id);
@@ -4016,6 +4522,32 @@ async function importSnapshot(snapshot, store, context) {
4016
4522
  }
4017
4523
  return result;
4018
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
+ }
4019
4551
  async function requireRecord(type, id, store) {
4020
4552
  const record = await store.get(type, id);
4021
4553
  if (!record)
@@ -4122,9 +4654,11 @@ var init_postgres_adapter = __esm(() => {
4122
4654
  init_types();
4123
4655
  init_creator_identity();
4124
4656
  init_plan_project_link_contract();
4657
+ init_stale_lock_handoff();
4125
4658
  init_postgres_sync();
4126
4659
  init_integrity();
4127
4660
  init_redaction();
4661
+ init_audit_history_import();
4128
4662
  TASK_ORDER_BY = `ORDER BY ${TASK_ORDER_TIEBREAK}`;
4129
4663
  });
4130
4664
 
@@ -12833,185 +13367,6 @@ var init_shared_events = __esm(() => {
12833
13367
  init_event_emission_safety();
12834
13368
  });
12835
13369
 
12836
- // src/lib/secret-redaction.ts
12837
- function isAllowlisted(text, match, allowlist) {
12838
- const context = text.slice(Math.max(0, text.indexOf(match) - 20), text.indexOf(match) + match.length + 20);
12839
- return allowlist.some((re) => re.test(context) || re.test(match));
12840
- }
12841
- function scanTextForSecrets(text, options = {}) {
12842
- const allowlist = [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []];
12843
- const matches = [];
12844
- const patterns = [
12845
- ...DEFAULT_PATTERNS,
12846
- ...options.custom_patterns?.map((p, i) => ({ name: `custom_${i}`, pattern: p })) ?? []
12847
- ];
12848
- for (const { name, pattern, allowlist_ok } of patterns) {
12849
- const re = new RegExp(pattern.source, pattern.flags);
12850
- let m;
12851
- while ((m = re.exec(text)) !== null) {
12852
- const match = m[0];
12853
- if (allowlist_ok && isAllowlisted(text, match, allowlist))
12854
- continue;
12855
- if (isAllowlisted(text, match, allowlist))
12856
- continue;
12857
- const line = text.slice(0, m.index).split(`
12858
- `).length;
12859
- matches.push({ pattern: name, match: match.slice(0, 12) + (match.length > 12 ? "\u2026" : ""), index: m.index, line });
12860
- }
12861
- }
12862
- return {
12863
- schema_version: SECRET_REDACTION_SCHEMA,
12864
- clean: matches.length === 0,
12865
- matches
12866
- };
12867
- }
12868
- function redactText(text, options = {}) {
12869
- const placeholder = options.placeholder ?? REDACTION_PLACEHOLDER2;
12870
- let out = text;
12871
- for (const { pattern, allowlist_ok } of DEFAULT_PATTERNS) {
12872
- out = out.replace(new RegExp(pattern.source, pattern.flags), (match) => {
12873
- if (allowlist_ok && isAllowlisted(out, match, [...DEFAULT_ALLOWLIST, ...options.allowlist ?? []])) {
12874
- return match;
12875
- }
12876
- return placeholder;
12877
- });
12878
- }
12879
- for (const custom of options.custom_patterns ?? []) {
12880
- out = out.replace(custom, placeholder);
12881
- }
12882
- for (const fn of customRedactors) {
12883
- out = fn(out);
12884
- }
12885
- return out;
12886
- }
12887
- function redactExportRecord(record) {
12888
- const base = redactValue(record);
12889
- for (const [key, value] of Object.entries(base)) {
12890
- if (typeof value === "string") {
12891
- base[key] = redactText(value);
12892
- }
12893
- }
12894
- return base;
12895
- }
12896
- var SECRET_REDACTION_SCHEMA, REDACTION_PLACEHOLDER2 = "[REDACTED]", DEFAULT_PATTERNS, DEFAULT_ALLOWLIST, customRedactors;
12897
- var init_secret_redaction = __esm(() => {
12898
- init_redaction();
12899
- SECRET_REDACTION_SCHEMA = ["todos", "secret_redaction", "v1"].join(".");
12900
- DEFAULT_PATTERNS = [
12901
- { name: "openai_sk", pattern: /\bsk-[a-zA-Z0-9]{10,}\b/g },
12902
- { name: "github_pat", pattern: /\bghp_[a-zA-Z0-9]{20,}\b/g },
12903
- { name: "github_oauth", pattern: /\bgho_[a-zA-Z0-9]{20,}\b/g },
12904
- { name: "aws_access_key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
12905
- { name: "bearer_token", pattern: /\bBearer\s+[a-zA-Z0-9\-._~+/]+=*\b/gi },
12906
- { name: "jwt", pattern: /\beyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
12907
- { name: "private_key_block", pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g },
12908
- { name: "generic_api_key", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"]?[a-zA-Z0-9\-._]{8,}['"]?/gi, allowlist_ok: true }
12909
- ];
12910
- DEFAULT_ALLOWLIST = [
12911
- /\[REDACTED\]/,
12912
- /example\.com/i,
12913
- /your-api-key-here/i,
12914
- /sk-test/i,
12915
- /ghp_xxx/i
12916
- ];
12917
- customRedactors = [];
12918
- });
12919
-
12920
- // src/lib/prewrite-secrets.ts
12921
- function mergeFindings(findings) {
12922
- const byPattern = new Map;
12923
- for (const finding of findings) {
12924
- const existing = byPattern.get(finding.pattern);
12925
- if (!existing) {
12926
- byPattern.set(finding.pattern, {
12927
- pattern: finding.pattern,
12928
- count: finding.count,
12929
- lines: Array.from(new Set(finding.lines)).sort((a, b) => a - b)
12930
- });
12931
- continue;
12932
- }
12933
- existing.count += finding.count;
12934
- existing.lines = Array.from(new Set([...existing.lines, ...finding.lines])).sort((a, b) => a - b);
12935
- }
12936
- return [...byPattern.values()].sort((left, right) => left.pattern.localeCompare(right.pattern));
12937
- }
12938
- function redactPreWriteText(value) {
12939
- return redactText(redactEvidenceText(value));
12940
- }
12941
- function scanPreWriteText(value, context = "text") {
12942
- const redacted = redactPreWriteText(value);
12943
- if (redacted === value) {
12944
- return {
12945
- schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
12946
- clean: true,
12947
- context,
12948
- findings: []
12949
- };
12950
- }
12951
- const structural = scanTextForSecrets(value).matches.map((match) => ({
12952
- pattern: match.pattern,
12953
- count: 1,
12954
- lines: match.line ? [match.line] : []
12955
- }));
12956
- const configured = listSecretFindings(value).map((finding) => ({
12957
- pattern: finding.pattern,
12958
- count: finding.count,
12959
- lines: []
12960
- }));
12961
- return {
12962
- schema_version: PREWRITE_SECRET_SCAN_SCHEMA,
12963
- clean: false,
12964
- context,
12965
- findings: mergeFindings([...structural, ...configured])
12966
- };
12967
- }
12968
- function assertPreWriteTextClean(value, context = "text") {
12969
- const scan = scanPreWriteText(value, context);
12970
- if (!scan.clean)
12971
- throw new PreWriteSecretError(context, scan.findings);
12972
- }
12973
- function sanitizePreWriteText(value, context = "text", options = {}) {
12974
- const mode = options.mode ?? "redact";
12975
- if (mode === "block") {
12976
- assertPreWriteTextClean(value, context);
12977
- return value;
12978
- }
12979
- return redactPreWriteText(value);
12980
- }
12981
- function sanitizePreWriteValue(value, context = "value", options = {}) {
12982
- if (typeof value === "string")
12983
- return sanitizePreWriteText(value, context, options);
12984
- if (Array.isArray(value)) {
12985
- return value.map((item, index) => sanitizePreWriteValue(item, `${context}[${index}]`, options));
12986
- }
12987
- if (value && typeof value === "object") {
12988
- const sanitized = {};
12989
- for (const [key, child] of Object.entries(value)) {
12990
- const safeKey = sanitizePreWriteText(key, `${context}.$key`, options);
12991
- sanitized[safeKey] = sanitizePreWriteValue(child, `${context}.${safeKey}`, options);
12992
- }
12993
- return redactValue(sanitized);
12994
- }
12995
- return value;
12996
- }
12997
- var PREWRITE_SECRET_SCAN_SCHEMA, PreWriteSecretError;
12998
- var init_prewrite_secrets = __esm(() => {
12999
- init_redaction();
13000
- init_secret_redaction();
13001
- PREWRITE_SECRET_SCAN_SCHEMA = ["todos", "prewrite_secret_scan", "v1"].join(".");
13002
- PreWriteSecretError = class PreWriteSecretError extends Error {
13003
- context;
13004
- findings;
13005
- constructor(context, findings) {
13006
- const summary = findings.map((finding) => `${finding.pattern}:${finding.count}`).join(", ");
13007
- super(`Secret pattern detected before persistence in ${context}: ${summary}`);
13008
- this.name = "PreWriteSecretError";
13009
- this.context = context;
13010
- this.findings = findings;
13011
- }
13012
- };
13013
- });
13014
-
13015
13370
  // src/lib/activity-audit.ts
13016
13371
  var exports_activity_audit = {};
13017
13372
  __export(exports_activity_audit, {
@@ -13204,28 +13559,55 @@ var init_activity_audit = __esm(() => {
13204
13559
  function sanitizeHistoryValue(value, context) {
13205
13560
  return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
13206
13561
  }
13207
- function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, db) {
13562
+ function insertTaskHistory(entry2, db) {
13208
13563
  const d = db || getDatabase();
13209
- const id = uuid();
13210
- const timestamp2 = now();
13211
- const machineId = currentStorageMachineId(d);
13212
- const safeOldValue = sanitizeHistoryValue(oldValue, "task_history.old_value");
13213
- 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
+ };
13214
13572
  d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
13215
- 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
+ ]);
13216
13584
  try {
13217
13585
  const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
13218
13586
  logActivity2({
13219
13587
  entity_type: "task",
13220
- entity_id: taskId,
13221
- action,
13222
- field,
13223
- old_value: safeOldValue,
13224
- new_value: safeNewValue,
13225
- 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
13226
13594
  }, d);
13227
13595
  } catch {}
13228
- 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);
13229
13611
  }
13230
13612
  function getTaskHistory(taskId, db) {
13231
13613
  const d = db || getDatabase();
@@ -14192,6 +14574,7 @@ __export(exports_task_lifecycle, {
14192
14574
  startTask: () => startTask2,
14193
14575
  spawnNextRecurrence: () => spawnNextRecurrence,
14194
14576
  lockTask: () => lockTask2,
14577
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
14195
14578
  getTasksChangedSince: () => getTasksChangedSince,
14196
14579
  getTaskLockStatus: () => getTaskLockStatus,
14197
14580
  getStaleTasks: () => getStaleTasks,
@@ -14460,6 +14843,38 @@ function unlockTask2(id, agentId, db) {
14460
14843
  WHERE id = ?`, [timestamp2, id]);
14461
14844
  return true;
14462
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
+ }
14463
14878
  function getTaskLockStatus(id, db) {
14464
14879
  const d = db || getDatabase();
14465
14880
  const task = getTask(id, d);
@@ -14760,6 +15175,7 @@ var init_task_lifecycle = __esm(() => {
14760
15175
  init_task_crud();
14761
15176
  init_task_graph();
14762
15177
  init_prewrite_secrets();
15178
+ init_stale_lock_handoff();
14763
15179
  });
14764
15180
 
14765
15181
  // src/db/task-crud.ts
@@ -18332,6 +18748,7 @@ __export(exports_tasks, {
18332
18748
  insertTaskTags: () => insertTaskTags,
18333
18749
  importTaskBoardBundle: () => importTaskBoardBundle,
18334
18750
  importCalendarIcs: () => importCalendarIcs,
18751
+ handoffStaleTaskLock: () => handoffStaleTaskLock,
18335
18752
  getTimeReport: () => getTimeReport,
18336
18753
  getTimeLogs: () => getTimeLogs,
18337
18754
  getTasksChangedSince: () => getTasksChangedSince,
@@ -19304,8 +19721,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
19304
19721
  const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
19305
19722
  result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
19306
19723
  }
19724
+ const auditImport = preflightAuditHistoryImport2(d, snapshot.auditHistory, snapshot.tombstones ?? []);
19725
+ result.errors.push(...auditImport.errors);
19307
19726
  if (result.errors.length > 0)
19308
19727
  return result;
19728
+ result.skipped += auditImport.identicalReplayCount;
19309
19729
  const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
19310
19730
  for (const row of rows) {
19311
19731
  try {
@@ -19340,10 +19760,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
19340
19760
  replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
19341
19761
  }
19342
19762
  });
19343
- applyRows("audit_history", "task_history", AUDIT_COLUMNS, snapshot.auditHistory);
19763
+ insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
19344
19764
  applyTombstones(d, snapshot.tombstones ?? [], result);
19345
19765
  return result;
19346
19766
  }
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
+ }
19347
19829
  function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
19348
19830
  const id = row["id"];
19349
19831
  if (typeof id !== "string" || !id)
@@ -19441,7 +19923,7 @@ function tableForTombstone(objectType) {
19441
19923
  return "task_templates";
19442
19924
  if (objectType === "template_tasks")
19443
19925
  return "template_tasks";
19444
- return "task_history";
19926
+ throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
19445
19927
  }
19446
19928
  function listRows(db, table, columns) {
19447
19929
  return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
@@ -19488,6 +19970,7 @@ var init_sqlite_snapshot = __esm(() => {
19488
19970
  init_tasks();
19489
19971
  init_templates();
19490
19972
  init_storage_tombstones();
19973
+ init_audit_history_import();
19491
19974
  PROJECT_COLUMNS = [
19492
19975
  "id",
19493
19976
  "name",
@@ -19752,6 +20235,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
19752
20235
  unlockTask2(id, agentId, database());
19753
20236
  return true;
19754
20237
  },
20238
+ handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
19755
20239
  delete: (id) => deleteTask(id, database()),
19756
20240
  start: (id, agentId) => startTask2(id, agentId, database()),
19757
20241
  complete: (id, agentId, options2) => completeTask2(id, agentId, database(), options2),
@@ -29130,6 +29614,8 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
29130
29614
  ProjectTaskListEnsureResult: projectTaskListEnsureResultSchema,
29131
29615
  ProjectTaskListRollbackResult: projectTaskListRollbackResultSchema,
29132
29616
  TaskComment: taskCommentSchema,
29617
+ StaleLockHandoffInput: staleLockHandoffInputSchema,
29618
+ StaleLockHandoffReceipt: staleLockHandoffReceiptSchema,
29133
29619
  TaskGitRef: taskGitRefSchema,
29134
29620
  Plan: planSchema,
29135
29621
  PlanProjectLinkReceipt: planProjectLinkReceiptSchema,
@@ -30337,6 +30823,51 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30337
30823
  }
30338
30824
  }
30339
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
+ },
30340
30871
  "/v1/tasks/{id}/refs": {
30341
30872
  get: {
30342
30873
  operationId: "listTaskGitRefs",
@@ -30933,7 +31464,7 @@ function buildV1OpenApiDocument(version = getPackageVersion()) {
30933
31464
  }
30934
31465
  };
30935
31466
  }
30936
- 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;
30937
31468
  var init_openapi = __esm(() => {
30938
31469
  init_package_version();
30939
31470
  init_types();
@@ -30952,6 +31483,8 @@ var init_openapi = __esm(() => {
30952
31483
  reason: { type: "string", nullable: true },
30953
31484
  tags: { type: "array", items: { type: "string" } },
30954
31485
  version: { type: "number" },
31486
+ locked_by: { type: "string", nullable: true },
31487
+ locked_at: { type: "string", format: "date-time", nullable: true },
30955
31488
  created_at: { type: "string" },
30956
31489
  updated_at: { type: "string" }
30957
31490
  }
@@ -31116,6 +31649,69 @@ var init_openapi = __esm(() => {
31116
31649
  created_at: { type: "string", format: "date-time" }
31117
31650
  }
31118
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
+ };
31119
31715
  taskGitRefSchema = {
31120
31716
  type: "object",
31121
31717
  additionalProperties: false,
@@ -32357,6 +32953,45 @@ async function handleV1Request(req, url, dependencies = {}) {
32357
32953
  return error(405, `method ${method} not allowed on /v1/tasks`);
32358
32954
  }
32359
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
+ }
32360
32995
  if (action === "comments") {
32361
32996
  if (method === "GET") {
32362
32997
  if (!await store.tasks.get(id))
@@ -33145,7 +33780,24 @@ async function handleV1Request(req, url, dependencies = {}) {
33145
33780
  if (received === 0) {
33146
33781
  return error(400, "empty snapshot: provide at least one record array (tasks/projects/plans/...)");
33147
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
+ }
33148
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
+ }
33149
33801
  return json5({ result, received });
33150
33802
  }
33151
33803
  return error(404, `unknown /v1 resource: ${resource ?? "(root)"}`);
@@ -33168,6 +33820,14 @@ async function handleV1Request(req, url, dependencies = {}) {
33168
33820
  if (e instanceof TaskNotFoundError) {
33169
33821
  return error(404, e.message, { code: TaskNotFoundError.code });
33170
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
+ }
33171
33831
  if (e instanceof PlanNotFoundError) {
33172
33832
  return error(404, e.message, { code: PlanNotFoundError.code });
33173
33833
  }
@@ -33202,6 +33862,8 @@ var init_v1 = __esm(() => {
33202
33862
  init_redaction();
33203
33863
  init_project_task_list_ensure();
33204
33864
  init_plan_project_link();
33865
+ init_stale_lock_handoff();
33866
+ init_audit_history_import();
33205
33867
  JSON_HEADERS4 = { "Content-Type": "application/json" };
33206
33868
  RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
33207
33869
  });
@@ -63967,6 +64629,7 @@ var init_http_client = __esm(() => {
63967
64629
  });
63968
64630
 
63969
64631
  // src/cli/cloud-router.ts
64632
+ import { randomUUID as randomUUID4 } from "crypto";
63970
64633
  import { resolve as resolvePath } from "path";
63971
64634
  function cleanMode(value) {
63972
64635
  const normalized = value?.trim().toLowerCase();
@@ -64369,7 +65032,10 @@ async function cloudGetTask(client, id) {
64369
65032
  }
64370
65033
  async function cloudCreateTask(client, input) {
64371
65034
  const expectedParentId = typeof input["parent_id"] === "string" ? input["parent_id"] : null;
64372
- 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"]));
64373
65039
  if (!created || typeof created.id !== "string" || !created.id.trim()) {
64374
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");
64375
65041
  }
@@ -64380,7 +65046,7 @@ async function cloudCreateTask(client, input) {
64380
65046
  return persisted;
64381
65047
  }
64382
65048
  async function cloudUpdateTask(client, id, patch) {
64383
- return unwrapTask(await client.update("tasks", id, patch));
65049
+ return unwrapTask(await client.transport.patch(`/tasks/${encodeURIComponent(id)}`, patch));
64384
65050
  }
64385
65051
  async function cloudDeleteTask(client, id) {
64386
65052
  try {