get-tbd 0.1.29 → 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 (93) hide show
  1. package/README.md +5 -1
  2. package/dist/bin.mjs +3241 -2326
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +1503 -791
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-B38rbI9u.mjs → config-BJz1m9eN.mjs} +183 -39
  7. package/dist/config-BJz1m9eN.mjs.map +1 -0
  8. package/dist/{config-C0ITTrtc.mjs → config-DlCUMyCG.mjs} +1 -1
  9. package/dist/docs/README.md +5 -1
  10. package/dist/docs/SKILL.md +0 -1
  11. package/dist/docs/guidelines/backward-compatibility-rules.md +4 -0
  12. package/dist/docs/guidelines/bun-monorepo-patterns.md +20 -4
  13. package/dist/docs/guidelines/cli-agent-skill-patterns.md +354 -37
  14. package/dist/docs/guidelines/commit-conventions.md +4 -0
  15. package/dist/docs/guidelines/common-doc-guidelines.md +234 -0
  16. package/dist/docs/guidelines/convex-limits-best-practices.md +4 -0
  17. package/dist/docs/guidelines/convex-rules.md +4 -0
  18. package/dist/docs/guidelines/electron-app-development-patterns.md +4 -0
  19. package/dist/docs/guidelines/error-handling-rules.md +4 -0
  20. package/dist/docs/guidelines/general-coding-rules.md +4 -0
  21. package/dist/docs/guidelines/general-comment-rules.md +4 -0
  22. package/dist/docs/guidelines/general-eng-assistant-rules.md +4 -0
  23. package/dist/docs/guidelines/general-tdd-guidelines.md +4 -0
  24. package/dist/docs/guidelines/general-testing-rules.md +4 -0
  25. package/dist/docs/guidelines/golden-testing-guidelines.md +4 -0
  26. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +27 -6
  27. package/dist/docs/guidelines/python-cli-patterns.md +4 -0
  28. package/dist/docs/guidelines/python-modern-guidelines.md +30 -0
  29. package/dist/docs/guidelines/python-rules.md +4 -0
  30. package/dist/docs/guidelines/release-notes-guidelines.md +4 -0
  31. package/dist/docs/guidelines/supply-chain-hardening.md +11 -7
  32. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +10 -4
  33. package/dist/docs/guidelines/typescript-cli-tool-rules.md +27 -24
  34. package/dist/docs/guidelines/typescript-code-coverage.md +11 -7
  35. package/dist/docs/guidelines/typescript-rules.md +10 -6
  36. package/dist/docs/guidelines/typescript-sorting-patterns.md +4 -0
  37. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +7 -3
  38. package/dist/docs/install/ensure-gh-cli.sh +59 -24
  39. package/dist/docs/shortcuts/standard/agent-handoff.md +4 -0
  40. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
  41. package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
  42. package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
  43. package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
  44. package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
  45. package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
  46. package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
  47. package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
  48. package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
  49. package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
  50. package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
  51. package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
  52. package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
  53. package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
  54. package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
  55. package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
  56. package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
  57. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
  58. package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
  59. package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
  60. package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
  61. package/dist/docs/shortcuts/standard/review-code.md +4 -0
  62. package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
  63. package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
  64. package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
  65. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
  66. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
  67. package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
  68. package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
  69. package/dist/docs/tbd-closing.md +4 -0
  70. package/dist/docs/tbd-design.md +109 -68
  71. package/dist/docs/tbd-docs.md +20 -13
  72. package/dist/docs/tbd-prime.md +4 -0
  73. package/dist/docs/templates/architecture-doc.md +4 -0
  74. package/dist/docs/templates/plan-spec.md +4 -0
  75. package/dist/docs/templates/qa-playbook.md +4 -0
  76. package/dist/docs/templates/research-brief.md +4 -0
  77. package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-CFoPVinz.mjs} +1 -1
  78. package/dist/{id-mapping-CqrrLgeX.mjs → id-mapping-CtfTfGIh.mjs} +146 -122
  79. package/dist/id-mapping-CtfTfGIh.mjs.map +1 -0
  80. package/dist/index.d.mts +53 -1
  81. package/dist/index.mjs +3 -3
  82. package/dist/{schemas-C8mOQykE.mjs → schemas-f0EcuAVu.mjs} +40 -3
  83. package/dist/schemas-f0EcuAVu.mjs.map +1 -0
  84. package/dist/{src-CJyVkC3V.mjs → src-rIE4xSVs.mjs} +3 -3
  85. package/dist/src-rIE4xSVs.mjs.map +1 -0
  86. package/dist/tbd +3241 -2326
  87. package/package.json +1 -1
  88. package/dist/config-B38rbI9u.mjs.map +0 -1
  89. package/dist/docs/guidelines/general-style-rules.md +0 -38
  90. package/dist/docs/guidelines/writing-style-guidelines.md +0 -42
  91. package/dist/id-mapping-CqrrLgeX.mjs.map +0 -1
  92. package/dist/schemas-C8mOQykE.mjs.map +0 -1
  93. package/dist/src-CJyVkC3V.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-CJyVkC3V.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 isValidWorkspaceName, C as TBD_SHORTCUTS_STANDARD, D as WORKTREE_DIR, E as WORKSPACES_DIR, M as resolveDataSyncDir, O as WORKTREE_DIR_NAME, S as TBD_GUIDELINES_DIR, T as TBD_TEMPLATES_DIR, _ as DEFAULT_SHORTCUT_PATHS, a as isInitialized, b as TBD_DIR, c as readConfigWithMigration, d as writeConfig, g as DEFAULT_GUIDELINES_PATHS, h as DATA_SYNC_DIR_NAME, i as initConfig, j as resolveAtticDir, k as getWorkspaceDir, l as readLocalState, m as DATA_SYNC_DIR, n as findTbdRoot, o as markWelcomeSeen, p as CHARS_PER_TOKEN, r as hasSeenWelcome, s as readConfig, u as updateLocalState, v as DEFAULT_TEMPLATE_PATHS, w as TBD_SHORTCUTS_SYSTEM, x as TBD_DOCS_DIR, y as SYNC_BRANCH } from "./config-B38rbI9u.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) {
1351
1321
  try {
1352
- await git("ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1322
+ await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1353
1323
  return true;
1354
1324
  } catch {
1355
1325
  return false;
1356
1326
  }
1357
1327
  }
1328
+ async function pathExists(path) {
1329
+ try {
1330
+ await access(path);
1331
+ return true;
1332
+ } catch {
1333
+ return false;
1334
+ }
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
  /**
@@ -4988,28 +5240,10 @@ var SyncHandler = class extends BaseCommand {
4988
5240
  const parts = [];
4989
5241
  if (result.added.length > 0) parts.push(`+${result.added.length}`);
4990
5242
  if (result.updated.length > 0) parts.push(`~${result.updated.length}`);
4991
- if (result.removed.length > 0) parts.push(`-${result.removed.length}`);
4992
- if (parts.length > 0) this.output.success(`Synced docs: ${parts.join(" ")} doc(s)`);
4993
- if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
4994
- for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
4995
- }
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
- }
5243
+ if (result.removed.length > 0) parts.push(`-${result.removed.length}`);
5244
+ if (parts.length > 0) this.output.success(`Synced docs: ${parts.join(" ")} doc(s)`);
5245
+ if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
5246
+ for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
5013
5247
  }
5014
5248
  async showIssueStatus(syncBranch, remote) {
5015
5249
  const status = await this.getSyncStatus(syncBranch, remote);
@@ -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.
@@ -5845,9 +6081,10 @@ function renderFooter(suggestions, colors) {
5845
6081
  /**
5846
6082
  * Centralized path constants and utilities for coding agent integrations.
5847
6083
  *
5848
- * IMPORTANT: All tbd integration files (hooks, settings, skills) are installed
5849
- * to PROJECT-LOCAL directories (.claude/, AGENTS.md) ONLY. We do NOT install to
5850
- * global/user directories (~/.claude/).
6084
+ * IMPORTANT: All tbd integration files (skills, hooks, settings, scripts) are
6085
+ * installed to PROJECT-LOCAL directories (.agents/, .claude/, .codex/,
6086
+ * scripts/agent/, AGENTS.md) ONLY. We do NOT install to global/user directories
6087
+ * (~/.claude/, ~/.codex/, ~/.agents/).
5851
6088
  *
5852
6089
  * This file defines all path constants in one place to:
5853
6090
  * 1. Ensure consistency across the codebase
@@ -5855,6 +6092,19 @@ function renderFooter(suggestions, colors) {
5855
6092
  * 3. Simplify future changes to path conventions
5856
6093
  */
5857
6094
  /**
6095
+ * Format version stamped into generated agent integration artifacts (e.g. the
6096
+ * AGENTS.md managed block's begin marker: `... format=f03 surface=...`).
6097
+ *
6098
+ * UNIFIED with the `.tbd/` directory format (`tbd_format`): there is one format
6099
+ * code for all tbd-managed surfaces, sourced from `tbd-format.ts` (the single
6100
+ * source of truth). Bump `CURRENT_FORMAT` there when any managed surface — config
6101
+ * schema OR a generated agent surface — changes shape. A marked AGENTS.md block
6102
+ * with no `format=` field predates this and is treated as `f01`; a running tbd
6103
+ * that finds a HIGHER format than it knows refuses to overwrite it and tells the
6104
+ * user to upgrade tbd.
6105
+ */
6106
+ const AGENT_INTEGRATION_FORMAT = CURRENT_FORMAT;
6107
+ /**
5858
6108
  * Relative path to Claude Code settings file from project root.
5859
6109
  * This is where hooks are configured.
5860
6110
  */
@@ -5888,11 +6138,33 @@ const TBD_CLOSING_REMINDER_REL = ".claude/hooks/tbd-closing-reminder.sh";
5888
6138
  */
5889
6139
  const GH_CLI_SCRIPT_REL = ".claude/scripts/ensure-gh-cli.sh";
5890
6140
  /**
6141
+ * Canonical portable project Agent Skill, scanned by Codex, Gemini CLI, Cursor,
6142
+ * GitHub Copilot, Amp, OpenCode, pi, and other Agent Skills clients.
6143
+ */
6144
+ const AGENTS_SKILL_REL = ".agents/skills/tbd/SKILL.md";
6145
+ /**
6146
+ * Repository distribution copy of the skill, for skills.sh-style installers
6147
+ * (`npx skills add`) and direct GitHub browsing.
6148
+ */
6149
+ const SKILLS_DIST_REL = "skills/tbd/SKILL.md";
6150
+ /**
5891
6151
  * Relative path to AGENTS.md file from project root.
5892
6152
  * Used by Codex, Factory.ai, Cursor (v1.6+), and other compatible tools.
5893
6153
  */
5894
6154
  const AGENTS_MD_REL = "AGENTS.md";
5895
6155
  /**
6156
+ * Codex project-local config/hook directory.
6157
+ */
6158
+ const CODEX_DIR_REL = ".codex";
6159
+ /**
6160
+ * Codex project-local hooks file (Claude-compatible event schema).
6161
+ */
6162
+ const CODEX_HOOKS_REL = ".codex/hooks.json";
6163
+ /**
6164
+ * Codex project-local config; may also carry an inline `[hooks]` table.
6165
+ */
6166
+ const CODEX_CONFIG_REL = ".codex/config.toml";
6167
+ /**
5896
6168
  * Global Claude Code directory in user's home.
5897
6169
  * Used ONLY for detecting if Claude Code is installed (for agent detection).
5898
6170
  * All installations are project-local.
@@ -5926,6 +6198,31 @@ function getAgentsMdPath(projectRoot) {
5926
6198
  return join(projectRoot, AGENTS_MD_REL);
5927
6199
  }
5928
6200
  /**
6201
+ * Get the three SKILL.md targets: the portable Agent Skills install, the Claude
6202
+ * Code mirror, and the committed distribution copy.
6203
+ *
6204
+ * @param projectRoot - The project root directory (containing .tbd/)
6205
+ */
6206
+ function getAgentSkillPaths(projectRoot) {
6207
+ return {
6208
+ portable: join(projectRoot, AGENTS_SKILL_REL),
6209
+ claudeMirror: join(projectRoot, CLAUDE_SKILL_REL),
6210
+ distribution: join(projectRoot, SKILLS_DIST_REL)
6211
+ };
6212
+ }
6213
+ /**
6214
+ * Get project-local Codex config/hook paths.
6215
+ *
6216
+ * @param projectRoot - The project root directory
6217
+ */
6218
+ function getCodexPaths(projectRoot) {
6219
+ return {
6220
+ dir: join(projectRoot, CODEX_DIR_REL),
6221
+ hooks: join(projectRoot, CODEX_HOOKS_REL),
6222
+ config: join(projectRoot, CODEX_CONFIG_REL)
6223
+ };
6224
+ }
6225
+ /**
5929
6226
  * Display path for Claude Code settings in status/doctor output.
5930
6227
  */
5931
6228
  const CLAUDE_SETTINGS_DISPLAY = "./.claude/settings.json";
@@ -5933,6 +6230,14 @@ const CLAUDE_SETTINGS_DISPLAY = "./.claude/settings.json";
5933
6230
  * Display path for AGENTS.md in status/doctor output.
5934
6231
  */
5935
6232
  const AGENTS_MD_DISPLAY = "./AGENTS.md";
6233
+ /**
6234
+ * Display path for the portable Agent Skill in status/doctor output.
6235
+ */
6236
+ const AGENTS_SKILL_DISPLAY = "./.agents/skills/tbd/SKILL.md";
6237
+ /**
6238
+ * Display path for the Codex hooks file in status/doctor output.
6239
+ */
6240
+ const CODEX_HOOKS_DISPLAY = "./.codex/hooks.json";
5936
6241
 
5937
6242
  //#endregion
5938
6243
  //#region src/cli/commands/status.ts
@@ -5968,12 +6273,17 @@ var StatusHandler = class extends BaseCommand {
5968
6273
  display_prefix: null,
5969
6274
  worktree_path: null,
5970
6275
  worktree_healthy: null,
6276
+ worktree_status: null,
5971
6277
  workspaces: [],
5972
6278
  integrations: {
6279
+ portable_skill: false,
6280
+ portable_skill_path: AGENTS_SKILL_DISPLAY,
5973
6281
  claude_code: false,
5974
6282
  claude_code_path: CLAUDE_SETTINGS_DISPLAY,
5975
6283
  codex: false,
5976
- codex_path: AGENTS_MD_DISPLAY
6284
+ codex_path: AGENTS_MD_DISPLAY,
6285
+ codex_hooks: false,
6286
+ codex_hooks_path: CODEX_HOOKS_DISPLAY
5977
6287
  }
5978
6288
  };
5979
6289
  const gitInfo = await this.checkGitRepo();
@@ -6041,11 +6351,23 @@ var StatusHandler = class extends BaseCommand {
6041
6351
  const claudePaths = getClaudePaths(projectRoot);
6042
6352
  const agentsPath = getAgentsMdPath(projectRoot);
6043
6353
  const result = {
6354
+ portable_skill: false,
6355
+ portable_skill_path: AGENTS_SKILL_DISPLAY,
6044
6356
  claude_code: false,
6045
6357
  claude_code_path: CLAUDE_SETTINGS_DISPLAY,
6046
6358
  codex: false,
6047
- codex_path: AGENTS_MD_DISPLAY
6359
+ codex_path: AGENTS_MD_DISPLAY,
6360
+ codex_hooks: false,
6361
+ codex_hooks_path: CODEX_HOOKS_DISPLAY
6048
6362
  };
6363
+ try {
6364
+ await access(getAgentSkillPaths(projectRoot).portable);
6365
+ result.portable_skill = true;
6366
+ } catch {}
6367
+ try {
6368
+ await access(getCodexPaths(projectRoot).hooks);
6369
+ result.codex_hooks = true;
6370
+ } catch {}
6049
6371
  try {
6050
6372
  await access(claudePaths.settings);
6051
6373
  const content = await readFile(claudePaths.settings, "utf-8");
@@ -6065,10 +6387,11 @@ var StatusHandler = class extends BaseCommand {
6065
6387
  data.remote = config.sync.remote;
6066
6388
  data.display_prefix = config.display.id_prefix;
6067
6389
  } catch {}
6068
- const worktreePath = join(cwd, WORKTREE_DIR);
6069
- const worktreeHealth = await checkWorktreeHealth(cwd);
6070
- 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;
6071
6393
  data.worktree_healthy = worktreeHealth.valid;
6394
+ data.worktree_status = worktreeHealth.status;
6072
6395
  try {
6073
6396
  data.workspaces = await listWorkspaces(cwd);
6074
6397
  } catch {}
@@ -6094,19 +6417,32 @@ var StatusHandler = class extends BaseCommand {
6094
6417
  remote: data.remote,
6095
6418
  displayPrefix: data.display_prefix
6096
6419
  }, colors);
6097
- if (renderIntegrationsSection([{
6098
- name: "Claude Code hooks",
6099
- installed: data.integrations.claude_code,
6100
- path: data.integrations.claude_code_path
6101
- }, {
6102
- name: "Codex AGENTS.md",
6103
- installed: data.integrations.codex,
6104
- path: data.integrations.codex_path
6105
- }], colors)) {
6420
+ if (renderIntegrationsSection([
6421
+ {
6422
+ name: "Portable Agent Skill",
6423
+ installed: data.integrations.portable_skill,
6424
+ path: data.integrations.portable_skill_path
6425
+ },
6426
+ {
6427
+ name: "Claude Code hooks",
6428
+ installed: data.integrations.claude_code,
6429
+ path: data.integrations.claude_code_path
6430
+ },
6431
+ {
6432
+ name: "Codex AGENTS.md",
6433
+ installed: data.integrations.codex,
6434
+ path: data.integrations.codex_path
6435
+ },
6436
+ {
6437
+ name: "Codex hooks",
6438
+ installed: data.integrations.codex_hooks,
6439
+ path: data.integrations.codex_hooks_path
6440
+ }
6441
+ ], colors)) {
6106
6442
  console.log("");
6107
6443
  console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
6108
6444
  }
6109
- 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);
6110
6446
  if (data.workspaces.length > 0) {
6111
6447
  console.log("");
6112
6448
  console.log(colors.bold("WORKSPACES"));
@@ -6204,7 +6540,8 @@ var StatsHandler = class extends BaseCommand {
6204
6540
  const tbdRoot = await requireInit();
6205
6541
  let issues;
6206
6542
  try {
6207
- issues = await listIssues(await resolveDataSyncDir(tbdRoot));
6543
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
6544
+ issues = await listIssues(dataSyncDir);
6208
6545
  } catch {
6209
6546
  throw new NotInitializedError("No issue store found. Run `tbd init` first.");
6210
6547
  }
@@ -6397,7 +6734,7 @@ var DoctorHandler = class extends BaseCommand {
6397
6734
  async run(options) {
6398
6735
  const tbdRoot = await requireInit();
6399
6736
  this.cwd = tbdRoot;
6400
- this.dataSyncDir = await resolveDataSyncDir(tbdRoot);
6737
+ this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
6401
6738
  try {
6402
6739
  this.config = await readConfig(this.cwd);
6403
6740
  } catch {}
@@ -6421,10 +6758,11 @@ var DoctorHandler = class extends BaseCommand {
6421
6758
  healthChecks.push(await this.checkTempFiles(options.fix));
6422
6759
  healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
6423
6760
  healthChecks.push(await this.checkWorktree(options.fix));
6761
+ healthChecks.push(await this.checkCommonDirLayout(options.fix));
6424
6762
  const dataLocationResult = await this.checkDataLocation(options.fix);
6425
6763
  healthChecks.push(dataLocationResult);
6426
6764
  if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
6427
- this.dataSyncDir = await resolveDataSyncDir(this.cwd);
6765
+ this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
6428
6766
  try {
6429
6767
  this.invalidIssueFiles = [];
6430
6768
  this.issues = await listIssues(this.dataSyncDir, {
@@ -6442,10 +6780,13 @@ var DoctorHandler = class extends BaseCommand {
6442
6780
  healthChecks.push(await this.checkCloneScenarios());
6443
6781
  healthChecks.push(await this.checkSyncConsistency());
6444
6782
  const integrationChecks = [];
6783
+ integrationChecks.push(await this.checkPortableSkill());
6445
6784
  integrationChecks.push(await this.checkClaudeSkill());
6446
6785
  integrationChecks.push(await this.checkCodexAgents());
6786
+ integrationChecks.push(await this.checkCodexHooks());
6447
6787
  const allChecks = [...healthChecks, ...integrationChecks];
6448
6788
  const allOk = allChecks.every((c) => c.status === "ok");
6789
+ const hasErrors = allChecks.some((c) => c.status === "error");
6449
6790
  const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
6450
6791
  this.output.data({
6451
6792
  statusInfo,
@@ -6481,11 +6822,12 @@ var DoctorHandler = class extends BaseCommand {
6481
6822
  else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
6482
6823
  else this.output.warn("Issues found that may require manual intervention.");
6483
6824
  });
6825
+ if (hasErrors) process.exitCode = 1;
6484
6826
  }
6485
6827
  async gatherStatusInfo() {
6486
6828
  let gitBranch = null;
6487
6829
  try {
6488
- gitBranch = await getCurrentBranch();
6830
+ gitBranch = await getCurrentBranch(this.cwd);
6489
6831
  } catch {}
6490
6832
  const worktreeHealth = await checkWorktreeHealth(this.cwd);
6491
6833
  return {
@@ -6512,7 +6854,7 @@ var DoctorHandler = class extends BaseCommand {
6512
6854
  if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
6513
6855
  }
6514
6856
  let remoteTotal = null;
6515
- 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);
6516
6858
  return {
6517
6859
  total: this.issues.length,
6518
6860
  ready: readyCount,
@@ -6570,6 +6912,13 @@ var DoctorHandler = class extends BaseCommand {
6570
6912
  path: configPath,
6571
6913
  suggestion: "Run: tbd init"
6572
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
+ };
6573
6922
  return {
6574
6923
  name: "Config file",
6575
6924
  status: "error",
@@ -6661,7 +7010,7 @@ var DoctorHandler = class extends BaseCommand {
6661
7010
  status: "ok"
6662
7011
  };
6663
7012
  if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
6664
- const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7013
+ const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
6665
7014
  const resolved = resolveIdMappingConflicts(content);
6666
7015
  await saveIdMapping(this.dataSyncDir, resolved);
6667
7016
  return {
@@ -6710,7 +7059,7 @@ var DoctorHandler = class extends BaseCommand {
6710
7059
  status: "ok"
6711
7060
  };
6712
7061
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
6713
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7062
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
6714
7063
  const mapping = await loadIdMapping(this.dataSyncDir);
6715
7064
  await saveIdMapping(this.dataSyncDir, mapping);
6716
7065
  return {
@@ -6849,7 +7198,7 @@ var DoctorHandler = class extends BaseCommand {
6849
7198
  name: "ID mapping coverage",
6850
7199
  status: "ok"
6851
7200
  };
6852
- const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7201
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-CFoPVinz.mjs");
6853
7202
  const mapping = await loadIdMapping(this.dataSyncDir);
6854
7203
  const missingIds = [];
6855
7204
  for (const issue of this.issues) {
@@ -6861,10 +7210,10 @@ var DoctorHandler = class extends BaseCommand {
6861
7210
  status: "ok"
6862
7211
  };
6863
7212
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
6864
- const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7213
+ const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-CFoPVinz.mjs");
6865
7214
  let historicalMapping;
6866
7215
  try {
6867
- const syncBranch = (await import("./config-C0ITTrtc.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;
6868
7217
  const logArgs = ["log", "--format=%H"];
6869
7218
  if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
6870
7219
  logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
@@ -6907,6 +7256,25 @@ var DoctorHandler = class extends BaseCommand {
6907
7256
  suggestion: "Run: tbd doctor --fix to create missing mappings"
6908
7257
  };
6909
7258
  }
7259
+ async checkPortableSkill() {
7260
+ const { portable } = getAgentSkillPaths(this.cwd);
7261
+ try {
7262
+ await access(portable);
7263
+ return {
7264
+ name: "Portable Agent Skill",
7265
+ status: "ok",
7266
+ path: AGENTS_SKILL_REL
7267
+ };
7268
+ } catch {
7269
+ return {
7270
+ name: "Portable Agent Skill",
7271
+ status: "warn",
7272
+ message: "not installed",
7273
+ path: AGENTS_SKILL_REL,
7274
+ suggestion: "Run: tbd setup --auto"
7275
+ };
7276
+ }
7277
+ }
6910
7278
  async checkClaudeSkill() {
6911
7279
  const claudePaths = getClaudePaths(this.cwd);
6912
7280
  try {
@@ -6926,6 +7294,25 @@ var DoctorHandler = class extends BaseCommand {
6926
7294
  };
6927
7295
  }
6928
7296
  }
7297
+ async checkCodexHooks() {
7298
+ const codexPaths = getCodexPaths(this.cwd);
7299
+ try {
7300
+ await access(codexPaths.hooks);
7301
+ return {
7302
+ name: "Codex hooks",
7303
+ status: "ok",
7304
+ path: CODEX_HOOKS_REL
7305
+ };
7306
+ } catch {
7307
+ return {
7308
+ name: "Codex hooks",
7309
+ status: "warn",
7310
+ message: "not installed",
7311
+ path: CODEX_HOOKS_REL,
7312
+ suggestion: "Run: tbd setup --auto"
7313
+ };
7314
+ }
7315
+ }
6929
7316
  async checkCodexAgents() {
6930
7317
  const agentsPath = getAgentsMdPath(this.cwd);
6931
7318
  try {
@@ -6957,24 +7344,51 @@ var DoctorHandler = class extends BaseCommand {
6957
7344
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
6958
7345
  */
6959
7346
  async checkWorktree(fix) {
6960
- const worktreePath = WORKTREE_DIR;
6961
- 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);
6962
7351
  switch (worktreeHealth.status) {
6963
7352
  case "valid": return {
6964
7353
  name: "Worktree",
6965
7354
  status: "ok",
6966
7355
  path: worktreePath
6967
7356
  };
6968
- case "missing": return {
6969
- name: "Worktree",
6970
- status: "ok",
6971
- message: "not created yet",
6972
- path: worktreePath
6973
- };
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
+ };
6974
7387
  case "prunable":
6975
7388
  case "corrupted":
6976
7389
  if (fix && !this.checkDryRun("Repair worktree")) {
6977
- 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));
6978
7392
  if (result.success) return {
6979
7393
  name: "Worktree",
6980
7394
  status: "ok",
@@ -7017,10 +7431,80 @@ var DoctorHandler = class extends BaseCommand {
7017
7431
  }
7018
7432
  }
7019
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
+ /**
7020
7504
  * Check for issues in wrong location.
7021
7505
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
7022
7506
  *
7023
- * 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/
7024
7508
  * If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
7025
7509
  * and data was written to the fallback path - this is a bug requiring migration.
7026
7510
  */
@@ -7038,7 +7522,7 @@ var DoctorHandler = class extends BaseCommand {
7038
7522
  if (fix && !this.checkDryRun("Migrate data to worktree")) {
7039
7523
  let worktreeHealth = await checkWorktreeHealth(this.cwd);
7040
7524
  if (worktreeHealth.status === "missing") {
7041
- const initResult = await initWorktree(this.cwd);
7525
+ const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
7042
7526
  if (!initResult.success) return {
7043
7527
  name: "Data location",
7044
7528
  status: "error",
@@ -7081,7 +7565,7 @@ var DoctorHandler = class extends BaseCommand {
7081
7565
  path: wrongIssuesPath,
7082
7566
  details: [
7083
7567
  `Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
7084
- "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/",
7085
7569
  "This indicates the worktree was missing when issues were created"
7086
7570
  ],
7087
7571
  fixable: true,
@@ -7094,14 +7578,14 @@ var DoctorHandler = class extends BaseCommand {
7094
7578
  */
7095
7579
  async checkLocalSyncBranch() {
7096
7580
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7097
- const localHealth = await checkLocalBranchHealth(syncBranch);
7581
+ const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
7098
7582
  if (localHealth.exists && !localHealth.orphaned) return {
7099
7583
  name: "Local sync branch",
7100
7584
  status: "ok",
7101
7585
  message: syncBranch
7102
7586
  };
7103
7587
  if (!localHealth.exists) {
7104
- 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 {
7105
7589
  name: "Local sync branch",
7106
7590
  status: "warn",
7107
7591
  message: `${syncBranch} not found (remote exists)`,
@@ -7127,7 +7611,7 @@ var DoctorHandler = class extends BaseCommand {
7127
7611
  async checkRemoteSyncBranch() {
7128
7612
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7129
7613
  const remote = this.config?.sync.remote ?? "origin";
7130
- const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
7614
+ const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
7131
7615
  if (remoteHealth.exists) {
7132
7616
  if (remoteHealth.diverged) return {
7133
7617
  name: "Remote sync branch",
@@ -7141,7 +7625,7 @@ var DoctorHandler = class extends BaseCommand {
7141
7625
  message: `${remote}/${syncBranch}`
7142
7626
  };
7143
7627
  }
7144
- if ((await checkLocalBranchHealth(syncBranch)).exists) return {
7628
+ if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
7145
7629
  name: "Remote sync branch",
7146
7630
  status: "warn",
7147
7631
  message: `${remote}/${syncBranch} not found`,
@@ -7170,7 +7654,7 @@ var DoctorHandler = class extends BaseCommand {
7170
7654
  status: "ok"
7171
7655
  };
7172
7656
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7173
- 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 {
7174
7658
  name: "Sync status",
7175
7659
  status: "warn",
7176
7660
  message: `${localIssueCount} local issues, remote branch not found`,
@@ -7316,6 +7800,7 @@ var ConfigShowHandler = class extends BaseCommand {
7316
7800
  console.log(`${colors.dim("sync:")}`);
7317
7801
  console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
7318
7802
  console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
7803
+ console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
7319
7804
  console.log(`${colors.dim("display:")}`);
7320
7805
  console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
7321
7806
  console.log(`${colors.dim("settings:")}`);
@@ -7451,9 +7936,9 @@ async function listAtticEntries(tbdRoot, filterById) {
7451
7936
  var AtticListHandler = class extends BaseCommand {
7452
7937
  async run(id) {
7453
7938
  const tbdRoot = await requireInit();
7939
+ const { mapping, config } = await loadDataContext(tbdRoot);
7454
7940
  const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
7455
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7456
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7941
+ const prefix = config.display.id_prefix;
7457
7942
  const showDebug = this.ctx.debug;
7458
7943
  const output = entries.map((e) => ({
7459
7944
  id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
@@ -7478,10 +7963,10 @@ var AtticListHandler = class extends BaseCommand {
7478
7963
  var AtticShowHandler = class extends BaseCommand {
7479
7964
  async run(id, timestamp) {
7480
7965
  const tbdRoot = await requireInit();
7966
+ const { mapping, config } = await loadDataContext(tbdRoot);
7481
7967
  const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7482
7968
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
7483
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7484
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7969
+ const prefix = config.display.id_prefix;
7485
7970
  const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
7486
7971
  this.output.data(entry, () => {
7487
7972
  const colors = this.output.getColors();
@@ -7507,6 +7992,7 @@ var AtticShowHandler = class extends BaseCommand {
7507
7992
  var AtticRestoreHandler = class extends BaseCommand {
7508
7993
  async run(id, timestamp) {
7509
7994
  const tbdRoot = await requireInit();
7995
+ await loadDataContext(tbdRoot);
7510
7996
  const normalizedId = normalizeIssueId(id);
7511
7997
  const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7512
7998
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
@@ -7514,24 +8000,24 @@ var AtticRestoreHandler = class extends BaseCommand {
7514
8000
  id: normalizedId,
7515
8001
  field: entry.field
7516
8002
  })) return;
7517
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
7518
- let issue;
7519
- try {
7520
- issue = await readIssue(dataSyncDir, normalizedId);
7521
- } catch {
7522
- throw new NotFoundError("Issue", id);
7523
- }
7524
- const field = entry.field;
7525
- if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
7526
- else throw new ValidationError(`Cannot restore field: ${entry.field}`);
7527
- issue.version += 1;
7528
- issue.updated_at = now();
8003
+ let displayId = id;
7529
8004
  await this.execute(async () => {
7530
- 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
+ });
7531
8020
  }, "Failed to restore from attic");
7532
- const mapping = await loadIdMapping(dataSyncDir);
7533
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7534
- const displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, prefix) : formatDisplayId(normalizedId, mapping, prefix);
7535
8021
  this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
7536
8022
  }
7537
8023
  };
@@ -7651,14 +8137,18 @@ var ImportHandler = class extends BaseCommand {
7651
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");
7652
8138
  if (options.validate) {
7653
8139
  this.tbdRoot = await requireInit();
7654
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7655
- await this.validateImport(options);
8140
+ await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8141
+ this.dataSyncDir = dataSyncDir;
8142
+ await this.validateImport(options);
8143
+ });
7656
8144
  return;
7657
8145
  }
7658
8146
  if (file) {
7659
8147
  this.tbdRoot = await requireInit();
7660
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7661
- 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
+ });
7662
8152
  }
7663
8153
  }
7664
8154
  /**
@@ -7666,7 +8156,6 @@ var ImportHandler = class extends BaseCommand {
7666
8156
  */
7667
8157
  async importFromWorkspaceCmd(options) {
7668
8158
  this.tbdRoot = await requireInit();
7669
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7670
8159
  const wsOptions = {
7671
8160
  workspace: options.workspace,
7672
8161
  dir: options.dir,
@@ -7677,7 +8166,10 @@ var ImportHandler = class extends BaseCommand {
7677
8166
  const spinner = this.output.spinner("Importing from workspace...");
7678
8167
  wsOptions.logger = this.output.logger(spinner);
7679
8168
  const result = await this.execute(async () => {
7680
- 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
+ });
7681
8173
  }, "Failed to import from workspace");
7682
8174
  spinner.stop();
7683
8175
  if (!result) return;
@@ -8323,7 +8815,12 @@ var UninstallHandler = class extends BaseCommand {
8323
8815
  const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
8324
8816
  const remote = config?.sync.remote ?? "origin";
8325
8817
  const tbdDir = join(tbdRoot, ".tbd");
8326
- 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");
8327
8824
  const displayPath = (p) => relative(process.cwd(), p) || p;
8328
8825
  const items = [];
8329
8826
  let worktreeExists = false;
@@ -8333,6 +8830,13 @@ var UninstallHandler = class extends BaseCommand {
8333
8830
  const worktreeStats = await this.getDirectoryStats(worktreePath);
8334
8831
  items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
8335
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 {}
8336
8840
  let localBranchExists = false;
8337
8841
  try {
8338
8842
  execSync(`git rev-parse --verify ${syncBranch}`, {
@@ -8396,6 +8900,23 @@ var UninstallHandler = class extends BaseCommand {
8396
8900
  console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
8397
8901
  }
8398
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
+ }
8399
8920
  if (localBranchExists && !options.keepBranch) try {
8400
8921
  execSync(`git branch -D ${syncBranch}`, {
8401
8922
  encoding: "utf-8",
@@ -8441,6 +8962,15 @@ var UninstallHandler = class extends BaseCommand {
8441
8962
  } catch (error) {
8442
8963
  throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
8443
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
+ }
8444
8974
  console.log("");
8445
8975
  this.output.success("tbd has been uninstalled from this repository.");
8446
8976
  if (options.keepBranch && localBranchExists) {
@@ -9011,7 +9541,8 @@ var PrimeHandler = class extends BaseCommand {
9011
9541
  */
9012
9542
  async getIssueStats(tbdRoot) {
9013
9543
  try {
9014
- const issues = await listIssues(await resolveDataSyncDir(tbdRoot));
9544
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
9545
+ const issues = await listIssues(dataSyncDir);
9015
9546
  let open = 0;
9016
9547
  let inProgress = 0;
9017
9548
  const blockedIds = /* @__PURE__ */ new Set();
@@ -9863,16 +10394,89 @@ async function getShortcutDirectory(quiet = false) {
9863
10394
  return generateShortcutDirectory(shortcuts, guidelines);
9864
10395
  }
9865
10396
  /**
9866
- * Get the tbd section content for AGENTS.md (Codex integration).
9867
- * Loads from SKILL.md, strips frontmatter, and wraps in TBD INTEGRATION markers.
10397
+ * DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
10398
+ * Formatted to match flowmark output.
10399
+ */
10400
+ const SKILL_DO_NOT_EDIT_MARKER = "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->";
10401
+ /**
10402
+ * Build the full generated SKILL.md payload: the bundled skill content with the
10403
+ * shortcut/guideline directory appended and a DO NOT EDIT marker after the
10404
+ * frontmatter. This is the single source for every skill surface (the portable
10405
+ * .agents/skills install and the .claude/skills mirror) so they stay identical.
9868
10406
  *
9869
- * @param quiet - If true, suppress auto-sync output (default: false)
10407
+ * @param quiet - If true, suppress auto-sync output.
9870
10408
  */
9871
- async function getCodexTbdSection(quiet = false) {
9872
- let content = stripFrontmatter(await loadSkillContent());
10409
+ async function buildSkillPayload(quiet = false) {
10410
+ let skillContent = await loadSkillContent();
9873
10411
  const directory = await getShortcutDirectory(quiet);
9874
- if (directory) content = content.trimEnd() + "\n\n" + directory + "\n";
9875
- return `<!-- BEGIN TBD INTEGRATION -->\n${content}<!-- END TBD INTEGRATION -->\n`;
10412
+ if (directory) skillContent = skillContent.trimEnd() + "\n\n" + directory;
10413
+ skillContent = insertAfterFrontmatter(skillContent, SKILL_DO_NOT_EDIT_MARKER);
10414
+ return skillContent.trimEnd() + "\n";
10415
+ }
10416
+ /**
10417
+ * Write a generated SKILL.md payload to a target path, creating parent dirs.
10418
+ */
10419
+ async function writeSkillFile(targetPath, payload) {
10420
+ await mkdir(dirname(targetPath), { recursive: true });
10421
+ await writeFile(targetPath, payload);
10422
+ }
10423
+ /**
10424
+ * AGENTS.md managed-block markers. `CODEX_BEGIN_MARKER` is the stable PREFIX of
10425
+ * the begin line — the metadata (`format=fNN surface=agents-md`) follows it on the
10426
+ * same line, e.g. `<!-- BEGIN TBD INTEGRATION format=f02 surface=agents-md -->`.
10427
+ * Cleanup/upgrade code matches on this prefix so it finds both legacy
10428
+ * (`<!-- BEGIN TBD INTEGRATION -->`) and current marked blocks.
10429
+ */
10430
+ const CODEX_BEGIN_MARKER = "<!-- BEGIN TBD INTEGRATION";
10431
+ const CODEX_END_MARKER = "<!-- END TBD INTEGRATION -->";
10432
+ /** The full begin marker line for newly generated blocks. */
10433
+ const CODEX_BEGIN_LINE = `${CODEX_BEGIN_MARKER} format=${AGENT_INTEGRATION_FORMAT} surface=agents-md -->`;
10434
+ /** Numeric value of an `fNN` format string, for ordering comparisons. */
10435
+ function formatToNumber(format) {
10436
+ return Number.parseInt(format.replace(/^f/, ""), 10);
10437
+ }
10438
+ /**
10439
+ * Read the integration format (`fNN`) stamped in a generated artifact's begin
10440
+ * marker. Returns `f01` for a legacy marked block with no `format=` field, or
10441
+ * null when there is no tbd-managed block at all.
10442
+ */
10443
+ function parseIntegrationFormat(content) {
10444
+ const match = /format=f(\d+)/.exec(content);
10445
+ if (match?.[1]) return `f${match[1]}`;
10446
+ return content.includes(CODEX_BEGIN_MARKER) ? "f01" : null;
10447
+ }
10448
+ /**
10449
+ * Forward-compatibility guard. If a generated artifact was written by a NEWER
10450
+ * tbd than this one understands, refuse to rewrite it and tell the user to
10451
+ * upgrade tbd — overwriting would downgrade a newer managed format. This is what
10452
+ * makes pinning safe on a team: an older tbd fails loudly instead of clobbering.
10453
+ */
10454
+ function assertNotNewerFormat(content, artifact) {
10455
+ const format = parseIntegrationFormat(content);
10456
+ if (format !== null && formatToNumber(format) > formatToNumber(AGENT_INTEGRATION_FORMAT)) throw new CLIError(`${artifact} was generated by a newer tbd (integration format ${format}; this tbd supports up to ${AGENT_INTEGRATION_FORMAT}).\nUpgrade tbd to manage it: npm install -g get-tbd@latest`);
10457
+ }
10458
+ /**
10459
+ * Get the tbd managed block for AGENTS.md.
10460
+ *
10461
+ * This is a COMPACT always-on bootstrap, not a copy of the full skill: it names
10462
+ * tbd, states the operating rule, and points to the CLI commands that provide
10463
+ * progressive disclosure (`tbd prime`, `tbd skill`, `tbd shortcut/guidelines
10464
+ * --list`). The full skill body lives in the SKILL.md surfaces, not here, so the
10465
+ * block stays well under the AGENTS.md prompt-budget guidance.
10466
+ */
10467
+ function getCodexTbdSection() {
10468
+ return `${CODEX_BEGIN_LINE}\n## tbd
10469
+
10470
+ This repository uses **tbd** for git-native issue tracking (beads), spec-driven
10471
+ planning, and on-demand engineering guidelines.
10472
+ As the agent, you operate tbd on the user’s behalf — translate their requests into tbd
10473
+ actions rather than telling them to run commands.
10474
+
10475
+ - Run \`tbd prime\` to load current project state and the full tbd workflow.
10476
+ - Run \`tbd skill\` for the complete reusable tbd skill instructions.
10477
+ - Run \`tbd shortcut --list\` and \`tbd guidelines --list\` for on-demand resources.
10478
+ - Track all work as beads: \`tbd create\`, \`tbd ready\`, \`tbd close\`, and \`tbd sync\`.
10479
+ \n${CODEX_END_MARKER}\n`;
9876
10480
  }
9877
10481
  /**
9878
10482
  * Script to ensure tbd CLI is installed and run tbd prime.
@@ -9884,82 +10488,32 @@ async function getCodexTbdSection(quiet = false) {
9884
10488
  * tbd-session.sh --brief # Ensure tbd + run tbd prime --brief (for PreCompact)
9885
10489
  */
9886
10490
  const TBD_SESSION_SCRIPT = `#!/bin/bash
9887
- # Ensure tbd CLI is installed and run tbd prime for Claude Code sessions
9888
- # Installed by: tbd setup --auto
9889
- # This script runs on SessionStart and PreCompact
9890
-
9891
- # Get npm global bin directory (if npm is available)
9892
- NPM_GLOBAL_BIN=""
9893
- if command -v npm &> /dev/null; then
9894
- NPM_PREFIX=$(npm config get prefix 2>/dev/null)
9895
- if [ -n "$NPM_PREFIX" ] && [ -d "$NPM_PREFIX/bin" ]; then
9896
- NPM_GLOBAL_BIN="$NPM_PREFIX/bin"
9897
- fi
9898
- fi
9899
-
9900
- # Add common binary locations to PATH (persists for entire script)
9901
- # Include npm global bin if found
9902
- export PATH="$NPM_GLOBAL_BIN:$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH"
9903
-
9904
- # Function to ensure tbd is available
9905
- ensure_tbd() {
9906
- # Check if tbd is already installed
9907
- if command -v tbd &> /dev/null; then
9908
- return 0
9909
- fi
9910
-
9911
- echo "[tbd] CLI not found, installing..."
10491
+ # Ensure the tbd CLI is available and run \`tbd prime\`.
10492
+ # Installed by: tbd setup --auto. Runs on SessionStart and PreCompact.
10493
+ #
10494
+ # Local-first, then a VERSION-PINNED zero-install fallback. Pinning is both a
10495
+ # supply-chain control (an unpinned runner re-resolves to latest on every run
10496
+ # and bypasses any cool-off) and a consistency control (every teammate and agent
10497
+ # runs the same tbd version).
9912
10498
 
9913
- # Try npm first (most common for Node.js tools)
9914
- if command -v npm &> /dev/null; then
9915
- echo "[tbd] Installing via npm..."
9916
- npm install -g get-tbd 2>/dev/null || {
9917
- # If global install fails (permissions), try local install
9918
- echo "[tbd] Global npm install failed, trying user install..."
9919
- mkdir -p ~/.local/bin
9920
- npm install --prefix ~/.local get-tbd
9921
- # Create symlink if needed
9922
- if [ -f ~/.local/node_modules/.bin/tbd ]; then
9923
- ln -sf ~/.local/node_modules/.bin/tbd ~/.local/bin/tbd
9924
- fi
9925
- }
9926
- elif command -v pnpm &> /dev/null; then
9927
- echo "[tbd] Installing via pnpm..."
9928
- pnpm add -g get-tbd
9929
- elif command -v yarn &> /dev/null; then
9930
- echo "[tbd] Installing via yarn..."
9931
- yarn global add get-tbd
9932
- else
9933
- echo "[tbd] ERROR: No package manager found (npm, pnpm, or yarn required)"
9934
- echo "[tbd] Please install Node.js and npm, then run: npm install -g get-tbd"
9935
- return 1
9936
- fi
10499
+ # Prefer common local bin locations.
10500
+ export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH"
9937
10501
 
9938
- # Verify installation
9939
- if command -v tbd &> /dev/null; then
9940
- echo "[tbd] Successfully installed to $(which tbd)"
9941
- return 0
9942
- else
9943
- echo "[tbd] WARNING: tbd installed but not found in PATH"
9944
- echo "[tbd] Checking common locations..."
9945
- # Try to find and add to path (include npm global bin)
9946
- for dir in "$NPM_GLOBAL_BIN" ~/.local/bin ~/.local/node_modules/.bin /usr/local/bin; do
9947
- if [ -n "$dir" ] && [ -x "$dir/tbd" ]; then
9948
- export PATH="$dir:$PATH"
9949
- echo "[tbd] Found at $dir/tbd"
9950
- return 0
9951
- fi
9952
- done
9953
- echo "[tbd] Could not locate tbd after installation"
9954
- return 1
9955
- fi
9956
- }
10502
+ # Local-first: use tbd if it is already on PATH.
10503
+ if command -v tbd &> /dev/null; then
10504
+ tbd prime "$@"
10505
+ exit $?
10506
+ fi
9957
10507
 
9958
- # Main
9959
- ensure_tbd || exit 1
10508
+ # Pinned zero-install fallback. Never use an unpinned runner here.
10509
+ if command -v npx &> /dev/null; then
10510
+ npx --yes get-tbd@${VERSION} prime "$@"
10511
+ exit $?
10512
+ fi
9960
10513
 
9961
- # Run tbd prime with any passed arguments (e.g., --brief for PreCompact)
9962
- tbd prime "$@"
10514
+ echo "[tbd] tbd CLI not found and npx is unavailable."
10515
+ echo "[tbd] Install it with: npm install -g get-tbd@${VERSION}"
10516
+ exit 1
9963
10517
  `;
9964
10518
  /**
9965
10519
  * Claude Code session hooks configuration.
@@ -10049,22 +10603,14 @@ async function loadBundledScript(name) {
10049
10603
  throw new Error(`Bundled script not found: ${name}`);
10050
10604
  }
10051
10605
  /**
10052
- * AGENTS.md integration markers for Codex/Factory.ai
10053
- * Content is now generated dynamically from SKILL.md via getCodexTbdSection()
10054
- */
10055
- const CODEX_BEGIN_MARKER = "<!-- BEGIN TBD INTEGRATION -->";
10056
- const CODEX_END_MARKER = "<!-- END TBD INTEGRATION -->";
10057
- /**
10058
10606
  * Generate a new AGENTS.md file with tbd integration.
10059
- *
10060
- * @param quiet - If true, suppress auto-sync output (default: false)
10061
10607
  */
10062
- async function getCodexNewAgentsFile(quiet = false) {
10608
+ function getCodexNewAgentsFile() {
10063
10609
  return `# Project Instructions for AI Agents
10064
10610
 
10065
10611
  This file provides instructions and context for AI coding agents working on this project.
10066
10612
 
10067
- ${await getCodexTbdSection(quiet)}
10613
+ ${getCodexTbdSection()}
10068
10614
  ## Build & Test
10069
10615
 
10070
10616
  _Add your build and test commands here_
@@ -10085,6 +10631,54 @@ _Add your project-specific conventions here_
10085
10631
  `;
10086
10632
  }
10087
10633
  /**
10634
+ * Codex project-local script paths (relative to repo root). Codex hooks
10635
+ * reference ONLY these `.codex/` paths, never `.claude/`, so Codex setup stays
10636
+ * independent of Claude Code setup.
10637
+ */
10638
+ const CODEX_SESSION_SCRIPT_REL = ".codex/tbd-session.sh";
10639
+ const CODEX_CLOSING_REMINDER_REL = ".codex/tbd-closing-reminder.sh";
10640
+ const CODEX_GH_CLI_SCRIPT_REL = ".codex/ensure-gh-cli.sh";
10641
+ /**
10642
+ * Build the Codex hooks.json content. Codex uses the same lifecycle event
10643
+ * schema as Claude Code (command handlers), so tbd's hooks map almost 1:1:
10644
+ * SessionStart and PreCompact run `tbd prime`, PostToolUse reminds about sync
10645
+ * after `git push`, and (when enabled) a second SessionStart entry ensures gh.
10646
+ */
10647
+ function getCodexHooksConfig(useGhCli) {
10648
+ const sessionStart = [{
10649
+ matcher: "",
10650
+ hooks: [{
10651
+ type: "command",
10652
+ command: `bash ${CODEX_SESSION_SCRIPT_REL}`
10653
+ }]
10654
+ }];
10655
+ if (useGhCli) sessionStart.push({
10656
+ matcher: "",
10657
+ hooks: [{
10658
+ type: "command",
10659
+ command: `bash ${CODEX_GH_CLI_SCRIPT_REL}`,
10660
+ timeout: 120
10661
+ }]
10662
+ });
10663
+ return { hooks: {
10664
+ SessionStart: sessionStart,
10665
+ PreCompact: [{
10666
+ matcher: "",
10667
+ hooks: [{
10668
+ type: "command",
10669
+ command: `bash ${CODEX_SESSION_SCRIPT_REL} --brief`
10670
+ }]
10671
+ }],
10672
+ PostToolUse: [{
10673
+ matcher: "Bash",
10674
+ hooks: [{
10675
+ type: "command",
10676
+ command: `bash ${CODEX_CLOSING_REMINDER_REL}`
10677
+ }]
10678
+ }]
10679
+ } };
10680
+ }
10681
+ /**
10088
10682
  * Legacy script patterns to clean up from .claude/scripts/
10089
10683
  * These were used in older versions of tbd before hooks moved to `tbd prime`
10090
10684
  */
@@ -10345,13 +10939,7 @@ var SetupClaudeHandler = class extends BaseCommand {
10345
10939
  await writeFile(claudePaths.closingReminder, TBD_CLOSE_PROTOCOL_SCRIPT);
10346
10940
  await chmod(claudePaths.closingReminder, 493);
10347
10941
  this.output.success("Installed sync reminder hook script");
10348
- await mkdir(dirname(skillPath), { recursive: true });
10349
- let skillContent = await loadSkillContent();
10350
- const directory = await getShortcutDirectory(this.ctx.quiet);
10351
- if (directory) skillContent = skillContent.trimEnd() + "\n\n" + directory;
10352
- skillContent = insertAfterFrontmatter(skillContent, "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->");
10353
- skillContent = skillContent.trimEnd() + "\n";
10354
- await writeFile(skillPath, skillContent);
10942
+ await writeSkillFile(skillPath, await buildSkillPayload(this.ctx.quiet));
10355
10943
  this.output.success("Installed skill file");
10356
10944
  this.output.info(` ${skillPath}`);
10357
10945
  this.output.info("");
@@ -10384,16 +10972,93 @@ var SetupCodexHandler = class extends BaseCommand {
10384
10972
  this.projectDir = dir;
10385
10973
  }
10386
10974
  async run(options) {
10387
- const agentsPath = join(this.projectDir ?? process.cwd(), "AGENTS.md");
10975
+ const cwd = this.projectDir ?? process.cwd();
10976
+ const agentsPath = join(cwd, "AGENTS.md");
10388
10977
  if (options.check) {
10389
10978
  await this.checkCodexSetup(agentsPath);
10390
10979
  return;
10391
10980
  }
10392
10981
  if (options.remove) {
10393
10982
  await this.removeCodexSection(agentsPath);
10983
+ await this.removeCodexHooks(cwd);
10394
10984
  return;
10395
10985
  }
10396
10986
  await this.installCodexSection(agentsPath);
10987
+ await this.installCodexHooks(cwd);
10988
+ }
10989
+ /**
10990
+ * Read the use_gh_cli setting; defaults to true (so fresh setup installs it).
10991
+ */
10992
+ async getUseGhCliSetting(cwd) {
10993
+ try {
10994
+ const tbdRoot = await findTbdRoot(cwd);
10995
+ if (!tbdRoot) return true;
10996
+ return (await readConfig(tbdRoot)).settings.use_gh_cli ?? true;
10997
+ } catch {
10998
+ return true;
10999
+ }
11000
+ }
11001
+ /**
11002
+ * Install Codex lifecycle hooks: writes .codex/ scripts and a .codex/hooks.json
11003
+ * (merged idempotently with any user hooks). Scripts reuse the same bodies as
11004
+ * the Claude install but live under .codex/ so Codex never references .claude/.
11005
+ */
11006
+ async installCodexHooks(cwd) {
11007
+ if (this.checkDryRun("Would install Codex hooks", { path: "./.codex/hooks.json" })) return;
11008
+ const codexPaths = getCodexPaths(cwd);
11009
+ await mkdir(codexPaths.dir, { recursive: true });
11010
+ const useGhCli = await this.getUseGhCliSetting(cwd);
11011
+ await writeFile(join(cwd, CODEX_SESSION_SCRIPT_REL), TBD_SESSION_SCRIPT);
11012
+ await chmod(join(cwd, CODEX_SESSION_SCRIPT_REL), 493);
11013
+ await writeFile(join(cwd, CODEX_CLOSING_REMINDER_REL), TBD_CLOSE_PROTOCOL_SCRIPT);
11014
+ await chmod(join(cwd, CODEX_CLOSING_REMINDER_REL), 493);
11015
+ if (useGhCli) {
11016
+ const ghScriptContent = await loadBundledScript("ensure-gh-cli.sh");
11017
+ await writeFile(join(cwd, CODEX_GH_CLI_SCRIPT_REL), ghScriptContent);
11018
+ await chmod(join(cwd, CODEX_GH_CLI_SCRIPT_REL), 493);
11019
+ } else await rm(join(cwd, CODEX_GH_CLI_SCRIPT_REL), { force: true });
11020
+ let existing = {};
11021
+ try {
11022
+ existing = JSON.parse(await readFile(codexPaths.hooks, "utf-8"));
11023
+ } catch {}
11024
+ const isTbdOwned = (entry) => {
11025
+ return (entry.hooks ?? []).some((h) => h.command?.includes(".codex/"));
11026
+ };
11027
+ const merged = { ...existing.hooks ?? {} };
11028
+ const tbdHooks = getCodexHooksConfig(useGhCli).hooks;
11029
+ for (const [event, entries] of Object.entries(tbdHooks)) merged[event] = [...(merged[event] ?? []).filter((e) => !isTbdOwned(e)), ...entries];
11030
+ await writeFile(codexPaths.hooks, JSON.stringify({
11031
+ ...existing,
11032
+ hooks: merged
11033
+ }, null, 2) + "\n");
11034
+ this.output.success("Installed Codex hooks (.codex/hooks.json)");
11035
+ }
11036
+ /**
11037
+ * Remove tbd-owned Codex hook entries and scripts, preserving user hooks.
11038
+ */
11039
+ async removeCodexHooks(cwd) {
11040
+ const codexPaths = getCodexPaths(cwd);
11041
+ try {
11042
+ const existing = JSON.parse(await readFile(codexPaths.hooks, "utf-8"));
11043
+ const hooks = existing.hooks ?? {};
11044
+ for (const event of Object.keys(hooks)) {
11045
+ const kept = (hooks[event] ?? []).filter((entry) => {
11046
+ return !(entry.hooks ?? []).some((h) => h.command?.includes(".codex/"));
11047
+ });
11048
+ if (kept.length === 0) delete hooks[event];
11049
+ else hooks[event] = kept;
11050
+ }
11051
+ if (Object.keys(hooks).length === 0) await rm(codexPaths.hooks, { force: true });
11052
+ else await writeFile(codexPaths.hooks, JSON.stringify({
11053
+ ...existing,
11054
+ hooks
11055
+ }, null, 2) + "\n");
11056
+ } catch {}
11057
+ for (const rel of [
11058
+ CODEX_SESSION_SCRIPT_REL,
11059
+ CODEX_CLOSING_REMINDER_REL,
11060
+ CODEX_GH_CLI_SCRIPT_REL
11061
+ ]) await rm(join(cwd, rel), { force: true });
10397
11062
  }
10398
11063
  async checkCodexSetup(agentsPath) {
10399
11064
  const agentsRelPath = "./AGENTS.md";
@@ -10478,8 +11143,9 @@ var SetupCodexHandler = class extends BaseCommand {
10478
11143
  existingContent = await readFile(agentsPath, "utf-8");
10479
11144
  } catch {}
10480
11145
  let newContent;
10481
- const tbdSection = await getCodexTbdSection(this.ctx.quiet);
11146
+ const tbdSection = getCodexTbdSection();
10482
11147
  if (existingContent) if (existingContent.includes(CODEX_BEGIN_MARKER)) {
11148
+ assertNotNewerFormat(existingContent, "AGENTS.md");
10483
11149
  newContent = this.updatetbdSection(existingContent, tbdSection);
10484
11150
  await writeFile(agentsPath, newContent);
10485
11151
  this.output.success("Updated existing tbd section in AGENTS.md");
@@ -10489,7 +11155,7 @@ var SetupCodexHandler = class extends BaseCommand {
10489
11155
  this.output.success("Added tbd section to existing AGENTS.md");
10490
11156
  }
10491
11157
  else {
10492
- await writeFile(agentsPath, await getCodexNewAgentsFile(this.ctx.quiet));
11158
+ await writeFile(agentsPath, getCodexNewAgentsFile());
10493
11159
  this.output.success("Created new AGENTS.md with tbd integration");
10494
11160
  }
10495
11161
  this.output.info(` File: ${agentsPath}`);
@@ -10552,25 +11218,26 @@ var SetupDefaultHandler = class extends BaseCommand {
10552
11218
  if (!gitRoot) throw new CLIError("Could not determine git repository root.");
10553
11219
  const projectDir = gitRoot;
10554
11220
  const hasTbd = await isInitialized(projectDir);
10555
- const hasBeads = await pathExists(join(projectDir, ".beads"));
11221
+ const hasBeads = await pathExists$1(join(projectDir, ".beads"));
10556
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>");
10557
11223
  console.log("Checking repository...");
10558
11224
  console.log(` ${colors.success("✓")} Git repository detected`);
10559
11225
  if (hasTbd) {
10560
11226
  const { config, migrated, changes } = await readConfigWithMigration(projectDir);
10561
11227
  console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
10562
- let needsConfigWrite = migrated;
11228
+ let ghCliChanged = false;
10563
11229
  if (options.ghCli === false && config.settings.use_gh_cli !== false) {
10564
11230
  config.settings.use_gh_cli = false;
10565
- needsConfigWrite = true;
11231
+ ghCliChanged = true;
11232
+ }
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)}`);
10566
11237
  }
10567
- if (needsConfigWrite) {
11238
+ if (ghCliChanged) {
10568
11239
  await writeConfig(projectDir, config);
10569
- if (migrated) {
10570
- console.log(` ${colors.success("✓")} Config migrated to latest format`);
10571
- for (const change of changes) console.log(` ${colors.dim(change)}`);
10572
- }
10573
- if (options.ghCli === false) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
11240
+ console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
10574
11241
  }
10575
11242
  console.log("");
10576
11243
  await this.handleAlreadyInitialized(projectDir, isAutoMode);
@@ -10718,7 +11385,7 @@ Example:
10718
11385
  }
10719
11386
  async initializeTbd(cwd, prefix) {
10720
11387
  const colors = this.output.getColors();
10721
- await initConfig(cwd, VERSION, prefix);
11388
+ const config = await initConfig(cwd, VERSION, prefix);
10722
11389
  console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
10723
11390
  const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
10724
11391
  "# Synced documentation cache (regenerated by tbd sync --docs)",
@@ -10749,7 +11416,10 @@ Example:
10749
11416
  if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
10750
11417
  else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
10751
11418
  try {
10752
- await initWorktree(cwd);
11419
+ await withSharedDataSyncLock(cwd, async () => {
11420
+ await initWorktree(cwd);
11421
+ await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
11422
+ });
10753
11423
  const health = await checkWorktreeHealth(cwd);
10754
11424
  if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
10755
11425
  else {
@@ -10859,9 +11529,11 @@ var SetupAutoHandler = class extends BaseCommand {
10859
11529
  console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
10860
11530
  }
10861
11531
  await this.syncDocs(cwd);
10862
- const claudeResult = await this.setupClaudeIfDetected(cwd);
11532
+ const targeting = this.resolveTargeting();
11533
+ await this.installPortableSkill(cwd);
11534
+ const claudeResult = await this.setupClaudeIfDetected(cwd, targeting.claude);
10863
11535
  results.push(claudeResult);
10864
- const codexResult = await this.setupCodexIfDetected(cwd);
11536
+ const codexResult = await this.setupCodexIfDetected(cwd, targeting.codex);
10865
11537
  results.push(codexResult);
10866
11538
  const installed = results.filter((r) => r.installed && !r.alreadyInstalled);
10867
11539
  const alreadyInstalled = results.filter((r) => r.alreadyInstalled);
@@ -10904,24 +11576,59 @@ var SetupAutoHandler = class extends BaseCommand {
10904
11576
  if (result.pruned.length > 0) console.log(colors.dim(`Pruned ${result.pruned.length} stale config entry/entries`));
10905
11577
  if (result.errors.length > 0) for (const { path, error } of result.errors) console.log(colors.warn(`Warning: ${path}: ${error}`));
10906
11578
  }
10907
- async setupClaudeIfDetected(cwd) {
11579
+ /**
11580
+ * Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
11581
+ * Runs for every initialized repo, independent of agent detection, so the
11582
+ * skill is portable across Codex, Gemini CLI, Cursor, and other clients.
11583
+ */
11584
+ async installPortableSkill(cwd) {
11585
+ const colors = this.output.getColors();
11586
+ if (this.checkDryRun("Would install portable Agent Skill", { path: AGENTS_SKILL_DISPLAY })) return;
11587
+ const { portable } = getAgentSkillPaths(cwd);
11588
+ await writeSkillFile(portable, await buildSkillPayload(this.ctx.quiet));
11589
+ console.log(` ${colors.success("✓")} Portable Agent Skill (${AGENTS_SKILL_DISPLAY})`);
11590
+ }
11591
+ /**
11592
+ * Resolve which agent surfaces to install from the explicit targeting flags.
11593
+ * `--all`/`--claude`/`--codex` force surfaces on (and suppress auto-detection
11594
+ * of untargeted surfaces); `--skip-claude`/`--skip-codex` force them off; with
11595
+ * no targeting flag each surface falls back to detection-based auto behavior.
11596
+ */
11597
+ resolveTargeting() {
11598
+ const opts = this.cmd.optsWithGlobals();
11599
+ const all = opts.all === true;
11600
+ const anyPositive = all || opts.claude === true || opts.codex === true;
11601
+ const resolve = (on, skip) => {
11602
+ if (skip === true) return "off";
11603
+ if (on === true || all) return "on";
11604
+ return anyPositive ? "off" : "auto";
11605
+ };
11606
+ return {
11607
+ claude: resolve(opts.claude, opts.skipClaude),
11608
+ codex: resolve(opts.codex, opts.skipCodex)
11609
+ };
11610
+ }
11611
+ async setupClaudeIfDetected(cwd, mode) {
10908
11612
  const result = {
10909
11613
  name: "Claude Code",
10910
11614
  detected: false,
10911
11615
  installed: false,
10912
11616
  alreadyInstalled: false
10913
11617
  };
10914
- const hasClaudeDir = await pathExists(GLOBAL_CLAUDE_DIR);
10915
- const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
10916
- if (!hasClaudeDir && !hasClaudeEnv) return result;
11618
+ if (mode === "off") return result;
11619
+ if (mode === "auto") {
11620
+ const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
11621
+ const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
11622
+ if (!hasClaudeDir && !hasClaudeEnv) return result;
11623
+ }
10917
11624
  result.detected = true;
10918
11625
  const claudePaths = getClaudePaths(cwd);
10919
11626
  try {
10920
- if (await pathExists(claudePaths.settings)) {
11627
+ if (await pathExists$1(claudePaths.settings)) {
10921
11628
  const content = await readFile(claudePaths.settings, "utf-8");
10922
11629
  const hooks = JSON.parse(content).hooks;
10923
11630
  if (hooks) {
10924
- 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;
10925
11632
  }
10926
11633
  }
10927
11634
  const handler = new SetupClaudeHandler(this.cmd);
@@ -10933,17 +11640,20 @@ var SetupAutoHandler = class extends BaseCommand {
10933
11640
  }
10934
11641
  return result;
10935
11642
  }
10936
- async setupCodexIfDetected(cwd) {
11643
+ async setupCodexIfDetected(cwd, mode) {
10937
11644
  const result = {
10938
11645
  name: "Codex/AGENTS.md",
10939
11646
  detected: false,
10940
11647
  installed: false,
10941
11648
  alreadyInstalled: false
10942
11649
  };
11650
+ if (mode === "off") return result;
10943
11651
  const agentsPath = getAgentsMdPath(cwd);
10944
- const hasAgentsMd = await pathExists(agentsPath);
10945
- const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
10946
- if (!hasAgentsMd && !hasCodexEnv) return result;
11652
+ const hasAgentsMd = await pathExists$1(agentsPath);
11653
+ if (mode === "auto") {
11654
+ const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
11655
+ if (!hasAgentsMd && !hasCodexEnv) return result;
11656
+ }
10947
11657
  result.detected = true;
10948
11658
  if (hasAgentsMd) {
10949
11659
  if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) result.alreadyInstalled = true;
@@ -10954,12 +11664,13 @@ var SetupAutoHandler = class extends BaseCommand {
10954
11664
  await handler.run({});
10955
11665
  result.installed = true;
10956
11666
  } catch (error) {
11667
+ if (error instanceof CLIError) throw error;
10957
11668
  result.error = error.message;
10958
11669
  }
10959
11670
  return result;
10960
11671
  }
10961
11672
  };
10962
- const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").action(async (options, command) => {
11673
+ const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--all", "Install every supported agent surface (Claude + Codex)").option("--claude", "Install the Claude Code surface (skill mirror + hooks)").option("--codex", "Install the Codex surface (AGENTS.md block + .codex hooks)").option("--skip-claude", "Skip the Claude Code surface even if detected").option("--skip-codex", "Skip the Codex surface even if detected").action(async (options, command) => {
10963
11674
  if (options.auto || options.interactive) {
10964
11675
  await new SetupDefaultHandler(command).run(options);
10965
11676
  return;
@@ -11008,7 +11719,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
11008
11719
  var SaveHandler = class extends BaseCommand {
11009
11720
  async run(options) {
11010
11721
  const tbdRoot = await requireInit();
11011
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
11012
11722
  if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
11013
11723
  const saveOptions = {
11014
11724
  workspace: options.workspace,
@@ -11020,7 +11730,9 @@ var SaveHandler = class extends BaseCommand {
11020
11730
  const spinner = this.output.spinner("Saving issues...");
11021
11731
  saveOptions.logger = this.output.logger(spinner);
11022
11732
  const result = await this.execute(async () => {
11023
- return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
11733
+ return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
11734
+ return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
11735
+ });
11024
11736
  }, "Failed to save issues");
11025
11737
  spinner.stop();
11026
11738
  if (!result) return;