get-tbd 0.1.30 → 0.2.0

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 (91) hide show
  1. package/README.md +5 -1
  2. package/dist/bin.mjs +2823 -2226
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +1063 -665
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-DVap9omo.mjs → config-BJz1m9eN.mjs} +179 -39
  7. package/dist/config-BJz1m9eN.mjs.map +1 -0
  8. package/dist/{config-BPHcePSm.mjs → config-DlCUMyCG.mjs} +1 -1
  9. package/dist/docs/README.md +5 -1
  10. package/dist/docs/guidelines/backward-compatibility-rules.md +4 -0
  11. package/dist/docs/guidelines/bun-monorepo-patterns.md +20 -4
  12. package/dist/docs/guidelines/cli-agent-skill-patterns.md +38 -34
  13. package/dist/docs/guidelines/commit-conventions.md +4 -0
  14. package/dist/docs/guidelines/common-doc-guidelines.md +234 -0
  15. package/dist/docs/guidelines/convex-limits-best-practices.md +4 -0
  16. package/dist/docs/guidelines/convex-rules.md +4 -0
  17. package/dist/docs/guidelines/electron-app-development-patterns.md +4 -0
  18. package/dist/docs/guidelines/error-handling-rules.md +4 -0
  19. package/dist/docs/guidelines/general-coding-rules.md +4 -0
  20. package/dist/docs/guidelines/general-comment-rules.md +4 -0
  21. package/dist/docs/guidelines/general-eng-assistant-rules.md +4 -0
  22. package/dist/docs/guidelines/general-tdd-guidelines.md +4 -0
  23. package/dist/docs/guidelines/general-testing-rules.md +4 -0
  24. package/dist/docs/guidelines/golden-testing-guidelines.md +4 -0
  25. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +27 -6
  26. package/dist/docs/guidelines/python-cli-patterns.md +4 -0
  27. package/dist/docs/guidelines/python-modern-guidelines.md +30 -0
  28. package/dist/docs/guidelines/python-rules.md +4 -0
  29. package/dist/docs/guidelines/release-notes-guidelines.md +4 -0
  30. package/dist/docs/guidelines/supply-chain-hardening.md +11 -7
  31. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +10 -4
  32. package/dist/docs/guidelines/typescript-cli-tool-rules.md +27 -24
  33. package/dist/docs/guidelines/typescript-code-coverage.md +11 -7
  34. package/dist/docs/guidelines/typescript-rules.md +10 -6
  35. package/dist/docs/guidelines/typescript-sorting-patterns.md +4 -0
  36. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +7 -3
  37. package/dist/docs/shortcuts/standard/agent-handoff.md +4 -0
  38. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
  39. package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
  40. package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
  41. package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
  42. package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
  43. package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
  44. package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
  45. package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
  46. package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
  47. package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
  48. package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
  49. package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
  50. package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
  51. package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
  52. package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
  53. package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
  54. package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
  55. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
  56. package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
  57. package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
  58. package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
  59. package/dist/docs/shortcuts/standard/review-code.md +4 -0
  60. package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
  61. package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
  62. package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
  63. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
  64. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
  65. package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
  66. package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
  67. package/dist/docs/tbd-closing.md +4 -0
  68. package/dist/docs/tbd-design.md +109 -68
  69. package/dist/docs/tbd-docs.md +20 -13
  70. package/dist/docs/tbd-prime.md +4 -0
  71. package/dist/docs/templates/architecture-doc.md +4 -0
  72. package/dist/docs/templates/plan-spec.md +4 -0
  73. package/dist/docs/templates/qa-playbook.md +4 -0
  74. package/dist/docs/templates/research-brief.md +4 -0
  75. package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-CFoPVinz.mjs} +1 -1
  76. package/dist/{id-mapping-CqrrLgeX.mjs → id-mapping-CtfTfGIh.mjs} +146 -122
  77. package/dist/id-mapping-CtfTfGIh.mjs.map +1 -0
  78. package/dist/index.d.mts +53 -1
  79. package/dist/index.mjs +3 -3
  80. package/dist/{schemas-C8mOQykE.mjs → schemas-f0EcuAVu.mjs} +40 -3
  81. package/dist/schemas-f0EcuAVu.mjs.map +1 -0
  82. package/dist/{src-BK_EF6mk.mjs → src-rIE4xSVs.mjs} +3 -3
  83. package/dist/src-rIE4xSVs.mjs.map +1 -0
  84. package/dist/tbd +2823 -2226
  85. package/package.json +1 -1
  86. package/dist/config-DVap9omo.mjs.map +0 -1
  87. package/dist/docs/guidelines/general-style-rules.md +0 -38
  88. package/dist/docs/guidelines/writing-style-guidelines.md +0 -42
  89. package/dist/id-mapping-CqrrLgeX.mjs.map +0 -1
  90. package/dist/schemas-C8mOQykE.mjs.map +0 -1
  91. package/dist/src-BK_EF6mk.mjs.map +0 -1
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { S as IssueTitle, b as IssueSchema, g as ISSUE_TITLE_MAX_LENGTH, n as AtticEntrySchema, t as ATTIC_ENTRY_FIELD_ORDER, x as IssueStatus, y as IssueKind } from "./schemas-C8mOQykE.mjs";
2
- import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-BK_EF6mk.mjs";
1
+ import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
2
+ import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-rIE4xSVs.mjs";
3
3
  import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
4
- import { A as getWorkspaceDir, C as TBD_GUIDELINES_DIR, D as WORKSPACES_DIR, E as TBD_TEMPLATES_DIR, M as resolveAtticDir, N as resolveDataSyncDir, O as WORKTREE_DIR, S as TBD_DOCS_DIR, T as TBD_SHORTCUTS_SYSTEM, _ as DEFAULT_GUIDELINES_PATHS, a as isInitialized, b as SYNC_BRANCH, c as readConfigWithMigration, d as writeConfig, g as DATA_SYNC_DIR_NAME, h as DATA_SYNC_DIR, i as initConfig, j as isValidWorkspaceName, k as WORKTREE_DIR_NAME, l as readLocalState, m as CHARS_PER_TOKEN, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, u as updateLocalState, v as DEFAULT_SHORTCUT_PATHS, w as TBD_SHORTCUTS_STANDARD, x as TBD_DIR, y as DEFAULT_TEMPLATE_PATHS } from "./config-DVap9omo.mjs";
5
- import { _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-CqrrLgeX.mjs";
4
+ import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
5
+ import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-CtfTfGIh.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError } from "zod";
8
8
  import matter from "gray-matter";
@@ -12,7 +12,7 @@ import pc from "picocolors";
12
12
  import { marked } from "marked";
13
13
  import { markedTerminal } from "marked-terminal";
14
14
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
15
- import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
15
+ import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink } from "node:fs/promises";
16
16
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
17
17
  import { writeFile } from "atomically";
18
18
  import { homedir } from "node:os";
@@ -620,28 +620,6 @@ var SyncError = class extends CLIError {
620
620
  }
621
621
  };
622
622
  /**
623
- * Worktree missing error - the data-sync-worktree directory doesn't exist.
624
- * This indicates the worktree was never created or was deleted.
625
- * See: tbd-design.md §2.3.6 Worktree Error Classes
626
- */
627
- var WorktreeMissingError = class extends CLIError {
628
- constructor(message = "Worktree not found at .tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.") {
629
- super(message, 1);
630
- this.name = "WorktreeMissingError";
631
- }
632
- };
633
- /**
634
- * Worktree corrupted error - the worktree exists but is invalid.
635
- * This can occur when the .git file is missing or points to an invalid location.
636
- * See: tbd-design.md §2.3.6 Worktree Error Classes
637
- */
638
- var WorktreeCorruptedError = class extends CLIError {
639
- constructor(message = "Worktree at .tbd/data-sync-worktree/ is corrupted. Run 'tbd doctor --fix' to repair.") {
640
- super(message, 1);
641
- this.name = "WorktreeCorruptedError";
642
- }
643
- };
644
- /**
645
623
  * Classify a sync error to determine appropriate recovery action.
646
624
  *
647
625
  * Used by `tbd sync` to decide whether to:
@@ -706,10 +684,7 @@ var BaseCommand = class {
706
684
  try {
707
685
  return await action();
708
686
  } catch (error) {
709
- if (error instanceof CLIError) {
710
- this.output.error(error.message);
711
- throw error;
712
- }
687
+ if (error instanceof CLIError) throw error;
713
688
  const originalError = error instanceof Error ? error : void 0;
714
689
  const detail = originalError?.message;
715
690
  const fullMessage = detail && detail !== errorMessage ? `${errorMessage}: ${detail}` : errorMessage;
@@ -740,7 +715,7 @@ var BaseCommand = class {
740
715
  /**
741
716
  * Check if a path exists on the filesystem.
742
717
  */
743
- async function pathExists(path) {
718
+ async function pathExists$1(path) {
744
719
  try {
745
720
  await access(path);
746
721
  return true;
@@ -780,7 +755,7 @@ function hasGitignorePattern(content, pattern) {
780
755
  async function ensureGitignorePatterns(gitignorePath, patterns, header) {
781
756
  let content = "";
782
757
  let created = false;
783
- if (await pathExists(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
758
+ if (await pathExists$1(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
784
759
  else created = true;
785
760
  const entries = [];
786
761
  let currentPreamble = [];
@@ -974,6 +949,18 @@ async function git(...args) {
974
949
  return stdout.trim();
975
950
  }
976
951
  /**
952
+ * Run `git commit` in a worktree with gpg signing disabled at the command level.
953
+ *
954
+ * Internal tbd-sync commits are machine-generated data commits on the data branch,
955
+ * not user commits. They must not depend on ambient `commit.gpgsign` config: in
956
+ * signed-by-default environments without a usable signing key, an unguarded
957
+ * `git commit` fails and leaves `tbd-sync` unborn, which the f04 fail-closed
958
+ * health check then surfaces as "worktree corrupted" on every command.
959
+ */
960
+ async function gitCommit(workdir, ...args) {
961
+ return git("-c", "commit.gpgsign=false", "-C", workdir, "commit", ...args);
962
+ }
963
+ /**
977
964
  * Minimum Git version required.
978
965
  * Git 2.42 (August 2023) introduced `git worktree add --orphan` which tbd requires.
979
966
  */
@@ -1088,23 +1075,6 @@ function getUpgradeInstructions(currentVersion) {
1088
1075
  return `Git ${versionStr} detected. Git ${MIN_GIT_VERSION}+ required for tbd.\nUpgrade: ${upgradeUrl}`;
1089
1076
  }
1090
1077
  /**
1091
- * Execute a git command with isolated index.
1092
- * This protects the user's staging area during sync operations.
1093
- *
1094
- * See: tbd-design.md §3.3.2 Writing to Sync Branch
1095
- */
1096
- async function withIsolatedIndex(fn) {
1097
- const isolatedIndex = join(await git("rev-parse", "--git-dir"), "tbd-index");
1098
- const originalIndex = process.env.GIT_INDEX_FILE;
1099
- try {
1100
- process.env.GIT_INDEX_FILE = isolatedIndex;
1101
- return await fn();
1102
- } finally {
1103
- if (originalIndex) process.env.GIT_INDEX_FILE = originalIndex;
1104
- else delete process.env.GIT_INDEX_FILE;
1105
- }
1106
- }
1107
- /**
1108
1078
  * Field-level merge strategies for Issue fields.
1109
1079
  * See: tbd-design.md §3.5 Merge Rules
1110
1080
  */
@@ -1330,15 +1300,15 @@ async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
1330
1300
  /**
1331
1301
  * Get the current branch name.
1332
1302
  */
1333
- async function getCurrentBranch() {
1334
- return git("rev-parse", "--abbrev-ref", "HEAD");
1303
+ async function getCurrentBranch(baseDir) {
1304
+ return git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--abbrev-ref", "HEAD");
1335
1305
  }
1336
1306
  /**
1337
1307
  * Check if a branch exists locally.
1338
1308
  */
1339
- async function branchExists(branch) {
1309
+ async function branchExists(branch, baseDir) {
1340
1310
  try {
1341
- await git("rev-parse", "--verify", `refs/heads/${branch}`);
1311
+ await git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--verify", `refs/heads/${branch}`);
1342
1312
  return true;
1343
1313
  } catch {
1344
1314
  return false;
@@ -1347,19 +1317,38 @@ async function branchExists(branch) {
1347
1317
  /**
1348
1318
  * Check if a remote branch exists.
1349
1319
  */
1350
- async function remoteBranchExists(remote, branch) {
1320
+ async function remoteBranchExists(remote, branch, baseDir) {
1321
+ try {
1322
+ await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1323
+ return true;
1324
+ } catch {
1325
+ return false;
1326
+ }
1327
+ }
1328
+ async function pathExists(path) {
1351
1329
  try {
1352
- await git("ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1330
+ await access(path);
1353
1331
  return true;
1354
1332
  } catch {
1355
1333
  return false;
1356
1334
  }
1357
1335
  }
1336
+ async function pathsReferToSameLocation(a, b) {
1337
+ if (normalize(a) === normalize(b)) return true;
1338
+ try {
1339
+ return normalize(await realpath(a)) === normalize(await realpath(b));
1340
+ } catch {
1341
+ return false;
1342
+ }
1343
+ }
1344
+ async function getSharedPaths(baseDir) {
1345
+ return resolveSharedTbdPaths(baseDir);
1346
+ }
1358
1347
  /**
1359
1348
  * Check if the hidden worktree exists and is valid.
1360
1349
  */
1361
1350
  async function worktreeExists(baseDir) {
1362
- const worktreePath = join(baseDir, WORKTREE_DIR);
1351
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
1363
1352
  try {
1364
1353
  await access(worktreePath);
1365
1354
  await access(join(worktreePath, ".git"));
@@ -1373,15 +1362,15 @@ async function worktreeExists(baseDir) {
1373
1362
  * See: tbd-design.md §2.3 Worktree Lifecycle
1374
1363
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §3
1375
1364
  */
1376
- async function checkWorktreeHealth(baseDir) {
1377
- const worktreePath = join(baseDir, WORKTREE_DIR);
1365
+ async function checkWorktreeHealth(baseDir, syncBranch = SYNC_BRANCH) {
1366
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
1378
1367
  try {
1379
1368
  const lines = (await git("-C", baseDir, "worktree", "list", "--porcelain")).split("\n");
1380
1369
  let foundWorktree = false;
1381
1370
  let isPrunable = false;
1382
1371
  for (let i = 0; i < lines.length; i++) {
1383
1372
  const line = lines[i];
1384
- if (line?.startsWith("worktree ") && line.includes(WORKTREE_DIR_NAME)) {
1373
+ if (line?.startsWith("worktree ") && await pathsReferToSameLocation(line.slice(9), worktreePath)) {
1385
1374
  foundWorktree = true;
1386
1375
  for (let j = i + 1; j < lines.length && !lines[j]?.startsWith("worktree "); j++) if (lines[j]?.startsWith("prunable")) {
1387
1376
  isPrunable = true;
@@ -1449,6 +1438,14 @@ async function checkWorktreeHealth(baseDir) {
1449
1438
  } catch {
1450
1439
  branch = null;
1451
1440
  }
1441
+ if (branch !== syncBranch) return {
1442
+ exists: true,
1443
+ valid: false,
1444
+ status: "corrupted",
1445
+ branch,
1446
+ commit,
1447
+ error: branch === null ? `Shared worktree is detached; expected branch ${syncBranch}` : `Shared worktree is on ${branch}; expected branch ${syncBranch}`
1448
+ };
1452
1449
  return {
1453
1450
  exists: true,
1454
1451
  valid: true,
@@ -1467,22 +1464,110 @@ async function checkWorktreeHealth(baseDir) {
1467
1464
  };
1468
1465
  }
1469
1466
  }
1467
+ function isLegacyDataSyncWorktreePath(path, sharedWorktreePath) {
1468
+ const normalized = normalize(path);
1469
+ if (normalized === normalize(sharedWorktreePath)) return false;
1470
+ return basename(normalized) === WORKTREE_DIR_NAME && basename(dirname(normalized)) === TBD_DIR;
1471
+ }
1472
+ async function listLegacyWorktreePaths(baseDir, sharedWorktreePath) {
1473
+ const paths = /* @__PURE__ */ new Set();
1474
+ try {
1475
+ const worktreeList = await git("-C", baseDir, "worktree", "list", "--porcelain");
1476
+ for (const line of worktreeList.split("\n")) {
1477
+ if (!line.startsWith("worktree ")) continue;
1478
+ const worktreePath = line.slice(9);
1479
+ if (isLegacyDataSyncWorktreePath(worktreePath, sharedWorktreePath)) paths.add(worktreePath);
1480
+ }
1481
+ } catch {}
1482
+ const currentCheckoutLegacyPath = join(baseDir, LEGACY_WORKTREE_DIR);
1483
+ if (currentCheckoutLegacyPath !== sharedWorktreePath && await pathExists(currentCheckoutLegacyPath)) paths.add(currentCheckoutLegacyPath);
1484
+ return Array.from(paths);
1485
+ }
1486
+ async function preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch) {
1487
+ if (!await pathExists(join(legacyPath, ".git"))) return;
1488
+ if ((await git("-C", legacyPath, "status", "--porcelain").catch(() => "")).trim()) {
1489
+ await git("-C", legacyPath, "add", "-A");
1490
+ await gitCommit(legacyPath, "--no-verify", "-m", "tbd: preserve legacy sync data").catch((error) => {
1491
+ if (!(error instanceof Error ? error.message : String(error)).includes("nothing to commit")) throw error;
1492
+ });
1493
+ }
1494
+ const head = await git("-C", legacyPath, "rev-parse", "HEAD").catch(() => "");
1495
+ if (!head) return;
1496
+ const branchHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
1497
+ if (!branchHead) {
1498
+ await git("-C", baseDir, "branch", syncBranch, head);
1499
+ return;
1500
+ }
1501
+ if (head === branchHead) return;
1502
+ if (await git("-C", baseDir, "merge-base", "--is-ancestor", branchHead, head).then(() => true).catch(() => false)) {
1503
+ await git("-C", baseDir, "update-ref", `refs/heads/${syncBranch}`, head);
1504
+ return;
1505
+ }
1506
+ if (await git("-C", baseDir, "merge-base", "--is-ancestor", head, branchHead).then(() => true).catch(() => false)) return;
1507
+ const backupBranch = `tbd-legacy-preserve-${nowFilenameTimestamp()}`;
1508
+ await git("-C", baseDir, "branch", backupBranch, head);
1509
+ throw new Error(`Legacy sync worktree at ${legacyPath} diverges from ${syncBranch}. Preserved its HEAD as ${backupBranch}; run 'tbd doctor --fix' after reviewing the backup branch.`);
1510
+ }
1511
+ /**
1512
+ * Preserve and remove f03 per-checkout sync worktrees before creating the shared worktree.
1513
+ */
1514
+ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH) {
1515
+ const { sharedWorktreePath } = await getSharedPaths(baseDir);
1516
+ const legacyPaths = await listLegacyWorktreePaths(baseDir, sharedWorktreePath);
1517
+ let migrated = 0;
1518
+ try {
1519
+ for (const legacyPath of legacyPaths) {
1520
+ await preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch);
1521
+ try {
1522
+ await git("-C", baseDir, "worktree", "remove", legacyPath, "--force");
1523
+ } catch {
1524
+ await rm(legacyPath, {
1525
+ recursive: true,
1526
+ force: true
1527
+ });
1528
+ }
1529
+ migrated += 1;
1530
+ }
1531
+ if (legacyPaths.length > 0) await git("-C", baseDir, "worktree", "prune");
1532
+ return {
1533
+ success: true,
1534
+ migrated
1535
+ };
1536
+ } catch (error) {
1537
+ return {
1538
+ success: false,
1539
+ migrated,
1540
+ error: error instanceof Error ? error.message : String(error)
1541
+ };
1542
+ }
1543
+ }
1470
1544
  /**
1471
1545
  * Initialize the hidden worktree for the tbd-sync branch.
1472
1546
  * Follows the decision tree from tbd-design.md §2.3.
1473
1547
  *
1548
+ * MUST be called while holding `withSharedDataSyncLock` — it migrates legacy
1549
+ * per-checkout worktrees and creates the shared attached worktree on tbd-sync,
1550
+ * so concurrent callers can otherwise race branch ownership and migration.
1551
+ *
1474
1552
  * @param baseDir - The base directory of the repository
1475
1553
  * @param remote - The remote name (default: 'origin')
1476
1554
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1477
1555
  * @returns Path to the worktree or error message
1478
1556
  */
1479
1557
  async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
1480
- const worktreePath = join(baseDir, WORKTREE_DIR);
1558
+ const paths = await getSharedPaths(baseDir);
1559
+ const worktreePath = paths.sharedWorktreePath;
1481
1560
  if (await worktreeExists(baseDir)) return {
1482
1561
  success: true,
1483
1562
  path: worktreePath,
1484
1563
  created: false
1485
1564
  };
1565
+ await mkdir(paths.sharedTbdDir, { recursive: true });
1566
+ const migrationResult = await migrateLegacyWorktreesToShared(baseDir, syncBranch);
1567
+ if (!migrationResult.success) return {
1568
+ success: false,
1569
+ error: migrationResult.error
1570
+ };
1486
1571
  try {
1487
1572
  await rm(worktreePath, {
1488
1573
  recursive: true,
@@ -1490,7 +1575,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1490
1575
  });
1491
1576
  } catch {}
1492
1577
  try {
1493
- if (await branchExists(syncBranch)) {
1578
+ if (await branchExists(syncBranch, baseDir)) {
1494
1579
  await git("-C", baseDir, "worktree", "add", worktreePath, syncBranch);
1495
1580
  return {
1496
1581
  success: true,
@@ -1498,7 +1583,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1498
1583
  created: true
1499
1584
  };
1500
1585
  }
1501
- if (await remoteBranchExists(remote, syncBranch)) {
1586
+ if (await remoteBranchExists(remote, syncBranch, baseDir)) {
1502
1587
  await git("-C", baseDir, "fetch", remote, syncBranch);
1503
1588
  await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
1504
1589
  return {
@@ -1513,12 +1598,12 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1513
1598
  await mkdir(join(dataSyncPath, "issues"), { recursive: true });
1514
1599
  await mkdir(join(dataSyncPath, "mappings"), { recursive: true });
1515
1600
  await mkdir(join(dataSyncPath, "attic", "conflicts"), { recursive: true });
1516
- await writeFile(join(dataSyncPath, "meta.yml"), "schema_version: 1\n");
1601
+ await writeFile(join(dataSyncPath, "meta.yml"), `schema_version: ${DATA_SYNC_SCHEMA_VERSION}\n`);
1517
1602
  await writeFile(join(dataSyncPath, "issues", ".gitkeep"), "");
1518
1603
  await writeFile(join(dataSyncPath, "mappings", ".gitkeep"), "");
1519
1604
  await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
1520
1605
  await git("-C", worktreePath, "add", ".");
1521
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "Initialize tbd-sync branch");
1606
+ await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
1522
1607
  return {
1523
1608
  success: true,
1524
1609
  path: worktreePath,
@@ -1538,16 +1623,17 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1538
1623
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1539
1624
  * @returns Health status indicating if branch exists and has commits
1540
1625
  */
1541
- async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
1626
+ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH, baseDir) {
1627
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1542
1628
  try {
1543
1629
  return {
1544
1630
  exists: true,
1545
1631
  orphaned: false,
1546
- head: (await git("rev-parse", `refs/heads/${syncBranch}`)).trim()
1632
+ head: (await git(...dirArgs, "rev-parse", `refs/heads/${syncBranch}`)).trim()
1547
1633
  };
1548
1634
  } catch {
1549
1635
  try {
1550
- await git("show-ref", "--verify", `refs/heads/${syncBranch}`);
1636
+ await git(...dirArgs, "show-ref", "--verify", `refs/heads/${syncBranch}`);
1551
1637
  return {
1552
1638
  exists: true,
1553
1639
  orphaned: true
@@ -1568,14 +1654,15 @@ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
1568
1654
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1569
1655
  * @returns Health status indicating if remote branch exists and divergence state
1570
1656
  */
1571
- async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH) {
1657
+ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
1658
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1572
1659
  try {
1573
- await git("fetch", remote, syncBranch);
1574
- const remoteHead = (await git("rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
1660
+ await git(...dirArgs, "fetch", remote, syncBranch);
1661
+ const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
1575
1662
  let diverged = false;
1576
1663
  try {
1577
- const mergeBase = await git("merge-base", syncBranch, `${remote}/${syncBranch}`);
1578
- const localHead = await git("rev-parse", syncBranch);
1664
+ const mergeBase = await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`);
1665
+ const localHead = await git(...dirArgs, "rev-parse", syncBranch);
1579
1666
  diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
1580
1667
  } catch {
1581
1668
  diverged = false;
@@ -1602,7 +1689,8 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
1602
1689
  * @returns Consistency status with HEAD comparisons and ahead/behind counts
1603
1690
  */
1604
1691
  async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote = "origin") {
1605
- const worktreeHead = await git("-C", join(baseDir, WORKTREE_DIR), "rev-parse", "HEAD").catch(() => "");
1692
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
1693
+ const worktreeHead = await git("-C", worktreePath, "rev-parse", "HEAD").catch(() => "");
1606
1694
  const localHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
1607
1695
  const remoteHead = await git("-C", baseDir, "rev-parse", `${remote}/${syncBranch}`).catch(() => "");
1608
1696
  let localAhead = 0;
@@ -1634,10 +1722,12 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
1634
1722
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1635
1723
  * @returns Number of issue files on the remote branch, or null if branch doesn't exist
1636
1724
  */
1637
- async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
1725
+ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
1726
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1638
1727
  try {
1639
- await git("fetch", remote, syncBranch);
1640
- const output = await git("ls-tree", "-r", "--name-only", `${remote}/${syncBranch}`);
1728
+ await git(...dirArgs, "fetch", remote, syncBranch);
1729
+ const remoteBranch = `${remote}/${syncBranch}`;
1730
+ const output = await git(...dirArgs, "ls-tree", "-r", "--name-only", remoteBranch);
1641
1731
  const issuesDir = `${TBD_DIR}/${DATA_SYNC_DIR_NAME}/issues/`;
1642
1732
  return output.split("\n").filter(Boolean).filter((line) => line.startsWith(issuesDir) && line.endsWith(".md")).length;
1643
1733
  } catch {
@@ -1652,6 +1742,10 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
1652
1742
  * - CORRUPTED: backup to .tbd/backups/, remove, then recreate
1653
1743
  * - MISSING: just create
1654
1744
  *
1745
+ * MUST be called while holding `withSharedDataSyncLock` — repair mutates
1746
+ * shared worktree and branch state and shares the same locking contract as
1747
+ * `initWorktree`.
1748
+ *
1655
1749
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
1656
1750
  *
1657
1751
  * @param baseDir - The base directory of the repository
@@ -1660,13 +1754,12 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
1660
1754
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1661
1755
  */
1662
1756
  async function repairWorktree(baseDir, status, remote = "origin", syncBranch = SYNC_BRANCH) {
1663
- const worktreePath = join(baseDir, WORKTREE_DIR);
1757
+ const { sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
1664
1758
  try {
1665
1759
  if (status === "missing" || status === "prunable") await git("-C", baseDir, "worktree", "prune");
1666
1760
  if (status === "corrupted") {
1667
- const backupsDir = join(baseDir, TBD_DIR, "backups");
1668
- await mkdir(backupsDir, { recursive: true });
1669
- const backupPath = join(backupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1761
+ await mkdir(sharedBackupsDir, { recursive: true });
1762
+ const backupPath = join(sharedBackupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1670
1763
  try {
1671
1764
  await cp(worktreePath, backupPath, { recursive: true });
1672
1765
  } catch {}
@@ -1697,9 +1790,15 @@ async function repairWorktree(baseDir, status, remote = "origin", syncBranch = S
1697
1790
  * @returns true if worktree was detached and repaired, false if already attached
1698
1791
  */
1699
1792
  async function ensureWorktreeAttached(worktreePath) {
1793
+ return ensureWorktreeAttachedToBranch(worktreePath, SYNC_BRANCH);
1794
+ }
1795
+ /**
1796
+ * Ensure worktree is attached to the requested sync branch, not detached HEAD.
1797
+ */
1798
+ async function ensureWorktreeAttachedToBranch(worktreePath, syncBranch = SYNC_BRANCH) {
1700
1799
  try {
1701
- if (!await git("-C", worktreePath, "branch", "--show-current").catch(() => "")) {
1702
- await git("-C", worktreePath, "checkout", SYNC_BRANCH);
1800
+ if (await git("-C", worktreePath, "branch", "--show-current").catch(() => "") !== syncBranch) {
1801
+ await git("-C", worktreePath, "checkout", syncBranch);
1703
1802
  return true;
1704
1803
  }
1705
1804
  return false;
@@ -1725,28 +1824,23 @@ async function ensureWorktreeAttached(worktreePath) {
1725
1824
  */
1726
1825
  async function migrateDataToWorktree(baseDir, removeSource = false) {
1727
1826
  const wrongPath = join(baseDir, TBD_DIR, DATA_SYNC_DIR_NAME);
1728
- const correctPath = join(baseDir, WORKTREE_DIR, TBD_DIR, DATA_SYNC_DIR_NAME);
1729
- const worktreePath = join(baseDir, WORKTREE_DIR);
1827
+ const { sharedDataSyncDir: correctPath, sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
1730
1828
  try {
1731
1829
  await ensureWorktreeAttached(worktreePath);
1732
1830
  const wrongIssuesPath = join(wrongPath, "issues");
1733
1831
  const wrongMappingsPath = join(wrongPath, "mappings");
1734
1832
  let issueFiles = [];
1735
1833
  let mappingFiles = [];
1736
- try {
1737
- const { readdir } = await import("node:fs/promises");
1738
- issueFiles = await readdir(wrongIssuesPath).catch(() => []);
1739
- mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
1740
- } catch {}
1834
+ issueFiles = await readdir(wrongIssuesPath).catch(() => []);
1835
+ mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
1741
1836
  issueFiles = issueFiles.filter((f) => f !== ".gitkeep");
1742
1837
  mappingFiles = mappingFiles.filter((f) => f !== ".gitkeep");
1743
1838
  if (issueFiles.length === 0 && mappingFiles.length === 0) return {
1744
1839
  success: true,
1745
1840
  migratedCount: 0
1746
1841
  };
1747
- const backupsDir = join(baseDir, TBD_DIR, "backups");
1748
- await mkdir(backupsDir, { recursive: true });
1749
- const backupPath = join(backupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1842
+ await mkdir(sharedBackupsDir, { recursive: true });
1843
+ const backupPath = join(sharedBackupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1750
1844
  await cp(wrongPath, backupPath, { recursive: true });
1751
1845
  const correctIssuesPath = join(correctPath, "issues");
1752
1846
  const correctMappingsPath = join(correctPath, "mappings");
@@ -1754,8 +1848,6 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1754
1848
  await mkdir(correctMappingsPath, { recursive: true });
1755
1849
  for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
1756
1850
  for (const file of mappingFiles) if (file === "ids.yml") {
1757
- const { readFile } = await import("node:fs/promises");
1758
- const { loadIdMapping, mergeIdMappings, saveIdMapping, resolveIdMappingConflicts } = await import("./id-mapping-Ctfl_nc1.mjs");
1759
1851
  const sourceMapping = resolveIdMappingConflicts(await readFile(join(wrongMappingsPath, file), "utf-8"));
1760
1852
  let targetMapping;
1761
1853
  try {
@@ -1767,7 +1859,7 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1767
1859
  } else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
1768
1860
  const totalFiles = issueFiles.length + mappingFiles.length;
1769
1861
  await git("-C", worktreePath, "add", "-A");
1770
- if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await git("-C", worktreePath, "commit", "--no-verify", "-m", `tbd: migrate ${totalFiles} file(s) from incorrect location`);
1862
+ if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await gitCommit(worktreePath, "--no-verify", "-m", `tbd: migrate ${totalFiles} file(s) from incorrect location`);
1771
1863
  if (removeSource) {
1772
1864
  for (const file of issueFiles) await rm(join(wrongIssuesPath, file));
1773
1865
  for (const file of mappingFiles) await rm(join(wrongMappingsPath, file));
@@ -1786,6 +1878,83 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1786
1878
  }
1787
1879
  }
1788
1880
 
1881
+ //#endregion
1882
+ //#region src/file/common-dir-layout.ts
1883
+ /**
1884
+ * Git common-dir layout metadata for shared issue sync machinery.
1885
+ */
1886
+ /**
1887
+ * Error thrown when common-dir layout metadata cannot be used safely.
1888
+ */
1889
+ var CommonDirLayoutError = class extends Error {
1890
+ constructor(message) {
1891
+ super(message);
1892
+ this.name = "CommonDirLayoutError";
1893
+ }
1894
+ };
1895
+ /**
1896
+ * Read $GIT_COMMON_DIR/tbd/layout.yml, returning null when it has not been created yet.
1897
+ */
1898
+ async function readCommonDirLayout(layoutPath) {
1899
+ try {
1900
+ const content = await readFile(layoutPath, "utf-8");
1901
+ return CommonDirLayoutSchema.parse(parse(content));
1902
+ } catch (error) {
1903
+ if (error.code === "ENOENT") return null;
1904
+ throw new CommonDirLayoutError(`Invalid tbd common-dir layout metadata at ${layoutPath}: ${error instanceof Error ? error.message : String(error)}`);
1905
+ }
1906
+ }
1907
+ /**
1908
+ * Validate that common-dir layout metadata matches the checkout config.
1909
+ */
1910
+ function validateCommonDirLayout(layout, config) {
1911
+ if (!isCompatibleFormat(layout.tbd_format)) throw new CommonDirLayoutError(formatUpgradeMessage("Common-dir layout", layout.tbd_format, CURRENT_FORMAT));
1912
+ if (layout.tbd_format !== config.tbd_format) throw new CommonDirLayoutError(`Common-dir layout format '${layout.tbd_format}' does not match config format '${config.tbd_format}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
1913
+ const layoutStorage = layout.sync_storage;
1914
+ const configStorage = config.sync.storage;
1915
+ if (layoutStorage !== configStorage) throw new CommonDirLayoutError(`Common-dir sync storage '${layoutStorage}' does not match config storage '${configStorage}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
1916
+ }
1917
+ /**
1918
+ * Write common-dir layout metadata using the synchronized tbd_format ID.
1919
+ */
1920
+ async function writeCommonDirLayout(paths, config, existing) {
1921
+ await mkdir(dirname(paths.sharedLayoutPath), { recursive: true });
1922
+ const timestamp = now();
1923
+ const layout = CommonDirLayoutSchema.parse({
1924
+ tbd_format: config.tbd_format,
1925
+ sync_storage: config.sync.storage,
1926
+ data_sync_worktree: "data-sync-worktree",
1927
+ lock_profile: "data-sync-v1",
1928
+ created_at: existing?.created_at ?? timestamp,
1929
+ updated_at: timestamp
1930
+ });
1931
+ const yaml = stringifyYaml(sortKeys(layout, COMMON_DIR_LAYOUT_FIELD_ORDER), {
1932
+ lineWidth: 0,
1933
+ sortMapEntries: false
1934
+ });
1935
+ await writeFile(paths.sharedLayoutPath, yaml);
1936
+ return layout;
1937
+ }
1938
+ /**
1939
+ * Ensure layout metadata exists and matches the checkout config.
1940
+ */
1941
+ async function ensureCommonDirLayout(paths, config) {
1942
+ const existing = await readCommonDirLayout(paths.sharedLayoutPath);
1943
+ if (existing) {
1944
+ validateCommonDirLayout(existing, config);
1945
+ return existing;
1946
+ }
1947
+ return writeCommonDirLayout(paths, config);
1948
+ }
1949
+ /**
1950
+ * Run a critical section while holding the repo-scoped data-sync lock.
1951
+ */
1952
+ async function withSharedDataSyncLock(tbdRoot, fn) {
1953
+ const paths = await resolveSharedTbdPaths(tbdRoot);
1954
+ await mkdir(paths.sharedLocksDir, { recursive: true });
1955
+ return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
1956
+ }
1957
+
1789
1958
  //#endregion
1790
1959
  //#region src/cli/commands/init.ts
1791
1960
  /**
@@ -1816,7 +1985,7 @@ Example:
1816
1985
  tbd init --prefix=${prefix} --force`);
1817
1986
  if (this.checkDryRun("Would initialize tbd repository", options)) return;
1818
1987
  await this.execute(async () => {
1819
- await initConfig(cwd, VERSION, options.prefix);
1988
+ const config = await initConfig(cwd, VERSION, options.prefix);
1820
1989
  this.output.debug(`Created ${TBD_DIR}/config.yml with prefix '${options.prefix}'`);
1821
1990
  await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
1822
1991
  "# Installed documentation (regenerated on setup)",
@@ -1857,11 +2026,19 @@ Example:
1857
2026
  if (error instanceof CLIError) throw error;
1858
2027
  this.output.debug(`Git version check skipped: ${error.message}`);
1859
2028
  }
1860
- const worktreeResult = await initWorktree(cwd, remote, syncBranch);
2029
+ const { worktreeResult, sharedPaths } = await withSharedDataSyncLock(cwd, async () => {
2030
+ const result = await initWorktree(cwd, remote, syncBranch);
2031
+ const paths = await resolveSharedTbdPaths(cwd);
2032
+ if (result.success) await writeCommonDirLayout(paths, config);
2033
+ return {
2034
+ worktreeResult: result,
2035
+ sharedPaths: paths
2036
+ };
2037
+ });
1861
2038
  if (worktreeResult.success) {
1862
- if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${TBD_DIR}/${WORKTREE_DIR_NAME}/`);
1863
- else this.output.debug(`Worktree already exists at ${TBD_DIR}/${WORKTREE_DIR_NAME}/`);
1864
- const health = await checkWorktreeHealth(cwd);
2039
+ if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${sharedPaths.sharedWorktreePath}`);
2040
+ else this.output.debug(`Worktree already exists at ${sharedPaths.sharedWorktreePath}`);
2041
+ const health = await checkWorktreeHealth(cwd, syncBranch);
1865
2042
  if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
1866
2043
  } else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
1867
2044
  }, "Failed to initialize tbd");
@@ -1913,7 +2090,8 @@ function formatUnknownError(error) {
1913
2090
  * Storage layer for issue files.
1914
2091
  *
1915
2092
  * Provides atomic file operations and issue CRUD operations.
1916
- * All operations work on the hidden worktree at .tbd/data-sync/issues/.
2093
+ * All operations work on the data-sync directory selected by the caller. In production
2094
+ * that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
1917
2095
  *
1918
2096
  * See: tbd-design.md §3.2 Storage Layer
1919
2097
  */
@@ -2208,6 +2386,152 @@ function validateIssueTitle(title, options) {
2208
2386
  return title;
2209
2387
  }
2210
2388
 
2389
+ //#endregion
2390
+ //#region src/cli/lib/data-context.ts
2391
+ async function probeDataSyncReadiness(tbdRoot) {
2392
+ const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
2393
+ const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
2394
+ const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
2395
+ if (layout) validateCommonDirLayout(layout, config);
2396
+ const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
2397
+ return {
2398
+ config,
2399
+ migrated,
2400
+ fromFormat,
2401
+ sharedPaths,
2402
+ layout,
2403
+ health,
2404
+ ready: !migrated && layout !== null && health.valid
2405
+ };
2406
+ }
2407
+ /**
2408
+ * Apply any pending first-use initialization, migration, or repair to the shared
2409
+ * data-sync layout. MUST be called while holding `withSharedDataSyncLock` so that
2410
+ * worktree repair, layout writes, and migrated-config writes are serialized.
2411
+ */
2412
+ async function ensureSharedDataSyncLayout(tbdRoot, probe) {
2413
+ let repairedWorktreeStatus;
2414
+ if (!probe.health.valid) if (probe.health.status === "missing" || probe.health.status === "prunable") {
2415
+ const repairResult = await repairWorktree(tbdRoot, probe.health.status, probe.config.sync.remote, probe.config.sync.branch);
2416
+ if (!repairResult.success) throw new Error(`Failed to initialize shared data-sync worktree: ${repairResult.error}`);
2417
+ repairedWorktreeStatus = probe.health.status;
2418
+ } else throw new Error(`Shared data-sync worktree is ${probe.health.status}: ${probe.health.error ?? "unknown error"}. Run 'tbd doctor --fix' to repair.`);
2419
+ await ensureCommonDirLayout(probe.sharedPaths, probe.config);
2420
+ if (probe.migrated) {
2421
+ await writeConfig(tbdRoot, probe.config);
2422
+ notifyConfigMigrated(probe.fromFormat, CURRENT_FORMAT);
2423
+ }
2424
+ return repairedWorktreeStatus;
2425
+ }
2426
+ /**
2427
+ * Emit a one-time stderr notice when this checkout's `.tbd/config.yml` was migrated
2428
+ * (typically `fXX → fYY`). The config bump is the "publish" step of the format
2429
+ * migration and lands as a tracked diff on the current branch; users on a sibling
2430
+ * worktree (and even on main) deserve to know that without having to discover the
2431
+ * diff themselves later.
2432
+ *
2433
+ * See: docs/tbd-format-versioning.md (internal contributor guide) and
2434
+ * plan-2026-05-17-shared-common-dir-sync-worktree.md.
2435
+ */
2436
+ function notifyConfigMigrated(fromFormat, toFormat) {
2437
+ if (fromFormat === toFormat) return;
2438
+ const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
2439
+ process.stderr.write(`• tbd_format ${arrow}: .tbd/config.yml updated in this checkout. Commit on this branch or merge main to publish the format upgrade.\n`);
2440
+ }
2441
+ async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
2442
+ const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
2443
+ return {
2444
+ dataSyncDir,
2445
+ mapping: await loadIdMapping(dataSyncDir),
2446
+ config: probe.config,
2447
+ prefix: probe.config.display.id_prefix,
2448
+ sharedPaths: probe.sharedPaths,
2449
+ repairedWorktreeStatus
2450
+ };
2451
+ }
2452
+ /**
2453
+ * Load all common data context needed by tbd commands.
2454
+ *
2455
+ * For writers this is called inside `withSharedDataSyncLock` by
2456
+ * `withDataSyncContext({ lock: true }, ...)`, so any ensure/migrate/repair work
2457
+ * is serialized.
2458
+ */
2459
+ async function prepareDataSyncContext(tbdRoot) {
2460
+ const probe = await probeDataSyncReadiness(tbdRoot);
2461
+ return assembleDataContext(tbdRoot, probe, probe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, probe));
2462
+ }
2463
+ /**
2464
+ * Prepare shared data-sync context, optionally holding the repo-scoped lock.
2465
+ *
2466
+ * - `{ lock: true }` (writers): always acquire the lock, then prepare under it.
2467
+ * - `{ lock: false }` (readers): probe first; only acquire the lock if first-use
2468
+ * init/migrate/repair is actually required. Steady-state reads take no lock.
2469
+ */
2470
+ async function withDataSyncContext(tbdRoot, options, fn) {
2471
+ if (options.lock) return withSharedDataSyncLock(tbdRoot, async () => fn(await prepareDataSyncContext(tbdRoot)));
2472
+ const probe = await probeDataSyncReadiness(tbdRoot);
2473
+ if (probe.ready) return fn(await assembleDataContext(tbdRoot, probe));
2474
+ return withSharedDataSyncLock(tbdRoot, async () => {
2475
+ const reProbe = await probeDataSyncReadiness(tbdRoot);
2476
+ return fn(await assembleDataContext(tbdRoot, reProbe, reProbe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, reProbe)));
2477
+ });
2478
+ }
2479
+ /**
2480
+ * Load the shared data-sync context for a read-only command.
2481
+ *
2482
+ * Read commands skip the shared lock when the layout and worktree are already
2483
+ * valid and the on-disk config needs no migration. When first-use
2484
+ * init/migrate/repair IS required, the underlying `withDataSyncContext` takes
2485
+ * the lock and runs the ensure path so concurrent readers cannot race
2486
+ * migration or worktree repair.
2487
+ */
2488
+ async function loadDataContext(tbdRoot) {
2489
+ return withDataSyncContext(tbdRoot, { lock: false }, async (context) => context);
2490
+ }
2491
+ /**
2492
+ * Load unified command context with CLI options, data, and helper methods.
2493
+ *
2494
+ * This is the recommended way to initialize command context. It:
2495
+ * 1. Checks that tbd is initialized (calls requireInit)
2496
+ * 2. Loads data context (dataSyncDir, mapping, config, prefix)
2497
+ * 3. Extracts CLI context from Commander
2498
+ * 4. Provides helper methods like displayId() and resolveId()
2499
+ *
2500
+ * Usage:
2501
+ * ```ts
2502
+ * class MyHandler extends BaseCommand {
2503
+ * async run(id: string): Promise<void> {
2504
+ * const ctx = await loadFullContext(this.command);
2505
+ * const internalId = ctx.resolveId(id);
2506
+ * const issue = await readIssue(ctx.dataSyncDir, internalId);
2507
+ * console.log(ctx.displayId(issue.id));
2508
+ * }
2509
+ * }
2510
+ * ```
2511
+ *
2512
+ * @param command - The Commander command instance
2513
+ * @throws Error if tbd is not initialized or resources fail to load
2514
+ */
2515
+ async function loadFullContext(command) {
2516
+ const tbdRoot = await requireInit();
2517
+ const cli = getCommandContext(command);
2518
+ const dataCtx = await loadDataContext(tbdRoot);
2519
+ return {
2520
+ ...dataCtx,
2521
+ cli,
2522
+ displayId(internalId) {
2523
+ return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
2524
+ },
2525
+ resolveId(inputId) {
2526
+ try {
2527
+ return resolveToInternalId(inputId, dataCtx.mapping);
2528
+ } catch {
2529
+ throw new NotFoundError("Issue", inputId);
2530
+ }
2531
+ }
2532
+ };
2533
+ }
2534
+
2211
2535
  //#endregion
2212
2536
  //#region src/cli/commands/create.ts
2213
2537
  /**
@@ -2252,52 +2576,52 @@ var CreateHandler = class extends BaseCommand {
2252
2576
  let prefix;
2253
2577
  let issue;
2254
2578
  await this.execute(async () => {
2255
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
2256
- prefix = (await readConfig(tbdRoot)).display.id_prefix;
2257
- const mapping = await loadIdMapping(dataSyncDir);
2258
- shortId = generateUniqueShortId(mapping);
2259
- addIdMapping(mapping, ulid, shortId);
2260
- let parentId;
2261
- if (options.parent) try {
2262
- parentId = resolveToInternalId(options.parent, mapping);
2263
- } catch {
2264
- throw new ValidationError(`Invalid parent ID: ${options.parent}`);
2265
- }
2266
- if (!specPath && parentId) {
2267
- const parentIssue = await readIssue(dataSyncDir, parentId);
2268
- if (parentIssue.spec_path) specPath = parentIssue.spec_path;
2269
- }
2270
- issue = {
2271
- type: "is",
2272
- id,
2273
- version: 1,
2274
- title: validatedTitle,
2275
- kind,
2276
- status: "open",
2277
- priority,
2278
- labels: options.label ?? [],
2279
- dependencies: [],
2280
- created_at: timestamp,
2281
- updated_at: timestamp,
2282
- description: description ?? void 0,
2283
- assignee: options.assignee ?? void 0,
2284
- due_date: options.due ?? void 0,
2285
- deferred_until: options.defer ?? void 0,
2286
- parent_id: parentId,
2287
- spec_path: specPath
2288
- };
2289
- await writeIssue(dataSyncDir, issue);
2290
- await saveIdMapping(dataSyncDir, mapping);
2291
- if (parentId) try {
2292
- const parentIssue = await readIssue(dataSyncDir, parentId);
2293
- const hints = parentIssue.child_order_hints ?? [];
2294
- if (!hints.includes(id)) {
2295
- parentIssue.child_order_hints = [...hints, id];
2296
- parentIssue.version += 1;
2297
- parentIssue.updated_at = timestamp;
2298
- await writeIssue(dataSyncDir, parentIssue);
2579
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
2580
+ prefix = config.display.id_prefix;
2581
+ shortId = generateUniqueShortId(mapping);
2582
+ addIdMapping(mapping, ulid, shortId);
2583
+ let parentId;
2584
+ if (options.parent) try {
2585
+ parentId = resolveToInternalId(options.parent, mapping);
2586
+ } catch {
2587
+ throw new ValidationError(`Invalid parent ID: ${options.parent}`);
2299
2588
  }
2300
- } catch {}
2589
+ if (!specPath && parentId) {
2590
+ const parentIssue = await readIssue(dataSyncDir, parentId);
2591
+ if (parentIssue.spec_path) specPath = parentIssue.spec_path;
2592
+ }
2593
+ issue = {
2594
+ type: "is",
2595
+ id,
2596
+ version: 1,
2597
+ title: validatedTitle,
2598
+ kind,
2599
+ status: "open",
2600
+ priority,
2601
+ labels: options.label ?? [],
2602
+ dependencies: [],
2603
+ created_at: timestamp,
2604
+ updated_at: timestamp,
2605
+ description: description ?? void 0,
2606
+ assignee: options.assignee ?? void 0,
2607
+ due_date: options.due ?? void 0,
2608
+ deferred_until: options.defer ?? void 0,
2609
+ parent_id: parentId,
2610
+ spec_path: specPath
2611
+ };
2612
+ await writeIssue(dataSyncDir, issue);
2613
+ await saveIdMapping(dataSyncDir, mapping);
2614
+ if (parentId) try {
2615
+ const parentIssue = await readIssue(dataSyncDir, parentId);
2616
+ const hints = parentIssue.child_order_hints ?? [];
2617
+ if (!hints.includes(id)) {
2618
+ parentIssue.child_order_hints = [...hints, id];
2619
+ parentIssue.version += 1;
2620
+ parentIssue.updated_at = timestamp;
2621
+ await writeIssue(dataSyncDir, parentIssue);
2622
+ }
2623
+ } catch {}
2624
+ });
2301
2625
  }, "Failed to create issue");
2302
2626
  const displayId = `${prefix}-${shortId}`;
2303
2627
  this.output.data({
@@ -2342,77 +2666,6 @@ function applyLimit(items, limitOption) {
2342
2666
  return items.slice(0, limit);
2343
2667
  }
2344
2668
 
2345
- //#endregion
2346
- //#region src/cli/lib/data-context.ts
2347
- /**
2348
- * Load all common data context needed by tbd commands.
2349
- *
2350
- * This loads:
2351
- * - dataSyncDir from resolveDataSyncDir()
2352
- * - mapping from loadIdMapping()
2353
- * - config from readConfig()
2354
- * - prefix from config.display.id_prefix
2355
- *
2356
- * Call this once at the start of a command handler instead of
2357
- * loading each piece separately.
2358
- *
2359
- * @param tbdRoot - The tbd repository root directory (from requireInit or findTbdRoot)
2360
- * @throws Error if any of the resources fail to load
2361
- */
2362
- async function loadDataContext(tbdRoot) {
2363
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
2364
- const [mapping, config] = await Promise.all([loadIdMapping(dataSyncDir), readConfig(tbdRoot)]);
2365
- return {
2366
- dataSyncDir,
2367
- mapping,
2368
- config,
2369
- prefix: config.display.id_prefix
2370
- };
2371
- }
2372
- /**
2373
- * Load unified command context with CLI options, data, and helper methods.
2374
- *
2375
- * This is the recommended way to initialize command context. It:
2376
- * 1. Checks that tbd is initialized (calls requireInit)
2377
- * 2. Loads data context (dataSyncDir, mapping, config, prefix)
2378
- * 3. Extracts CLI context from Commander
2379
- * 4. Provides helper methods like displayId() and resolveId()
2380
- *
2381
- * Usage:
2382
- * ```ts
2383
- * class MyHandler extends BaseCommand {
2384
- * async run(id: string): Promise<void> {
2385
- * const ctx = await loadFullContext(this.command);
2386
- * const internalId = ctx.resolveId(id);
2387
- * const issue = await readIssue(ctx.dataSyncDir, internalId);
2388
- * console.log(ctx.displayId(issue.id));
2389
- * }
2390
- * }
2391
- * ```
2392
- *
2393
- * @param command - The Commander command instance
2394
- * @throws Error if tbd is not initialized or resources fail to load
2395
- */
2396
- async function loadFullContext(command) {
2397
- const tbdRoot = await requireInit();
2398
- const cli = getCommandContext(command);
2399
- const dataCtx = await loadDataContext(tbdRoot);
2400
- return {
2401
- ...dataCtx,
2402
- cli,
2403
- displayId(internalId) {
2404
- return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
2405
- },
2406
- resolveId(inputId) {
2407
- try {
2408
- return resolveToInternalId(inputId, dataCtx.mapping);
2409
- } catch {
2410
- throw new NotFoundError("Issue", inputId);
2411
- }
2412
- }
2413
- };
2414
- }
2415
-
2416
2669
  //#endregion
2417
2670
  //#region src/lib/status.ts
2418
2671
  /**
@@ -3060,81 +3313,83 @@ const showCommand = new Command("show").description("Show issue details").argume
3060
3313
  var UpdateHandler = class extends BaseCommand {
3061
3314
  async run(id, options) {
3062
3315
  const tbdRoot = await requireInit();
3063
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3064
- const mapping = await loadIdMapping(dataSyncDir);
3065
- let internalId;
3066
- try {
3067
- internalId = resolveToInternalId(id, mapping);
3068
- } catch {
3069
- throw new NotFoundError("Issue", id);
3070
- }
3071
- let issue;
3072
- try {
3073
- issue = await readIssue(dataSyncDir, internalId);
3074
- } catch {
3075
- throw new NotFoundError("Issue", id);
3076
- }
3077
- const updates = await this.parseUpdates(options, mapping, tbdRoot);
3078
- if (updates === null) return;
3079
- if (this.checkDryRun("Would update issue", {
3080
- id: internalId,
3081
- ...updates
3082
- })) return;
3083
- const oldSpecPath = issue.spec_path;
3084
- if (updates.title !== void 0) issue.title = updates.title;
3085
- if (updates.status !== void 0) issue.status = updates.status;
3086
- if (updates.kind !== void 0) issue.kind = updates.kind;
3087
- if (updates.priority !== void 0) issue.priority = updates.priority;
3088
- if (updates.assignee !== void 0) issue.assignee = updates.assignee;
3089
- if (updates.description !== void 0) issue.description = updates.description;
3090
- if (updates.notes !== void 0) issue.notes = updates.notes;
3091
- if (updates.due_date !== void 0) issue.due_date = updates.due_date;
3092
- if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
3093
- if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
3094
- if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
3095
- if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
3096
- if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
3097
- const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3098
- if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
3099
- } catch {}
3100
- if (updates.labels !== void 0) issue.labels = updates.labels;
3101
- if (updates.addLabels && updates.addLabels.length > 0) {
3102
- const labelsSet = new Set(issue.labels);
3103
- for (const label of updates.addLabels) labelsSet.add(label);
3104
- issue.labels = [...labelsSet];
3105
- }
3106
- if (updates.removeLabels && updates.removeLabels.length > 0) {
3107
- const removeSet = new Set(updates.removeLabels);
3108
- issue.labels = issue.labels.filter((l) => !removeSet.has(l));
3109
- }
3110
- issue.version += 1;
3111
- issue.updated_at = now();
3316
+ let displayId = id;
3317
+ let didUpdate = false;
3112
3318
  await this.execute(async () => {
3113
- await writeIssue(dataSyncDir, issue);
3319
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3320
+ let internalId;
3321
+ try {
3322
+ internalId = resolveToInternalId(id, mapping);
3323
+ } catch {
3324
+ throw new NotFoundError("Issue", id);
3325
+ }
3326
+ let issue;
3327
+ try {
3328
+ issue = await readIssue(dataSyncDir, internalId);
3329
+ } catch {
3330
+ throw new NotFoundError("Issue", id);
3331
+ }
3332
+ const updates = await this.parseUpdates(options, mapping, tbdRoot);
3333
+ if (updates === null) return;
3334
+ if (this.checkDryRun("Would update issue", {
3335
+ id: internalId,
3336
+ ...updates
3337
+ })) return;
3338
+ const oldSpecPath = issue.spec_path;
3339
+ if (updates.title !== void 0) issue.title = updates.title;
3340
+ if (updates.status !== void 0) issue.status = updates.status;
3341
+ if (updates.kind !== void 0) issue.kind = updates.kind;
3342
+ if (updates.priority !== void 0) issue.priority = updates.priority;
3343
+ if (updates.assignee !== void 0) issue.assignee = updates.assignee;
3344
+ if (updates.description !== void 0) issue.description = updates.description;
3345
+ if (updates.notes !== void 0) issue.notes = updates.notes;
3346
+ if (updates.due_date !== void 0) issue.due_date = updates.due_date;
3347
+ if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
3348
+ if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
3349
+ if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
3350
+ if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
3351
+ if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
3352
+ const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3353
+ if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
3354
+ } catch {}
3355
+ if (updates.labels !== void 0) issue.labels = updates.labels;
3356
+ if (updates.addLabels && updates.addLabels.length > 0) {
3357
+ const labelsSet = new Set(issue.labels);
3358
+ for (const label of updates.addLabels) labelsSet.add(label);
3359
+ issue.labels = [...labelsSet];
3360
+ }
3361
+ if (updates.removeLabels && updates.removeLabels.length > 0) {
3362
+ const removeSet = new Set(updates.removeLabels);
3363
+ issue.labels = issue.labels.filter((l) => !removeSet.has(l));
3364
+ }
3365
+ issue.version += 1;
3366
+ issue.updated_at = now();
3367
+ await writeIssue(dataSyncDir, issue);
3368
+ if (updates.parent_id) try {
3369
+ const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3370
+ const hints = parentIssue.child_order_hints ?? [];
3371
+ if (!hints.includes(internalId)) {
3372
+ parentIssue.child_order_hints = [...hints, internalId];
3373
+ parentIssue.version += 1;
3374
+ parentIssue.updated_at = now();
3375
+ await writeIssue(dataSyncDir, parentIssue);
3376
+ }
3377
+ } catch {}
3378
+ if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
3379
+ const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
3380
+ const timestamp = now();
3381
+ for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
3382
+ child.spec_path = issue.spec_path;
3383
+ child.version += 1;
3384
+ child.updated_at = timestamp;
3385
+ await writeIssue(dataSyncDir, child);
3386
+ }
3387
+ }
3388
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3389
+ didUpdate = true;
3390
+ });
3114
3391
  }, "Failed to update issue");
3115
- if (updates.parent_id) try {
3116
- const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3117
- const hints = parentIssue.child_order_hints ?? [];
3118
- if (!hints.includes(internalId)) {
3119
- parentIssue.child_order_hints = [...hints, internalId];
3120
- parentIssue.version += 1;
3121
- parentIssue.updated_at = now();
3122
- await writeIssue(dataSyncDir, parentIssue);
3123
- }
3124
- } catch {}
3125
- if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
3126
- const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
3127
- const timestamp = now();
3128
- for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
3129
- child.spec_path = issue.spec_path;
3130
- child.version += 1;
3131
- child.updated_at = timestamp;
3132
- await writeIssue(dataSyncDir, child);
3133
- }
3134
- }
3135
- const showDebug = this.ctx.debug;
3136
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3137
- const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
3392
+ if (!didUpdate) return;
3138
3393
  this.output.data({
3139
3394
  id: displayId,
3140
3395
  updated: true
@@ -3262,48 +3517,47 @@ const updateCommand = new Command("update").description("Update an issue").argum
3262
3517
  var CloseHandler = class extends BaseCommand {
3263
3518
  async run(id, options) {
3264
3519
  const tbdRoot = await requireInit();
3265
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3266
- const mapping = await loadIdMapping(dataSyncDir);
3267
- let internalId;
3268
- try {
3269
- internalId = resolveToInternalId(id, mapping);
3270
- } catch {
3271
- throw new NotFoundError("Issue", id);
3272
- }
3273
- let issue;
3274
- try {
3275
- issue = await readIssue(dataSyncDir, internalId);
3276
- } catch {
3277
- throw new NotFoundError("Issue", id);
3278
- }
3279
- const showDebug = this.ctx.debug;
3280
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3281
- const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
3282
- if (issue.status === "closed") {
3283
- this.output.data({
3284
- id: displayId,
3285
- closed: true,
3286
- alreadyClosed: true
3287
- }, () => {
3288
- this.output.success(`Closed ${displayId}`);
3289
- });
3290
- return;
3291
- }
3292
- if (this.checkDryRun("Would close issue", {
3293
- id: internalId,
3294
- reason: options.reason
3295
- })) return;
3296
- issue.status = "closed";
3297
- issue.closed_at = now();
3298
- issue.close_reason = options.reason ?? null;
3299
- issue.version += 1;
3300
- issue.updated_at = now();
3520
+ let displayId = id;
3521
+ let alreadyClosed = false;
3522
+ let didClose = false;
3301
3523
  await this.execute(async () => {
3302
- await writeIssue(dataSyncDir, issue);
3524
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3525
+ let internalId;
3526
+ try {
3527
+ internalId = resolveToInternalId(id, mapping);
3528
+ } catch {
3529
+ throw new NotFoundError("Issue", id);
3530
+ }
3531
+ let issue;
3532
+ try {
3533
+ issue = await readIssue(dataSyncDir, internalId);
3534
+ } catch {
3535
+ throw new NotFoundError("Issue", id);
3536
+ }
3537
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3538
+ if (issue.status === "closed") {
3539
+ alreadyClosed = true;
3540
+ didClose = true;
3541
+ return;
3542
+ }
3543
+ if (this.checkDryRun("Would close issue", {
3544
+ id: internalId,
3545
+ reason: options.reason
3546
+ })) return;
3547
+ issue.status = "closed";
3548
+ issue.closed_at = now();
3549
+ issue.close_reason = options.reason ?? null;
3550
+ issue.version += 1;
3551
+ issue.updated_at = now();
3552
+ await writeIssue(dataSyncDir, issue);
3553
+ didClose = true;
3554
+ });
3303
3555
  }, "Failed to close issue");
3556
+ if (!didClose) return;
3304
3557
  this.output.data({
3305
3558
  id: displayId,
3306
- closed: true
3559
+ closed: true,
3560
+ alreadyClosed
3307
3561
  }, () => {
3308
3562
  this.output.success(`Closed ${displayId}`);
3309
3563
  });
@@ -3323,40 +3577,42 @@ const closeCommand = new Command("close").description("Close an issue").argument
3323
3577
  var ReopenHandler = class extends BaseCommand {
3324
3578
  async run(id, options) {
3325
3579
  const tbdRoot = await requireInit();
3326
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3327
- const mapping = await loadIdMapping(dataSyncDir);
3328
- let internalId;
3329
- try {
3330
- internalId = resolveToInternalId(id, mapping);
3331
- } catch {
3332
- throw new NotFoundError("Issue", id);
3333
- }
3334
- let issue;
3335
- try {
3336
- issue = await readIssue(dataSyncDir, internalId);
3337
- } catch {
3338
- throw new NotFoundError("Issue", id);
3339
- }
3340
- if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
3341
- if (this.checkDryRun("Would reopen issue", {
3342
- id: internalId,
3343
- reason: options.reason
3344
- })) return;
3345
- issue.status = "open";
3346
- issue.closed_at = null;
3347
- issue.close_reason = null;
3348
- issue.version += 1;
3349
- issue.updated_at = now();
3350
- if (options.reason) {
3351
- const reopenNote = `Reopened: ${options.reason}`;
3352
- issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
3353
- }
3580
+ let displayId = id;
3581
+ let didReopen = false;
3354
3582
  await this.execute(async () => {
3355
- await writeIssue(dataSyncDir, issue);
3583
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3584
+ let internalId;
3585
+ try {
3586
+ internalId = resolveToInternalId(id, mapping);
3587
+ } catch {
3588
+ throw new NotFoundError("Issue", id);
3589
+ }
3590
+ let issue;
3591
+ try {
3592
+ issue = await readIssue(dataSyncDir, internalId);
3593
+ } catch {
3594
+ throw new NotFoundError("Issue", id);
3595
+ }
3596
+ if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
3597
+ if (this.checkDryRun("Would reopen issue", {
3598
+ id: internalId,
3599
+ reason: options.reason
3600
+ })) return;
3601
+ issue.status = "open";
3602
+ issue.closed_at = null;
3603
+ issue.close_reason = null;
3604
+ issue.version += 1;
3605
+ issue.updated_at = now();
3606
+ if (options.reason) {
3607
+ const reopenNote = `Reopened: ${options.reason}`;
3608
+ issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
3609
+ }
3610
+ await writeIssue(dataSyncDir, issue);
3611
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3612
+ didReopen = true;
3613
+ });
3356
3614
  }, "Failed to reopen issue");
3357
- const showDebug = this.ctx.debug;
3358
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3359
- const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
3615
+ if (!didReopen) return;
3360
3616
  this.output.data({
3361
3617
  id: displayId,
3362
3618
  reopened: true
@@ -3604,43 +3860,45 @@ const staleCommand = new Command("stale").description("List issues not updated r
3604
3860
  var LabelAddHandler = class extends BaseCommand {
3605
3861
  async run(id, labels) {
3606
3862
  const tbdRoot = await requireInit();
3607
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3608
- const mapping = await loadIdMapping(dataSyncDir);
3609
- let internalId;
3610
- try {
3611
- internalId = resolveToInternalId(id, mapping);
3612
- } catch {
3613
- throw new NotFoundError("Issue", id);
3614
- }
3615
- let issue;
3616
- try {
3617
- issue = await readIssue(dataSyncDir, internalId);
3618
- } catch {
3619
- throw new NotFoundError("Issue", id);
3620
- }
3621
- if (this.checkDryRun("Would add labels", {
3622
- id: internalId,
3623
- labels
3624
- })) return;
3625
- const labelsSet = new Set(issue.labels);
3626
- let added = 0;
3627
- for (const label of labels) if (!labelsSet.has(label)) {
3628
- labelsSet.add(label);
3629
- added++;
3630
- }
3631
- if (added === 0) {
3632
- this.output.info("All labels already present");
3633
- return;
3634
- }
3635
- issue.labels = [...labelsSet];
3636
- issue.version += 1;
3637
- issue.updated_at = now();
3863
+ let displayId = id;
3864
+ let didAdd = false;
3638
3865
  await this.execute(async () => {
3639
- await writeIssue(dataSyncDir, issue);
3866
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3867
+ let internalId;
3868
+ try {
3869
+ internalId = resolveToInternalId(id, mapping);
3870
+ } catch {
3871
+ throw new NotFoundError("Issue", id);
3872
+ }
3873
+ let issue;
3874
+ try {
3875
+ issue = await readIssue(dataSyncDir, internalId);
3876
+ } catch {
3877
+ throw new NotFoundError("Issue", id);
3878
+ }
3879
+ if (this.checkDryRun("Would add labels", {
3880
+ id: internalId,
3881
+ labels
3882
+ })) return;
3883
+ const labelsSet = new Set(issue.labels);
3884
+ let added = 0;
3885
+ for (const label of labels) if (!labelsSet.has(label)) {
3886
+ labelsSet.add(label);
3887
+ added++;
3888
+ }
3889
+ if (added === 0) {
3890
+ this.output.info("All labels already present");
3891
+ return;
3892
+ }
3893
+ issue.labels = [...labelsSet];
3894
+ issue.version += 1;
3895
+ issue.updated_at = now();
3896
+ await writeIssue(dataSyncDir, issue);
3897
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3898
+ didAdd = true;
3899
+ });
3640
3900
  }, "Failed to update issue");
3641
- const showDebug = this.ctx.debug;
3642
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3643
- const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
3901
+ if (!didAdd) return;
3644
3902
  this.output.data({
3645
3903
  id: displayId,
3646
3904
  addedLabels: labels
@@ -3652,39 +3910,41 @@ var LabelAddHandler = class extends BaseCommand {
3652
3910
  var LabelRemoveHandler = class extends BaseCommand {
3653
3911
  async run(id, labels) {
3654
3912
  const tbdRoot = await requireInit();
3655
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3656
- const mapping = await loadIdMapping(dataSyncDir);
3657
- let internalId;
3658
- try {
3659
- internalId = resolveToInternalId(id, mapping);
3660
- } catch {
3661
- throw new NotFoundError("Issue", id);
3662
- }
3663
- let issue;
3664
- try {
3665
- issue = await readIssue(dataSyncDir, internalId);
3666
- } catch {
3667
- throw new NotFoundError("Issue", id);
3668
- }
3669
- if (this.checkDryRun("Would remove labels", {
3670
- id: internalId,
3671
- labels
3672
- })) return;
3673
- const removeSet = new Set(labels);
3674
- const originalCount = issue.labels.length;
3675
- issue.labels = issue.labels.filter((l) => !removeSet.has(l));
3676
- if (originalCount - issue.labels.length === 0) {
3677
- this.output.info("No matching labels found");
3678
- return;
3679
- }
3680
- issue.version += 1;
3681
- issue.updated_at = now();
3913
+ let displayId = id;
3914
+ let didRemove = false;
3682
3915
  await this.execute(async () => {
3683
- await writeIssue(dataSyncDir, issue);
3916
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3917
+ let internalId;
3918
+ try {
3919
+ internalId = resolveToInternalId(id, mapping);
3920
+ } catch {
3921
+ throw new NotFoundError("Issue", id);
3922
+ }
3923
+ let issue;
3924
+ try {
3925
+ issue = await readIssue(dataSyncDir, internalId);
3926
+ } catch {
3927
+ throw new NotFoundError("Issue", id);
3928
+ }
3929
+ if (this.checkDryRun("Would remove labels", {
3930
+ id: internalId,
3931
+ labels
3932
+ })) return;
3933
+ const removeSet = new Set(labels);
3934
+ const originalCount = issue.labels.length;
3935
+ issue.labels = issue.labels.filter((l) => !removeSet.has(l));
3936
+ if (originalCount - issue.labels.length === 0) {
3937
+ this.output.info("No matching labels found");
3938
+ return;
3939
+ }
3940
+ issue.version += 1;
3941
+ issue.updated_at = now();
3942
+ await writeIssue(dataSyncDir, issue);
3943
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3944
+ didRemove = true;
3945
+ });
3684
3946
  }, "Failed to update issue");
3685
- const showDebug = this.ctx.debug;
3686
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3687
- const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
3947
+ if (!didRemove) return;
3688
3948
  this.output.data({
3689
3949
  id: displayId,
3690
3950
  removedLabels: labels
@@ -3695,7 +3955,7 @@ var LabelRemoveHandler = class extends BaseCommand {
3695
3955
  };
3696
3956
  var LabelListHandler = class extends BaseCommand {
3697
3957
  async run() {
3698
- const dataSyncDir = await resolveDataSyncDir(await requireInit());
3958
+ const { dataSyncDir } = await loadDataContext(await requireInit());
3699
3959
  let issues;
3700
3960
  try {
3701
3961
  issues = await listIssues(dataSyncDir);
@@ -3743,53 +4003,56 @@ const labelCommand = new Command("label").description("Manage issue labels").add
3743
4003
  var DependsAddHandler = class extends BaseCommand {
3744
4004
  async run(issueId, dependsOnId) {
3745
4005
  const tbdRoot = await requireInit();
3746
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3747
- const mapping = await loadIdMapping(dataSyncDir);
3748
- let internalIssueId;
3749
- let internalDependsOnId;
3750
- try {
3751
- internalIssueId = resolveToInternalId(issueId, mapping);
3752
- } catch {
3753
- throw new NotFoundError("Issue", issueId);
3754
- }
3755
- try {
3756
- internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
3757
- } catch {
3758
- throw new NotFoundError("Issue", dependsOnId);
3759
- }
3760
- try {
3761
- await readIssue(dataSyncDir, internalIssueId);
3762
- } catch {
3763
- throw new NotFoundError("Issue", issueId);
3764
- }
3765
- let blockerIssue;
3766
- try {
3767
- blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
3768
- } catch {
3769
- throw new NotFoundError("Issue", dependsOnId);
3770
- }
3771
- if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
3772
- if (this.checkDryRun("Would add dependency", {
3773
- issue: internalIssueId,
3774
- dependsOn: internalDependsOnId
3775
- })) return;
3776
- if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
3777
- this.output.info("Dependency already exists");
3778
- return;
3779
- }
3780
- blockerIssue.dependencies.push({
3781
- type: "blocks",
3782
- target: internalIssueId
3783
- });
3784
- blockerIssue.version += 1;
3785
- blockerIssue.updated_at = now();
4006
+ let displayIssueId = issueId;
4007
+ let displayDependsOnId = dependsOnId;
4008
+ let didAdd = false;
3786
4009
  await this.execute(async () => {
3787
- await writeIssue(dataSyncDir, blockerIssue);
4010
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4011
+ let internalIssueId;
4012
+ let internalDependsOnId;
4013
+ try {
4014
+ internalIssueId = resolveToInternalId(issueId, mapping);
4015
+ } catch {
4016
+ throw new NotFoundError("Issue", issueId);
4017
+ }
4018
+ try {
4019
+ internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
4020
+ } catch {
4021
+ throw new NotFoundError("Issue", dependsOnId);
4022
+ }
4023
+ try {
4024
+ await readIssue(dataSyncDir, internalIssueId);
4025
+ } catch {
4026
+ throw new NotFoundError("Issue", issueId);
4027
+ }
4028
+ let blockerIssue;
4029
+ try {
4030
+ blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
4031
+ } catch {
4032
+ throw new NotFoundError("Issue", dependsOnId);
4033
+ }
4034
+ if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
4035
+ if (this.checkDryRun("Would add dependency", {
4036
+ issue: internalIssueId,
4037
+ dependsOn: internalDependsOnId
4038
+ })) return;
4039
+ if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
4040
+ this.output.info("Dependency already exists");
4041
+ return;
4042
+ }
4043
+ blockerIssue.dependencies.push({
4044
+ type: "blocks",
4045
+ target: internalIssueId
4046
+ });
4047
+ blockerIssue.version += 1;
4048
+ blockerIssue.updated_at = now();
4049
+ await writeIssue(dataSyncDir, blockerIssue);
4050
+ displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
4051
+ displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
4052
+ didAdd = true;
4053
+ });
3788
4054
  }, "Failed to update issue");
3789
- const showDebug = this.ctx.debug;
3790
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3791
- const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
3792
- const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
4055
+ if (!didAdd) return;
3793
4056
  this.output.data({
3794
4057
  issue: displayIssueId,
3795
4058
  dependsOn: displayDependsOnId
@@ -3801,45 +4064,48 @@ var DependsAddHandler = class extends BaseCommand {
3801
4064
  var DependsRemoveHandler = class extends BaseCommand {
3802
4065
  async run(issueId, dependsOnId) {
3803
4066
  const tbdRoot = await requireInit();
3804
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3805
- const mapping = await loadIdMapping(dataSyncDir);
3806
- let internalIssueId;
3807
- let internalDependsOnId;
3808
- try {
3809
- internalIssueId = resolveToInternalId(issueId, mapping);
3810
- } catch {
3811
- throw new NotFoundError("Issue", issueId);
3812
- }
3813
- try {
3814
- internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
3815
- } catch {
3816
- throw new NotFoundError("Issue", dependsOnId);
3817
- }
3818
- let blockerIssue;
3819
- try {
3820
- blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
3821
- } catch {
3822
- throw new NotFoundError("Issue", dependsOnId);
3823
- }
3824
- if (this.checkDryRun("Would remove dependency", {
3825
- issue: internalIssueId,
3826
- dependsOn: internalDependsOnId
3827
- })) return;
3828
- const initialLength = blockerIssue.dependencies.length;
3829
- blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
3830
- if (blockerIssue.dependencies.length === initialLength) {
3831
- this.output.info("Dependency not found");
3832
- return;
3833
- }
3834
- blockerIssue.version += 1;
3835
- blockerIssue.updated_at = now();
4067
+ let displayIssueId = issueId;
4068
+ let displayDependsOnId = dependsOnId;
4069
+ let didRemove = false;
3836
4070
  await this.execute(async () => {
3837
- await writeIssue(dataSyncDir, blockerIssue);
4071
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4072
+ let internalIssueId;
4073
+ let internalDependsOnId;
4074
+ try {
4075
+ internalIssueId = resolveToInternalId(issueId, mapping);
4076
+ } catch {
4077
+ throw new NotFoundError("Issue", issueId);
4078
+ }
4079
+ try {
4080
+ internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
4081
+ } catch {
4082
+ throw new NotFoundError("Issue", dependsOnId);
4083
+ }
4084
+ let blockerIssue;
4085
+ try {
4086
+ blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
4087
+ } catch {
4088
+ throw new NotFoundError("Issue", dependsOnId);
4089
+ }
4090
+ if (this.checkDryRun("Would remove dependency", {
4091
+ issue: internalIssueId,
4092
+ dependsOn: internalDependsOnId
4093
+ })) return;
4094
+ const initialLength = blockerIssue.dependencies.length;
4095
+ blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
4096
+ if (blockerIssue.dependencies.length === initialLength) {
4097
+ this.output.info("Dependency not found");
4098
+ return;
4099
+ }
4100
+ blockerIssue.version += 1;
4101
+ blockerIssue.updated_at = now();
4102
+ await writeIssue(dataSyncDir, blockerIssue);
4103
+ displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
4104
+ displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
4105
+ didRemove = true;
4106
+ });
3838
4107
  }, "Failed to update issue");
3839
- const showDebug = this.ctx.debug;
3840
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
3841
- const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
3842
- const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
4108
+ if (!didRemove) return;
3843
4109
  this.output.data({
3844
4110
  issue: displayIssueId,
3845
4111
  removed: displayDependsOnId
@@ -3850,9 +4116,7 @@ var DependsRemoveHandler = class extends BaseCommand {
3850
4116
  };
3851
4117
  var DependsListHandler = class extends BaseCommand {
3852
4118
  async run(id) {
3853
- const tbdRoot = await requireInit();
3854
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3855
- const mapping = await loadIdMapping(dataSyncDir);
4119
+ const { dataSyncDir, mapping, config } = await loadDataContext(await requireInit());
3856
4120
  let internalId;
3857
4121
  try {
3858
4122
  internalId = resolveToInternalId(id, mapping);
@@ -3872,7 +4136,7 @@ var DependsListHandler = class extends BaseCommand {
3872
4136
  allIssues = [];
3873
4137
  }
3874
4138
  const showDebug = this.ctx.debug;
3875
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
4139
+ const prefix = config.display.id_prefix;
3876
4140
  const deps = getDependencyDirections(issue, allIssues, (dependencyId) => showDebug ? formatDebugId(dependencyId, mapping, prefix) : formatDisplayId(dependencyId, mapping, prefix));
3877
4141
  this.output.data(deps, () => {
3878
4142
  const colors = this.output.getColors();
@@ -4896,6 +5160,8 @@ async function workspaceExists(tbdRoot, name) {
4896
5160
  var SyncHandler = class extends BaseCommand {
4897
5161
  dataSyncDir = "";
4898
5162
  tbdRoot = "";
5163
+ worktreePath = "";
5164
+ syncBranch = "";
4899
5165
  async run(options) {
4900
5166
  const tbdRoot = await requireInit();
4901
5167
  this.tbdRoot = tbdRoot;
@@ -4908,42 +5174,28 @@ var SyncHandler = class extends BaseCommand {
4908
5174
  await this.syncDocs(options.status);
4909
5175
  if (!syncIssues) return;
4910
5176
  }
4911
- let worktreeHealth = await checkWorktreeHealth(tbdRoot);
4912
- if (!worktreeHealth.valid) if (worktreeHealth.status === "missing") {
4913
- await this.doRepairWorktree(tbdRoot, "missing");
4914
- worktreeHealth = await checkWorktreeHealth(tbdRoot);
4915
- if (!worktreeHealth.valid) throw new WorktreeCorruptedError(`Failed to create worktree. Status: ${worktreeHealth.status}. Run 'tbd doctor' for diagnostics.`);
4916
- } else if (options.fix) {
4917
- await this.doRepairWorktree(tbdRoot, worktreeHealth.status);
4918
- worktreeHealth = await checkWorktreeHealth(tbdRoot);
4919
- if (!worktreeHealth.valid) throw new WorktreeCorruptedError(`Worktree repair failed. Status: ${worktreeHealth.status}. Run 'tbd doctor' for diagnostics.`);
4920
- } else {
4921
- if (worktreeHealth.status === "prunable") throw new WorktreeMissingError("Worktree directory was deleted but git still tracks it. Run 'tbd sync --fix' or 'tbd doctor --fix' to repair.");
4922
- if (worktreeHealth.status === "corrupted") throw new WorktreeCorruptedError(`Worktree is corrupted: ${worktreeHealth.error ?? "unknown error"}. Run 'tbd sync --fix' or 'tbd doctor --fix' to repair.`);
4923
- }
4924
- this.dataSyncDir = await resolveDataSyncDir(tbdRoot);
4925
- let config;
4926
- try {
4927
- config = await readConfig(tbdRoot);
4928
- } catch {
4929
- throw new NotInitializedError("Not a tbd repository. Run `tbd init` first.");
4930
- }
4931
- const syncBranch = config.sync.branch;
4932
- const remote = config.sync.remote;
4933
- if (options.status) {
4934
- await this.showIssueStatus(syncBranch, remote);
4935
- return;
4936
- }
4937
- if (this.checkDryRun("Would sync repository", {
4938
- syncBranch,
4939
- remote
4940
- })) return;
4941
- if (options.pull) await this.pullChanges(syncBranch, remote);
4942
- else if (options.push) await this.pushChanges(syncBranch, remote);
4943
- else await this.fullSync(syncBranch, remote, {
4944
- force: options.force,
4945
- autoSave: options.autoSave,
4946
- outbox: options.outbox
5177
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
5178
+ this.dataSyncDir = dataSyncDir;
5179
+ this.worktreePath = sharedPaths.sharedWorktreePath;
5180
+ this.syncBranch = config.sync.branch;
5181
+ const syncBranch = config.sync.branch;
5182
+ const remote = config.sync.remote;
5183
+ if (options.status) {
5184
+ await this.showIssueStatus(syncBranch, remote);
5185
+ return;
5186
+ }
5187
+ if (this.checkDryRun("Would sync repository", {
5188
+ syncBranch,
5189
+ remote
5190
+ })) return;
5191
+ if (repairedWorktreeStatus) this.output.success("Worktree repaired successfully");
5192
+ if (options.pull) await this.pullChanges(syncBranch, remote);
5193
+ else if (options.push) await this.pushChanges(syncBranch, remote);
5194
+ else await this.fullSync(syncBranch, remote, {
5195
+ force: options.force,
5196
+ autoSave: options.autoSave,
5197
+ outbox: options.outbox
5198
+ });
4947
5199
  });
4948
5200
  }
4949
5201
  /**
@@ -4993,24 +5245,6 @@ var SyncHandler = class extends BaseCommand {
4993
5245
  if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
4994
5246
  for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
4995
5247
  }
4996
- /**
4997
- * Attempt to repair an unhealthy worktree.
4998
- * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
4999
- */
5000
- async doRepairWorktree(tbdRoot, status) {
5001
- const spinner = this.output.spinner(`Repairing worktree (${status})...`);
5002
- try {
5003
- const result = await repairWorktree(tbdRoot, status);
5004
- spinner.stop();
5005
- if (!result.success) throw new WorktreeCorruptedError(`Failed to repair worktree: ${result.error}`);
5006
- if (result.backedUp) this.output.info(`Corrupted worktree backed up to: ${result.backedUp}`);
5007
- this.output.success("Worktree repaired successfully");
5008
- } catch (error) {
5009
- spinner.stop();
5010
- if (error instanceof WorktreeCorruptedError) throw error;
5011
- throw new WorktreeCorruptedError(`Failed to repair worktree: ${error.message}`);
5012
- }
5013
- }
5014
5248
  async showIssueStatus(syncBranch, remote) {
5015
5249
  const status = await this.getSyncStatus(syncBranch, remote);
5016
5250
  this.output.data(status, () => {
@@ -5041,7 +5275,7 @@ var SyncHandler = class extends BaseCommand {
5041
5275
  let ahead = 0;
5042
5276
  let behind = 0;
5043
5277
  try {
5044
- const status = await git("-C", join(this.tbdRoot, WORKTREE_DIR), "status", "--porcelain");
5278
+ const status = await git("-C", this.worktreePath, "status", "--porcelain");
5045
5279
  if (status) for (const line of status.split("\n")) {
5046
5280
  if (!line) continue;
5047
5281
  const statusCode = line.slice(0, 2).trim();
@@ -5100,11 +5334,8 @@ var SyncHandler = class extends BaseCommand {
5100
5334
  this.output.success("Already up to date");
5101
5335
  return;
5102
5336
  }
5103
- await withIsolatedIndex(async () => {
5104
- await git("read-tree", `${remote}/${syncBranch}`);
5105
- const remoteCommit = await git("rev-parse", `${remote}/${syncBranch}`);
5106
- await git("update-ref", `refs/heads/${syncBranch}`, remoteCommit);
5107
- });
5337
+ await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
5338
+ await git("-C", this.worktreePath, "merge", "--ff-only", `${remote}/${syncBranch}`);
5108
5339
  this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
5109
5340
  } catch (error) {
5110
5341
  spinner.stop();
@@ -5120,15 +5351,15 @@ var SyncHandler = class extends BaseCommand {
5120
5351
  * @returns Tallies of new/updated/deleted files committed
5121
5352
  */
5122
5353
  async commitWorktreeChanges() {
5123
- const worktreePath = join(this.tbdRoot, WORKTREE_DIR);
5354
+ const worktreePath = this.worktreePath;
5124
5355
  try {
5125
- await ensureWorktreeAttached(worktreePath);
5356
+ await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);
5126
5357
  const status = await git("-C", worktreePath, "status", "--porcelain");
5127
5358
  if (!status || status.trim() === "") return emptyTallies();
5128
5359
  const tallies = parseGitStatus(status);
5129
5360
  const fileCount = tallies.new + tallies.updated + tallies.deleted;
5130
5361
  await git("-C", worktreePath, "add", "-A");
5131
- await git("-C", worktreePath, "commit", "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
5362
+ await gitCommit(worktreePath, "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
5132
5363
  return tallies;
5133
5364
  } catch (error) {
5134
5365
  if (error.message.includes("nothing to commit")) return emptyTallies();
@@ -5210,7 +5441,7 @@ var SyncHandler = class extends BaseCommand {
5210
5441
  const spinner = this.output.spinner("Syncing with remote...");
5211
5442
  const summary = emptySummary();
5212
5443
  const conflicts = [];
5213
- const worktreePath = join(this.tbdRoot, WORKTREE_DIR);
5444
+ const worktreePath = this.worktreePath;
5214
5445
  try {
5215
5446
  const committedTallies = await this.commitWorktreeChanges();
5216
5447
  summary.sent.new += committedTallies.new;
@@ -5238,7 +5469,6 @@ var SyncHandler = class extends BaseCommand {
5238
5469
  this.output.debug("Remote sync branch does not exist yet");
5239
5470
  }
5240
5471
  {
5241
- const { access, writeFile } = await import("node:fs/promises");
5242
5472
  const attrPath = join(this.dataSyncDir, "mappings", ".gitattributes");
5243
5473
  try {
5244
5474
  await access(attrPath);
@@ -5246,7 +5476,7 @@ var SyncHandler = class extends BaseCommand {
5246
5476
  await writeFile(attrPath, "ids.yml merge=union\n");
5247
5477
  await git("-C", worktreePath, "add", attrPath);
5248
5478
  try {
5249
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "chore: add merge=union for ids.yml");
5479
+ await gitCommit(worktreePath, "--no-verify", "-m", "chore: add merge=union for ids.yml");
5250
5480
  } catch {}
5251
5481
  }
5252
5482
  }
@@ -5272,7 +5502,7 @@ var SyncHandler = class extends BaseCommand {
5272
5502
  await saveIdMapping(this.dataSyncDir, postMergeMapping);
5273
5503
  await git("-C", worktreePath, "add", "-A");
5274
5504
  try {
5275
- await git("-C", worktreePath, "commit", "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
5505
+ await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
5276
5506
  } catch {}
5277
5507
  if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
5278
5508
  if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
@@ -5294,7 +5524,6 @@ var SyncHandler = class extends BaseCommand {
5294
5524
  const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
5295
5525
  if (remoteIdsContent) {
5296
5526
  conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
5297
- const { readFile } = await import("node:fs/promises");
5298
5527
  const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
5299
5528
  const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
5300
5529
  await saveIdMapping(this.dataSyncDir, mergedMapping);
@@ -5320,7 +5549,7 @@ var SyncHandler = class extends BaseCommand {
5320
5549
  throw new SyncError(`Cannot commit: ${conflictedFiles.length} file(s) still have merge conflict markers:\n` + conflictedFiles.map((f) => ` - ${f}`).join("\n") + `\n\nThis is a bug in tbd sync. Please report it and manually resolve conflicts in:\n ${worktreePath}`);
5321
5550
  }
5322
5551
  try {
5323
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "tbd sync: resolved merge conflicts");
5552
+ await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
5324
5553
  } catch {
5325
5554
  this.output.debug("No merge commit needed (conflicts already resolved)");
5326
5555
  }
@@ -5813,16 +6042,23 @@ function renderBeadsWarning(colors) {
5813
6042
  * Used by: status
5814
6043
  *
5815
6044
  * @param path - Worktree path
5816
- * @param healthy - Whether worktree is healthy
6045
+ * @param status - 'valid' (healthy), 'missing' (created on next sync), or 'prunable' /
6046
+ * 'corrupted' (truly unhealthy and needs repair)
5817
6047
  * @param colors - Color functions
5818
6048
  */
5819
- function renderWorktreeStatus(path, healthy, colors) {
6049
+ function renderWorktreeStatus(path, status, colors) {
5820
6050
  console.log("");
5821
- if (healthy) console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
5822
- else {
5823
- console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
5824
- console.log(" Run: tbd doctor --fix");
6051
+ if (status === "valid") {
6052
+ console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
6053
+ return;
6054
+ }
6055
+ if (status === "missing") {
6056
+ console.log(`${colors.dim("Worktree:")} ${path} (not initialized)`);
6057
+ console.log(" Run: tbd sync (or tbd doctor --fix) to initialize");
6058
+ return;
5825
6059
  }
6060
+ console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
6061
+ console.log(" Run: tbd doctor --fix");
5826
6062
  }
5827
6063
  /**
5828
6064
  * Render footer with command suggestions.
@@ -6037,6 +6273,7 @@ var StatusHandler = class extends BaseCommand {
6037
6273
  display_prefix: null,
6038
6274
  worktree_path: null,
6039
6275
  worktree_healthy: null,
6276
+ worktree_status: null,
6040
6277
  workspaces: [],
6041
6278
  integrations: {
6042
6279
  portable_skill: false,
@@ -6150,10 +6387,11 @@ var StatusHandler = class extends BaseCommand {
6150
6387
  data.remote = config.sync.remote;
6151
6388
  data.display_prefix = config.display.id_prefix;
6152
6389
  } catch {}
6153
- const worktreePath = join(cwd, WORKTREE_DIR);
6154
- const worktreeHealth = await checkWorktreeHealth(cwd);
6155
- data.worktree_path = worktreePath;
6390
+ const sharedPaths = await resolveSharedTbdPaths(cwd);
6391
+ const worktreeHealth = await checkWorktreeHealth(cwd, data.sync_branch ?? void 0);
6392
+ data.worktree_path = sharedPaths.sharedWorktreePath;
6156
6393
  data.worktree_healthy = worktreeHealth.valid;
6394
+ data.worktree_status = worktreeHealth.status;
6157
6395
  try {
6158
6396
  data.workspaces = await listWorkspaces(cwd);
6159
6397
  } catch {}
@@ -6204,7 +6442,7 @@ var StatusHandler = class extends BaseCommand {
6204
6442
  console.log("");
6205
6443
  console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
6206
6444
  }
6207
- if (data.worktree_healthy !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_healthy, colors);
6445
+ if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
6208
6446
  if (data.workspaces.length > 0) {
6209
6447
  console.log("");
6210
6448
  console.log(colors.bold("WORKSPACES"));
@@ -6302,7 +6540,8 @@ var StatsHandler = class extends BaseCommand {
6302
6540
  const tbdRoot = await requireInit();
6303
6541
  let issues;
6304
6542
  try {
6305
- issues = await listIssues(await resolveDataSyncDir(tbdRoot));
6543
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
6544
+ issues = await listIssues(dataSyncDir);
6306
6545
  } catch {
6307
6546
  throw new NotInitializedError("No issue store found. Run `tbd init` first.");
6308
6547
  }
@@ -6495,7 +6734,7 @@ var DoctorHandler = class extends BaseCommand {
6495
6734
  async run(options) {
6496
6735
  const tbdRoot = await requireInit();
6497
6736
  this.cwd = tbdRoot;
6498
- this.dataSyncDir = await resolveDataSyncDir(tbdRoot);
6737
+ this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
6499
6738
  try {
6500
6739
  this.config = await readConfig(this.cwd);
6501
6740
  } catch {}
@@ -6519,10 +6758,11 @@ var DoctorHandler = class extends BaseCommand {
6519
6758
  healthChecks.push(await this.checkTempFiles(options.fix));
6520
6759
  healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
6521
6760
  healthChecks.push(await this.checkWorktree(options.fix));
6761
+ healthChecks.push(await this.checkCommonDirLayout(options.fix));
6522
6762
  const dataLocationResult = await this.checkDataLocation(options.fix);
6523
6763
  healthChecks.push(dataLocationResult);
6524
6764
  if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
6525
- this.dataSyncDir = await resolveDataSyncDir(this.cwd);
6765
+ this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
6526
6766
  try {
6527
6767
  this.invalidIssueFiles = [];
6528
6768
  this.issues = await listIssues(this.dataSyncDir, {
@@ -6546,6 +6786,7 @@ var DoctorHandler = class extends BaseCommand {
6546
6786
  integrationChecks.push(await this.checkCodexHooks());
6547
6787
  const allChecks = [...healthChecks, ...integrationChecks];
6548
6788
  const allOk = allChecks.every((c) => c.status === "ok");
6789
+ const hasErrors = allChecks.some((c) => c.status === "error");
6549
6790
  const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
6550
6791
  this.output.data({
6551
6792
  statusInfo,
@@ -6581,11 +6822,12 @@ var DoctorHandler = class extends BaseCommand {
6581
6822
  else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
6582
6823
  else this.output.warn("Issues found that may require manual intervention.");
6583
6824
  });
6825
+ if (hasErrors) process.exitCode = 1;
6584
6826
  }
6585
6827
  async gatherStatusInfo() {
6586
6828
  let gitBranch = null;
6587
6829
  try {
6588
- gitBranch = await getCurrentBranch();
6830
+ gitBranch = await getCurrentBranch(this.cwd);
6589
6831
  } catch {}
6590
6832
  const worktreeHealth = await checkWorktreeHealth(this.cwd);
6591
6833
  return {
@@ -6612,7 +6854,7 @@ var DoctorHandler = class extends BaseCommand {
6612
6854
  if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
6613
6855
  }
6614
6856
  let remoteTotal = null;
6615
- if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync");
6857
+ if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync", this.cwd);
6616
6858
  return {
6617
6859
  total: this.issues.length,
6618
6860
  ready: readyCount,
@@ -6670,6 +6912,13 @@ var DoctorHandler = class extends BaseCommand {
6670
6912
  path: configPath,
6671
6913
  suggestion: "Run: tbd init"
6672
6914
  };
6915
+ if (error instanceof IncompatibleFormatError) return {
6916
+ name: "Config file",
6917
+ status: "error",
6918
+ message: `requires newer tbd (found ${error.foundFormat}, supported ${error.supportedFormat})`,
6919
+ path: configPath,
6920
+ suggestion: "Upgrade: npm install -g get-tbd@latest"
6921
+ };
6673
6922
  return {
6674
6923
  name: "Config file",
6675
6924
  status: "error",
@@ -6761,7 +7010,7 @@ var DoctorHandler = class extends BaseCommand {
6761
7010
  status: "ok"
6762
7011
  };
6763
7012
  if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
6764
- const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7013
+ const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
6765
7014
  const resolved = resolveIdMappingConflicts(content);
6766
7015
  await saveIdMapping(this.dataSyncDir, resolved);
6767
7016
  return {
@@ -6810,7 +7059,7 @@ var DoctorHandler = class extends BaseCommand {
6810
7059
  status: "ok"
6811
7060
  };
6812
7061
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
6813
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7062
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
6814
7063
  const mapping = await loadIdMapping(this.dataSyncDir);
6815
7064
  await saveIdMapping(this.dataSyncDir, mapping);
6816
7065
  return {
@@ -6949,7 +7198,7 @@ var DoctorHandler = class extends BaseCommand {
6949
7198
  name: "ID mapping coverage",
6950
7199
  status: "ok"
6951
7200
  };
6952
- const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7201
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-CFoPVinz.mjs");
6953
7202
  const mapping = await loadIdMapping(this.dataSyncDir);
6954
7203
  const missingIds = [];
6955
7204
  for (const issue of this.issues) {
@@ -6961,10 +7210,10 @@ var DoctorHandler = class extends BaseCommand {
6961
7210
  status: "ok"
6962
7211
  };
6963
7212
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
6964
- const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7213
+ const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-CFoPVinz.mjs");
6965
7214
  let historicalMapping;
6966
7215
  try {
6967
- const syncBranch = (await import("./config-BPHcePSm.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
7216
+ const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
6968
7217
  const logArgs = ["log", "--format=%H"];
6969
7218
  if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
6970
7219
  logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
@@ -7095,24 +7344,51 @@ var DoctorHandler = class extends BaseCommand {
7095
7344
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
7096
7345
  */
7097
7346
  async checkWorktree(fix) {
7098
- const worktreePath = WORKTREE_DIR;
7099
- const worktreeHealth = await checkWorktreeHealth(this.cwd);
7347
+ const worktreePath = (await resolveSharedTbdPaths(this.cwd)).sharedWorktreePath;
7348
+ const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7349
+ const remote = this.config?.sync.remote ?? "origin";
7350
+ const worktreeHealth = await checkWorktreeHealth(this.cwd, syncBranch);
7100
7351
  switch (worktreeHealth.status) {
7101
7352
  case "valid": return {
7102
7353
  name: "Worktree",
7103
7354
  status: "ok",
7104
7355
  path: worktreePath
7105
7356
  };
7106
- case "missing": return {
7107
- name: "Worktree",
7108
- status: "ok",
7109
- message: "not created yet",
7110
- path: worktreePath
7111
- };
7357
+ case "missing":
7358
+ if (fix && !this.checkDryRun("Initialize shared data-sync worktree")) {
7359
+ try {
7360
+ await withSharedDataSyncLock(this.cwd, async () => {
7361
+ await prepareDataSyncContext(this.cwd);
7362
+ });
7363
+ } catch (error) {
7364
+ return {
7365
+ name: "Worktree",
7366
+ status: "error",
7367
+ message: `initialization failed: ${error instanceof Error ? error.message : String(error)}`,
7368
+ path: worktreePath
7369
+ };
7370
+ }
7371
+ try {
7372
+ this.config = await readConfig(this.cwd);
7373
+ } catch {}
7374
+ return {
7375
+ name: "Worktree",
7376
+ status: "ok",
7377
+ message: "initialized",
7378
+ path: worktreePath
7379
+ };
7380
+ }
7381
+ return {
7382
+ name: "Worktree",
7383
+ status: "ok",
7384
+ message: "not created yet",
7385
+ path: worktreePath
7386
+ };
7112
7387
  case "prunable":
7113
7388
  case "corrupted":
7114
7389
  if (fix && !this.checkDryRun("Repair worktree")) {
7115
- const result = await repairWorktree(this.cwd, worktreeHealth.status);
7390
+ const repairStatus = worktreeHealth.status;
7391
+ const result = await withSharedDataSyncLock(this.cwd, async () => repairWorktree(this.cwd, repairStatus, remote, syncBranch));
7116
7392
  if (result.success) return {
7117
7393
  name: "Worktree",
7118
7394
  status: "ok",
@@ -7155,10 +7431,80 @@ var DoctorHandler = class extends BaseCommand {
7155
7431
  }
7156
7432
  }
7157
7433
  /**
7434
+ * Check $GIT_COMMON_DIR/tbd/layout.yml against the checkout config.
7435
+ *
7436
+ * Reports missing (initialized on next mutating command), mismatched (rewrite
7437
+ * from config under --fix), or future-format (requires newer tbd, no fix).
7438
+ *
7439
+ * See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Format And Layout
7440
+ * Versioning.
7441
+ */
7442
+ async checkCommonDirLayout(fix) {
7443
+ if (!this.config) return {
7444
+ name: "Common-dir layout",
7445
+ status: "ok",
7446
+ message: "skipped (no config)"
7447
+ };
7448
+ const sharedPaths = await resolveSharedTbdPaths(this.cwd);
7449
+ const layoutPath = sharedPaths.sharedLayoutPath;
7450
+ let layout;
7451
+ try {
7452
+ layout = await readCommonDirLayout(layoutPath);
7453
+ } catch (error) {
7454
+ return {
7455
+ name: "Common-dir layout",
7456
+ status: "error",
7457
+ message: error instanceof Error ? error.message : String(error),
7458
+ path: layoutPath
7459
+ };
7460
+ }
7461
+ if (!layout) return {
7462
+ name: "Common-dir layout",
7463
+ status: "ok",
7464
+ message: "not initialized yet (created on first sync)",
7465
+ path: layoutPath
7466
+ };
7467
+ if (!isCompatibleFormat(layout.tbd_format)) return {
7468
+ name: "Common-dir layout",
7469
+ status: "error",
7470
+ message: `requires newer tbd (found ${layout.tbd_format})`,
7471
+ path: layoutPath,
7472
+ suggestion: "Upgrade: npm install -g get-tbd@latest"
7473
+ };
7474
+ try {
7475
+ validateCommonDirLayout(layout, this.config);
7476
+ return {
7477
+ name: "Common-dir layout",
7478
+ status: "ok",
7479
+ path: layoutPath
7480
+ };
7481
+ } catch (error) {
7482
+ if (!(error instanceof CommonDirLayoutError)) throw error;
7483
+ if (fix && !this.checkDryRun("Repair common-dir layout")) {
7484
+ const configRef = this.config;
7485
+ await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef, layout));
7486
+ return {
7487
+ name: "Common-dir layout",
7488
+ status: "ok",
7489
+ message: "rewritten from config",
7490
+ path: layoutPath
7491
+ };
7492
+ }
7493
+ return {
7494
+ name: "Common-dir layout",
7495
+ status: "error",
7496
+ message: "mismatched with config",
7497
+ path: layoutPath,
7498
+ fixable: true,
7499
+ suggestion: "Run: tbd doctor --fix"
7500
+ };
7501
+ }
7502
+ }
7503
+ /**
7158
7504
  * Check for issues in wrong location.
7159
7505
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
7160
7506
  *
7161
- * Issues should be in .tbd/data-sync-worktree/.tbd/data-sync/issues/
7507
+ * Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/issues/
7162
7508
  * If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
7163
7509
  * and data was written to the fallback path - this is a bug requiring migration.
7164
7510
  */
@@ -7176,7 +7522,7 @@ var DoctorHandler = class extends BaseCommand {
7176
7522
  if (fix && !this.checkDryRun("Migrate data to worktree")) {
7177
7523
  let worktreeHealth = await checkWorktreeHealth(this.cwd);
7178
7524
  if (worktreeHealth.status === "missing") {
7179
- const initResult = await initWorktree(this.cwd);
7525
+ const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
7180
7526
  if (!initResult.success) return {
7181
7527
  name: "Data location",
7182
7528
  status: "error",
@@ -7219,7 +7565,7 @@ var DoctorHandler = class extends BaseCommand {
7219
7565
  path: wrongIssuesPath,
7220
7566
  details: [
7221
7567
  `Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
7222
- "Issues should be in .tbd/data-sync-worktree/.tbd/data-sync/",
7568
+ "Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/",
7223
7569
  "This indicates the worktree was missing when issues were created"
7224
7570
  ],
7225
7571
  fixable: true,
@@ -7232,14 +7578,14 @@ var DoctorHandler = class extends BaseCommand {
7232
7578
  */
7233
7579
  async checkLocalSyncBranch() {
7234
7580
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7235
- const localHealth = await checkLocalBranchHealth(syncBranch);
7581
+ const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
7236
7582
  if (localHealth.exists && !localHealth.orphaned) return {
7237
7583
  name: "Local sync branch",
7238
7584
  status: "ok",
7239
7585
  message: syncBranch
7240
7586
  };
7241
7587
  if (!localHealth.exists) {
7242
- if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
7588
+ if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
7243
7589
  name: "Local sync branch",
7244
7590
  status: "warn",
7245
7591
  message: `${syncBranch} not found (remote exists)`,
@@ -7265,7 +7611,7 @@ var DoctorHandler = class extends BaseCommand {
7265
7611
  async checkRemoteSyncBranch() {
7266
7612
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7267
7613
  const remote = this.config?.sync.remote ?? "origin";
7268
- const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
7614
+ const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
7269
7615
  if (remoteHealth.exists) {
7270
7616
  if (remoteHealth.diverged) return {
7271
7617
  name: "Remote sync branch",
@@ -7279,7 +7625,7 @@ var DoctorHandler = class extends BaseCommand {
7279
7625
  message: `${remote}/${syncBranch}`
7280
7626
  };
7281
7627
  }
7282
- if ((await checkLocalBranchHealth(syncBranch)).exists) return {
7628
+ if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
7283
7629
  name: "Remote sync branch",
7284
7630
  status: "warn",
7285
7631
  message: `${remote}/${syncBranch} not found`,
@@ -7308,7 +7654,7 @@ var DoctorHandler = class extends BaseCommand {
7308
7654
  status: "ok"
7309
7655
  };
7310
7656
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7311
- if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
7657
+ if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
7312
7658
  name: "Sync status",
7313
7659
  status: "warn",
7314
7660
  message: `${localIssueCount} local issues, remote branch not found`,
@@ -7454,6 +7800,7 @@ var ConfigShowHandler = class extends BaseCommand {
7454
7800
  console.log(`${colors.dim("sync:")}`);
7455
7801
  console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
7456
7802
  console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
7803
+ console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
7457
7804
  console.log(`${colors.dim("display:")}`);
7458
7805
  console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
7459
7806
  console.log(`${colors.dim("settings:")}`);
@@ -7589,9 +7936,9 @@ async function listAtticEntries(tbdRoot, filterById) {
7589
7936
  var AtticListHandler = class extends BaseCommand {
7590
7937
  async run(id) {
7591
7938
  const tbdRoot = await requireInit();
7939
+ const { mapping, config } = await loadDataContext(tbdRoot);
7592
7940
  const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
7593
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7594
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7941
+ const prefix = config.display.id_prefix;
7595
7942
  const showDebug = this.ctx.debug;
7596
7943
  const output = entries.map((e) => ({
7597
7944
  id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
@@ -7616,10 +7963,10 @@ var AtticListHandler = class extends BaseCommand {
7616
7963
  var AtticShowHandler = class extends BaseCommand {
7617
7964
  async run(id, timestamp) {
7618
7965
  const tbdRoot = await requireInit();
7966
+ const { mapping, config } = await loadDataContext(tbdRoot);
7619
7967
  const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7620
7968
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
7621
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7622
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7969
+ const prefix = config.display.id_prefix;
7623
7970
  const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
7624
7971
  this.output.data(entry, () => {
7625
7972
  const colors = this.output.getColors();
@@ -7645,6 +7992,7 @@ var AtticShowHandler = class extends BaseCommand {
7645
7992
  var AtticRestoreHandler = class extends BaseCommand {
7646
7993
  async run(id, timestamp) {
7647
7994
  const tbdRoot = await requireInit();
7995
+ await loadDataContext(tbdRoot);
7648
7996
  const normalizedId = normalizeIssueId(id);
7649
7997
  const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7650
7998
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
@@ -7652,24 +8000,24 @@ var AtticRestoreHandler = class extends BaseCommand {
7652
8000
  id: normalizedId,
7653
8001
  field: entry.field
7654
8002
  })) return;
7655
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
7656
- let issue;
7657
- try {
7658
- issue = await readIssue(dataSyncDir, normalizedId);
7659
- } catch {
7660
- throw new NotFoundError("Issue", id);
7661
- }
7662
- const field = entry.field;
7663
- if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
7664
- else throw new ValidationError(`Cannot restore field: ${entry.field}`);
7665
- issue.version += 1;
7666
- issue.updated_at = now();
8003
+ let displayId = id;
7667
8004
  await this.execute(async () => {
7668
- await writeIssue(dataSyncDir, issue);
8005
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
8006
+ let issue;
8007
+ try {
8008
+ issue = await readIssue(dataSyncDir, normalizedId);
8009
+ } catch {
8010
+ throw new NotFoundError("Issue", id);
8011
+ }
8012
+ const field = entry.field;
8013
+ if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
8014
+ else throw new ValidationError(`Cannot restore field: ${entry.field}`);
8015
+ issue.version += 1;
8016
+ issue.updated_at = now();
8017
+ await writeIssue(dataSyncDir, issue);
8018
+ displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, config.display.id_prefix) : formatDisplayId(normalizedId, mapping, config.display.id_prefix);
8019
+ });
7669
8020
  }, "Failed to restore from attic");
7670
- const mapping = await loadIdMapping(dataSyncDir);
7671
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7672
- const displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, prefix) : formatDisplayId(normalizedId, mapping, prefix);
7673
8021
  this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
7674
8022
  }
7675
8023
  };
@@ -7789,14 +8137,18 @@ var ImportHandler = class extends BaseCommand {
7789
8137
  if (!file && !options.validate) throw new ValidationError("Provide a JSONL file path to import.\n\nFor Beads migration, use: tbd setup --from-beads\nFor workspace import, use: tbd import --workspace=<name> or --outbox");
7790
8138
  if (options.validate) {
7791
8139
  this.tbdRoot = await requireInit();
7792
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7793
- await this.validateImport(options);
8140
+ await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8141
+ this.dataSyncDir = dataSyncDir;
8142
+ await this.validateImport(options);
8143
+ });
7794
8144
  return;
7795
8145
  }
7796
8146
  if (file) {
7797
8147
  this.tbdRoot = await requireInit();
7798
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7799
- await this.importFromFile(file, options);
8148
+ await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8149
+ this.dataSyncDir = dataSyncDir;
8150
+ await this.importFromFile(file, options);
8151
+ });
7800
8152
  }
7801
8153
  }
7802
8154
  /**
@@ -7804,7 +8156,6 @@ var ImportHandler = class extends BaseCommand {
7804
8156
  */
7805
8157
  async importFromWorkspaceCmd(options) {
7806
8158
  this.tbdRoot = await requireInit();
7807
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7808
8159
  const wsOptions = {
7809
8160
  workspace: options.workspace,
7810
8161
  dir: options.dir,
@@ -7815,7 +8166,10 @@ var ImportHandler = class extends BaseCommand {
7815
8166
  const spinner = this.output.spinner("Importing from workspace...");
7816
8167
  wsOptions.logger = this.output.logger(spinner);
7817
8168
  const result = await this.execute(async () => {
7818
- return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
8169
+ return await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8170
+ this.dataSyncDir = dataSyncDir;
8171
+ return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
8172
+ });
7819
8173
  }, "Failed to import from workspace");
7820
8174
  spinner.stop();
7821
8175
  if (!result) return;
@@ -8461,7 +8815,12 @@ var UninstallHandler = class extends BaseCommand {
8461
8815
  const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
8462
8816
  const remote = config?.sync.remote ?? "origin";
8463
8817
  const tbdDir = join(tbdRoot, ".tbd");
8464
- const worktreePath = join(tbdDir, "data-sync-worktree");
8818
+ const sharedPaths = await resolveSharedTbdPaths(tbdRoot).catch((error) => {
8819
+ this.output.debug(`resolveSharedTbdPaths failed: ${error instanceof Error ? error.message : String(error)}`);
8820
+ return null;
8821
+ });
8822
+ const worktreePath = sharedPaths?.sharedWorktreePath ?? join(tbdDir, "data-sync-worktree");
8823
+ const legacyWorktreePath = join(tbdDir, "data-sync-worktree");
8465
8824
  const displayPath = (p) => relative(process.cwd(), p) || p;
8466
8825
  const items = [];
8467
8826
  let worktreeExists = false;
@@ -8471,6 +8830,13 @@ var UninstallHandler = class extends BaseCommand {
8471
8830
  const worktreeStats = await this.getDirectoryStats(worktreePath);
8472
8831
  items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
8473
8832
  } catch {}
8833
+ let legacyWorktreeExists = false;
8834
+ if (legacyWorktreePath !== worktreePath) try {
8835
+ await access(legacyWorktreePath);
8836
+ legacyWorktreeExists = true;
8837
+ const legacyStats = await this.getDirectoryStats(legacyWorktreePath);
8838
+ items.push(` - Legacy worktree: ${displayPath(legacyWorktreePath)} (${legacyStats.files} files)`);
8839
+ } catch {}
8474
8840
  let localBranchExists = false;
8475
8841
  try {
8476
8842
  execSync(`git rev-parse --verify ${syncBranch}`, {
@@ -8534,6 +8900,23 @@ var UninstallHandler = class extends BaseCommand {
8534
8900
  console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
8535
8901
  }
8536
8902
  }
8903
+ if (legacyWorktreeExists) try {
8904
+ execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
8905
+ encoding: "utf-8",
8906
+ stdio: [
8907
+ "ignore",
8908
+ "pipe",
8909
+ "ignore"
8910
+ ]
8911
+ });
8912
+ console.log(` ${colors.success("✓")} Removed legacy git worktree`);
8913
+ } catch {
8914
+ await rm(legacyWorktreePath, {
8915
+ recursive: true,
8916
+ force: true
8917
+ });
8918
+ console.log(` ${colors.success("✓")} Removed legacy worktree directory`);
8919
+ }
8537
8920
  if (localBranchExists && !options.keepBranch) try {
8538
8921
  execSync(`git branch -D ${syncBranch}`, {
8539
8922
  encoding: "utf-8",
@@ -8579,6 +8962,15 @@ var UninstallHandler = class extends BaseCommand {
8579
8962
  } catch (error) {
8580
8963
  throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
8581
8964
  }
8965
+ if (sharedPaths) try {
8966
+ await rm(sharedPaths.sharedTbdDir, {
8967
+ recursive: true,
8968
+ force: true
8969
+ });
8970
+ console.log(` ${colors.success("✓")} Removed shared common-dir metadata`);
8971
+ } catch {
8972
+ console.log(` ${colors.warn("⚠")} Could not remove shared common-dir metadata`);
8973
+ }
8582
8974
  console.log("");
8583
8975
  this.output.success("tbd has been uninstalled from this repository.");
8584
8976
  if (options.keepBranch && localBranchExists) {
@@ -9149,7 +9541,8 @@ var PrimeHandler = class extends BaseCommand {
9149
9541
  */
9150
9542
  async getIssueStats(tbdRoot) {
9151
9543
  try {
9152
- const issues = await listIssues(await resolveDataSyncDir(tbdRoot));
9544
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
9545
+ const issues = await listIssues(dataSyncDir);
9153
9546
  let open = 0;
9154
9547
  let inProgress = 0;
9155
9548
  const blockedIds = /* @__PURE__ */ new Set();
@@ -10825,25 +11218,26 @@ var SetupDefaultHandler = class extends BaseCommand {
10825
11218
  if (!gitRoot) throw new CLIError("Could not determine git repository root.");
10826
11219
  const projectDir = gitRoot;
10827
11220
  const hasTbd = await isInitialized(projectDir);
10828
- const hasBeads = await pathExists(join(projectDir, ".beads"));
11221
+ const hasBeads = await pathExists$1(join(projectDir, ".beads"));
10829
11222
  if (options.fromBeads && !hasBeads) throw new CLIError("The --from-beads flag requires a .beads/ directory to migrate from.\nFor fresh setup, use: tbd setup --auto --prefix=<name>");
10830
11223
  console.log("Checking repository...");
10831
11224
  console.log(` ${colors.success("✓")} Git repository detected`);
10832
11225
  if (hasTbd) {
10833
11226
  const { config, migrated, changes } = await readConfigWithMigration(projectDir);
10834
11227
  console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
10835
- let needsConfigWrite = migrated;
11228
+ let ghCliChanged = false;
10836
11229
  if (options.ghCli === false && config.settings.use_gh_cli !== false) {
10837
11230
  config.settings.use_gh_cli = false;
10838
- needsConfigWrite = true;
11231
+ ghCliChanged = true;
10839
11232
  }
10840
- if (needsConfigWrite) {
11233
+ if (migrated) {
11234
+ await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
11235
+ console.log(` ${colors.success("✓")} Config migrated to latest format`);
11236
+ for (const change of changes) console.log(` ${colors.dim(change)}`);
11237
+ }
11238
+ if (ghCliChanged) {
10841
11239
  await writeConfig(projectDir, config);
10842
- if (migrated) {
10843
- console.log(` ${colors.success("✓")} Config migrated to latest format`);
10844
- for (const change of changes) console.log(` ${colors.dim(change)}`);
10845
- }
10846
- if (options.ghCli === false) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
11240
+ console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
10847
11241
  }
10848
11242
  console.log("");
10849
11243
  await this.handleAlreadyInitialized(projectDir, isAutoMode);
@@ -10991,7 +11385,7 @@ Example:
10991
11385
  }
10992
11386
  async initializeTbd(cwd, prefix) {
10993
11387
  const colors = this.output.getColors();
10994
- await initConfig(cwd, VERSION, prefix);
11388
+ const config = await initConfig(cwd, VERSION, prefix);
10995
11389
  console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
10996
11390
  const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
10997
11391
  "# Synced documentation cache (regenerated by tbd sync --docs)",
@@ -11022,7 +11416,10 @@ Example:
11022
11416
  if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
11023
11417
  else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
11024
11418
  try {
11025
- await initWorktree(cwd);
11419
+ await withSharedDataSyncLock(cwd, async () => {
11420
+ await initWorktree(cwd);
11421
+ await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
11422
+ });
11026
11423
  const health = await checkWorktreeHealth(cwd);
11027
11424
  if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
11028
11425
  else {
@@ -11220,18 +11617,18 @@ var SetupAutoHandler = class extends BaseCommand {
11220
11617
  };
11221
11618
  if (mode === "off") return result;
11222
11619
  if (mode === "auto") {
11223
- const hasClaudeDir = await pathExists(GLOBAL_CLAUDE_DIR);
11620
+ const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
11224
11621
  const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
11225
11622
  if (!hasClaudeDir && !hasClaudeEnv) return result;
11226
11623
  }
11227
11624
  result.detected = true;
11228
11625
  const claudePaths = getClaudePaths(cwd);
11229
11626
  try {
11230
- if (await pathExists(claudePaths.settings)) {
11627
+ if (await pathExists$1(claudePaths.settings)) {
11231
11628
  const content = await readFile(claudePaths.settings, "utf-8");
11232
11629
  const hooks = JSON.parse(content).hooks;
11233
11630
  if (hooks) {
11234
- if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists(claudePaths.skill)) result.alreadyInstalled = true;
11631
+ if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists$1(claudePaths.skill)) result.alreadyInstalled = true;
11235
11632
  }
11236
11633
  }
11237
11634
  const handler = new SetupClaudeHandler(this.cmd);
@@ -11252,7 +11649,7 @@ var SetupAutoHandler = class extends BaseCommand {
11252
11649
  };
11253
11650
  if (mode === "off") return result;
11254
11651
  const agentsPath = getAgentsMdPath(cwd);
11255
- const hasAgentsMd = await pathExists(agentsPath);
11652
+ const hasAgentsMd = await pathExists$1(agentsPath);
11256
11653
  if (mode === "auto") {
11257
11654
  const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
11258
11655
  if (!hasAgentsMd && !hasCodexEnv) return result;
@@ -11322,7 +11719,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
11322
11719
  var SaveHandler = class extends BaseCommand {
11323
11720
  async run(options) {
11324
11721
  const tbdRoot = await requireInit();
11325
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
11326
11722
  if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
11327
11723
  const saveOptions = {
11328
11724
  workspace: options.workspace,
@@ -11334,7 +11730,9 @@ var SaveHandler = class extends BaseCommand {
11334
11730
  const spinner = this.output.spinner("Saving issues...");
11335
11731
  saveOptions.logger = this.output.logger(spinner);
11336
11732
  const result = await this.execute(async () => {
11337
- return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
11733
+ return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
11734
+ return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
11735
+ });
11338
11736
  }, "Failed to save issues");
11339
11737
  spinner.stop();
11340
11738
  if (!result) return;