get-tbd 0.1.30 → 0.2.1

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 +3193 -2220
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +1545 -821
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-DVap9omo.mjs → config-BJz1m9eN.mjs} +179 -39
  7. package/dist/config-BJz1m9eN.mjs.map +1 -0
  8. package/dist/{config-BPHcePSm.mjs → config-DlCUMyCG.mjs} +1 -1
  9. package/dist/docs/README.md +5 -1
  10. package/dist/docs/SKILL.md +2 -2
  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 +120 -34
  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/shortcuts/standard/agent-handoff.md +4 -0
  39. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
  40. package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
  41. package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
  42. package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
  43. package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
  44. package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
  45. package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
  46. package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
  47. package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
  48. package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
  49. package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
  50. package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
  51. package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
  52. package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
  53. package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
  54. package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
  55. package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
  56. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
  57. package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
  58. package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
  59. package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
  60. package/dist/docs/shortcuts/standard/review-code.md +4 -0
  61. package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
  62. package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
  63. package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
  64. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
  65. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
  66. package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
  67. package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
  68. package/dist/docs/shortcuts/system/skill-baseline.md +2 -2
  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-CqrrLgeX.mjs → id-mapping-687_UEsy.mjs} +198 -124
  78. package/dist/id-mapping-687_UEsy.mjs.map +1 -0
  79. package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-mtoSP9Qt.mjs} +1 -1
  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-BK_EF6mk.mjs → src-CtZIHxYM.mjs} +3 -3
  85. package/dist/src-CtZIHxYM.mjs.map +1 -0
  86. package/dist/tbd +3193 -2220
  87. package/package.json +1 -1
  88. package/dist/config-DVap9omo.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-BK_EF6mk.mjs.map +0 -1
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { S as IssueTitle, b as IssueSchema, g as ISSUE_TITLE_MAX_LENGTH, n as AtticEntrySchema, t as ATTIC_ENTRY_FIELD_ORDER, x as IssueStatus, y as IssueKind } from "./schemas-C8mOQykE.mjs";
2
- import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-BK_EF6mk.mjs";
1
+ import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
2
+ import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-CtZIHxYM.mjs";
3
3
  import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
4
- import { A as getWorkspaceDir, C as TBD_GUIDELINES_DIR, D as WORKSPACES_DIR, E as TBD_TEMPLATES_DIR, M as resolveAtticDir, N as resolveDataSyncDir, O as WORKTREE_DIR, S as TBD_DOCS_DIR, T as TBD_SHORTCUTS_SYSTEM, _ as DEFAULT_GUIDELINES_PATHS, a as isInitialized, b as SYNC_BRANCH, c as readConfigWithMigration, d as writeConfig, g as DATA_SYNC_DIR_NAME, h as DATA_SYNC_DIR, i as initConfig, j as isValidWorkspaceName, k as WORKTREE_DIR_NAME, l as readLocalState, m as CHARS_PER_TOKEN, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, u as updateLocalState, v as DEFAULT_SHORTCUT_PATHS, w as TBD_SHORTCUTS_STANDARD, x as TBD_DIR, y as DEFAULT_TEMPLATE_PATHS } from "./config-DVap9omo.mjs";
5
- import { _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-CqrrLgeX.mjs";
4
+ import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
5
+ import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-687_UEsy.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,25 +620,15 @@ 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
623
+ * Unrelated-history error - the local and remote tbd-sync share no common
624
+ * ancestor, so a push can never fast-forward and a plain git merge refuses.
625
+ * `tbd sync` cannot resolve this; the rescue lives in `tbd doctor --fix`.
626
+ * See: plan-2026-05-29-tbd-sync-unrelated-history-hardening.md
626
627
  */
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";
628
+ var UnrelatedHistoriesError = class extends SyncError {
629
+ constructor(remote = "origin", syncBranch = "tbd-sync") {
630
+ super(`${remote}/${syncBranch} has an unrelated history (no common ancestor) — push cannot fast-forward and a merge would refuse.\nRun \`tbd doctor --fix\` to reconcile the unrelated histories (non-destructive; a backup branch is created first).`);
631
+ this.name = "UnrelatedHistoriesError";
642
632
  }
643
633
  };
644
634
  /**
@@ -706,10 +696,7 @@ var BaseCommand = class {
706
696
  try {
707
697
  return await action();
708
698
  } catch (error) {
709
- if (error instanceof CLIError) {
710
- this.output.error(error.message);
711
- throw error;
712
- }
699
+ if (error instanceof CLIError) throw error;
713
700
  const originalError = error instanceof Error ? error : void 0;
714
701
  const detail = originalError?.message;
715
702
  const fullMessage = detail && detail !== errorMessage ? `${errorMessage}: ${detail}` : errorMessage;
@@ -740,7 +727,7 @@ var BaseCommand = class {
740
727
  /**
741
728
  * Check if a path exists on the filesystem.
742
729
  */
743
- async function pathExists(path) {
730
+ async function pathExists$1(path) {
744
731
  try {
745
732
  await access(path);
746
733
  return true;
@@ -780,7 +767,7 @@ function hasGitignorePattern(content, pattern) {
780
767
  async function ensureGitignorePatterns(gitignorePath, patterns, header) {
781
768
  let content = "";
782
769
  let created = false;
783
- if (await pathExists(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
770
+ if (await pathExists$1(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
784
771
  else created = true;
785
772
  const entries = [];
786
773
  let currentPreamble = [];
@@ -942,6 +929,124 @@ function nowFilenameTimestamp() {
942
929
  return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
943
930
  }
944
931
 
932
+ //#endregion
933
+ //#region src/utils/zod-error-utils.ts
934
+ /**
935
+ * Helpers for rendering Zod errors without relying on object inspection.
936
+ */
937
+ /**
938
+ * Format a ZodError as concise path-qualified messages for CLI output.
939
+ */
940
+ function formatZodError(error) {
941
+ const messages = error.issues.map((issue) => {
942
+ return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
943
+ });
944
+ return messages.length > 0 ? messages.join("; ") : error.message;
945
+ }
946
+ /**
947
+ * Format unknown thrown values as safe strings for warnings and diagnostics.
948
+ */
949
+ function formatUnknownError(error) {
950
+ if (error instanceof ZodError) return formatZodError(error);
951
+ if (error instanceof Error) return error.message;
952
+ return String(error);
953
+ }
954
+
955
+ //#endregion
956
+ //#region src/file/storage.ts
957
+ /**
958
+ * Storage layer for issue files.
959
+ *
960
+ * Provides atomic file operations and issue CRUD operations.
961
+ * All operations work on the data-sync directory selected by the caller. In production
962
+ * that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
963
+ *
964
+ * See: tbd-design.md §3.2 Storage Layer
965
+ */
966
+ /**
967
+ * Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
968
+ */
969
+ const ISSUE_READ_BATCH_SIZE = 200;
970
+ /**
971
+ * Get the path to an issue file.
972
+ */
973
+ function getIssuePath(baseDir, id) {
974
+ return join(baseDir, "issues", `${id}.md`);
975
+ }
976
+ /**
977
+ * Read an issue from the worktree.
978
+ * @throws If the issue file doesn't exist or is invalid.
979
+ */
980
+ async function readIssue(baseDir, id) {
981
+ return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
982
+ }
983
+ /**
984
+ * Write an issue to the worktree.
985
+ * Uses atomic write to prevent corruption.
986
+ */
987
+ async function writeIssue(baseDir, issue) {
988
+ const validIssue = IssueSchema.parse(issue);
989
+ await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
990
+ }
991
+ /**
992
+ * List all issues in the worktree.
993
+ * Returns empty array if issues directory doesn't exist.
994
+ *
995
+ * Uses parallel file reading for better performance with many issues.
996
+ */
997
+ async function listIssues(baseDir, options = {}) {
998
+ const warnOnInvalid = options.warnOnInvalid ?? true;
999
+ const issuesDir = join(baseDir, "issues");
1000
+ let files;
1001
+ try {
1002
+ files = await readdir(issuesDir);
1003
+ } catch {
1004
+ return [];
1005
+ }
1006
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
1007
+ const issues = [];
1008
+ for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
1009
+ const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
1010
+ const fileContents = await Promise.all(batch.map(async (file) => {
1011
+ const filePath = join(issuesDir, file);
1012
+ try {
1013
+ return {
1014
+ file,
1015
+ content: await readFile(filePath, "utf-8")
1016
+ };
1017
+ } catch (error) {
1018
+ return {
1019
+ file,
1020
+ error: formatUnknownError(error)
1021
+ };
1022
+ }
1023
+ }));
1024
+ for (const result of fileContents) {
1025
+ if ("error" in result) {
1026
+ reportInvalidIssueFile({
1027
+ file: result.file,
1028
+ reason: `failed to read file: ${result.error}`
1029
+ }, warnOnInvalid, options.onInvalidIssue);
1030
+ continue;
1031
+ }
1032
+ try {
1033
+ const issue = parseIssue(result.content);
1034
+ issues.push(issue);
1035
+ } catch (error) {
1036
+ reportInvalidIssueFile({
1037
+ file: result.file,
1038
+ reason: formatUnknownError(error)
1039
+ }, warnOnInvalid, options.onInvalidIssue);
1040
+ }
1041
+ }
1042
+ }
1043
+ return issues;
1044
+ }
1045
+ function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
1046
+ onInvalidIssue?.(invalidIssue);
1047
+ if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
1048
+ }
1049
+
945
1050
  //#endregion
946
1051
  //#region src/file/git.ts
947
1052
  /**
@@ -956,6 +1061,55 @@ function nowFilenameTimestamp() {
956
1061
  */
957
1062
  const execFileAsync$1 = promisify(execFile);
958
1063
  /**
1064
+ * Error thrown by {@link git} when a git command exits non-zero.
1065
+ *
1066
+ * Carries the process `exitCode` so callers can branch on git's exit status
1067
+ * (e.g. `ls-remote --exit-code` => 2 means "ref absent", `merge-base` => 1
1068
+ * means "no common ancestor") instead of string-matching stderr. The original
1069
+ * message/stderr is preserved so existing message-based classifiers
1070
+ * (classifySyncError) keep working.
1071
+ */
1072
+ var GitError = class GitError extends Error {
1073
+ /** Process exit code, or null for a spawn failure (e.g. git not found). */
1074
+ exitCode;
1075
+ stderr;
1076
+ stdout;
1077
+ args;
1078
+ constructor(message, opts) {
1079
+ super(message);
1080
+ this.name = "GitError";
1081
+ this.exitCode = opts.exitCode;
1082
+ this.stderr = opts.stderr;
1083
+ this.stdout = opts.stdout;
1084
+ this.args = opts.args;
1085
+ }
1086
+ /**
1087
+ * Wrap a raw execFile rejection into a GitError.
1088
+ *
1089
+ * Node's execFile rejection carries `code` (numeric exit code for a normal
1090
+ * exit, or a string like 'ENOENT' for a spawn failure), plus `stderr`/`stdout`.
1091
+ */
1092
+ static from(err, args) {
1093
+ const raw = err;
1094
+ const exitCode = typeof raw.code === "number" ? raw.code : null;
1095
+ const stderr = typeof raw.stderr === "string" ? raw.stderr : "";
1096
+ const stdout = typeof raw.stdout === "string" ? raw.stdout : "";
1097
+ return new GitError(raw.message ?? `git ${args.join(" ")} failed`, {
1098
+ exitCode,
1099
+ stderr,
1100
+ stdout,
1101
+ args
1102
+ });
1103
+ }
1104
+ };
1105
+ /**
1106
+ * Read the git exit code from a thrown value, or null if it is not a GitError
1107
+ * (or carries no numeric code, e.g. a spawn failure).
1108
+ */
1109
+ function exitCodeOf(err) {
1110
+ return err instanceof GitError ? err.exitCode : null;
1111
+ }
1112
+ /**
959
1113
  * Maximum buffer size for git command output.
960
1114
  *
961
1115
  * Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
@@ -970,8 +1124,43 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
970
1124
  * Uses execFile for security - prevents shell injection attacks.
971
1125
  */
972
1126
  async function git(...args) {
973
- const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
974
- return stdout.trim();
1127
+ try {
1128
+ const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
1129
+ return stdout.trim();
1130
+ } catch (err) {
1131
+ throw GitError.from(err, args);
1132
+ }
1133
+ }
1134
+ /**
1135
+ * Like {@link git} but with `GIT_TERMINAL_PROMPT=0` so a network operation
1136
+ * (e.g. a best-effort push) fails fast instead of blocking on a credential
1137
+ * prompt in a non-interactive environment.
1138
+ */
1139
+ async function gitNoPrompt(...args) {
1140
+ try {
1141
+ const { stdout } = await execFileAsync$1("git", args, {
1142
+ maxBuffer: GIT_MAX_BUFFER,
1143
+ env: {
1144
+ ...process.env,
1145
+ GIT_TERMINAL_PROMPT: "0"
1146
+ }
1147
+ });
1148
+ return stdout.trim();
1149
+ } catch (err) {
1150
+ throw GitError.from(err, args);
1151
+ }
1152
+ }
1153
+ /**
1154
+ * Run `git commit` in a worktree with gpg signing disabled at the command level.
1155
+ *
1156
+ * Internal tbd-sync commits are machine-generated data commits on the data branch,
1157
+ * not user commits. They must not depend on ambient `commit.gpgsign` config: in
1158
+ * signed-by-default environments without a usable signing key, an unguarded
1159
+ * `git commit` fails and leaves `tbd-sync` unborn, which the f04 fail-closed
1160
+ * health check then surfaces as "worktree corrupted" on every command.
1161
+ */
1162
+ async function gitCommit(workdir, ...args) {
1163
+ return git("-c", "commit.gpgsign=false", "-C", workdir, "commit", ...args);
975
1164
  }
976
1165
  /**
977
1166
  * Minimum Git version required.
@@ -1088,23 +1277,6 @@ function getUpgradeInstructions(currentVersion) {
1088
1277
  return `Git ${versionStr} detected. Git ${MIN_GIT_VERSION}+ required for tbd.\nUpgrade: ${upgradeUrl}`;
1089
1278
  }
1090
1279
  /**
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
1280
  * Field-level merge strategies for Issue fields.
1109
1281
  * See: tbd-design.md §3.5 Merge Rules
1110
1282
  */
@@ -1274,15 +1446,42 @@ function mergeIssues(base, local, remote) {
1274
1446
  };
1275
1447
  }
1276
1448
  /**
1449
+ * Categorize issues from two unrelated tbd-sync roots by id (which embeds the
1450
+ * globally unique ULID). Pure and git-free so the rescue is robust to the
1451
+ * missing merge base. Substantive equality ignores version/updated_at so
1452
+ * trivial bumps don't masquerade as conflicts.
1453
+ */
1454
+ function categorizeIssuesByUlid(local, remote) {
1455
+ const localById = new Map(local.map((i) => [i.id, i]));
1456
+ const remoteById = new Map(remote.map((i) => [i.id, i]));
1457
+ const buckets = {
1458
+ localOnly: [],
1459
+ remoteOnly: [],
1460
+ bothIdentical: [],
1461
+ bothDifferent: []
1462
+ };
1463
+ for (const [id, localIssue] of localById) {
1464
+ const remoteIssue = remoteById.get(id);
1465
+ if (!remoteIssue) buckets.localOnly.push(localIssue);
1466
+ else if (issuesSubstantivelyEqual(localIssue, remoteIssue)) buckets.bothIdentical.push(remoteIssue);
1467
+ else buckets.bothDifferent.push({
1468
+ local: localIssue,
1469
+ remote: remoteIssue
1470
+ });
1471
+ }
1472
+ for (const [id, remoteIssue] of remoteById) if (!localById.has(id)) buckets.remoteOnly.push(remoteIssue);
1473
+ return buckets;
1474
+ }
1475
+ /**
1277
1476
  * Maximum retry attempts for push operations.
1278
1477
  */
1279
1478
  const MAX_PUSH_RETRIES = 3;
1280
1479
  /**
1281
- * Check if error is a non-fast-forward rejection.
1480
+ * Check if error is a non-fast-forward rejection (also the init race signal).
1282
1481
  */
1283
1482
  function isNonFastForward(error) {
1284
- const msg = error instanceof Error ? error.message : String(error);
1285
- return msg.includes("non-fast-forward") || msg.includes("fetch first") || msg.includes("rejected");
1483
+ const msg = error instanceof GitError ? `${error.stderr}\n${error.message}` : error instanceof Error ? error.message : String(error);
1484
+ return /non-fast-forward|fetch first|rejected|updates were rejected/i.test(msg);
1286
1485
  }
1287
1486
  /**
1288
1487
  * Push with retry and merge on conflict.
@@ -1330,36 +1529,63 @@ async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
1330
1529
  /**
1331
1530
  * Get the current branch name.
1332
1531
  */
1333
- async function getCurrentBranch() {
1334
- return git("rev-parse", "--abbrev-ref", "HEAD");
1532
+ async function getCurrentBranch(baseDir) {
1533
+ return git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--abbrev-ref", "HEAD");
1335
1534
  }
1336
1535
  /**
1337
1536
  * Check if a branch exists locally.
1338
1537
  */
1339
- async function branchExists(branch) {
1538
+ async function branchExists(branch, baseDir) {
1340
1539
  try {
1341
- await git("rev-parse", "--verify", `refs/heads/${branch}`);
1540
+ await git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--verify", `refs/heads/${branch}`);
1342
1541
  return true;
1343
1542
  } catch {
1344
1543
  return false;
1345
1544
  }
1346
1545
  }
1347
1546
  /**
1348
- * Check if a remote branch exists.
1547
+ * Probe whether a remote branch exists, distinguishing a clean "absent" from a
1548
+ * "check failed".
1549
+ *
1550
+ * `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
1551
+ * matched; any other failure (auth/network/transient, or git not found) is a
1552
+ * check failure. Orphan-creating callers MUST branch on all three states and
1553
+ * never treat `check-failed` as `absent` — doing so risks creating a divergent
1554
+ * local branch when the remote is merely unreachable.
1349
1555
  */
1350
- async function remoteBranchExists(remote, branch) {
1556
+ async function probeRemoteBranch(remote, branch, baseDir) {
1557
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1351
1558
  try {
1352
- await git("ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1559
+ await git(...dirArgs, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1560
+ return "present";
1561
+ } catch (err) {
1562
+ return exitCodeOf(err) === 2 ? "absent" : "check-failed";
1563
+ }
1564
+ }
1565
+ async function pathExists(path) {
1566
+ try {
1567
+ await access(path);
1353
1568
  return true;
1354
1569
  } catch {
1355
1570
  return false;
1356
1571
  }
1357
1572
  }
1573
+ async function pathsReferToSameLocation(a, b) {
1574
+ if (normalize(a) === normalize(b)) return true;
1575
+ try {
1576
+ return normalize(await realpath(a)) === normalize(await realpath(b));
1577
+ } catch {
1578
+ return false;
1579
+ }
1580
+ }
1581
+ async function getSharedPaths(baseDir) {
1582
+ return resolveSharedTbdPaths(baseDir);
1583
+ }
1358
1584
  /**
1359
1585
  * Check if the hidden worktree exists and is valid.
1360
1586
  */
1361
1587
  async function worktreeExists(baseDir) {
1362
- const worktreePath = join(baseDir, WORKTREE_DIR);
1588
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
1363
1589
  try {
1364
1590
  await access(worktreePath);
1365
1591
  await access(join(worktreePath, ".git"));
@@ -1373,15 +1599,15 @@ async function worktreeExists(baseDir) {
1373
1599
  * See: tbd-design.md §2.3 Worktree Lifecycle
1374
1600
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §3
1375
1601
  */
1376
- async function checkWorktreeHealth(baseDir) {
1377
- const worktreePath = join(baseDir, WORKTREE_DIR);
1602
+ async function checkWorktreeHealth(baseDir, syncBranch = SYNC_BRANCH) {
1603
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
1378
1604
  try {
1379
1605
  const lines = (await git("-C", baseDir, "worktree", "list", "--porcelain")).split("\n");
1380
1606
  let foundWorktree = false;
1381
1607
  let isPrunable = false;
1382
1608
  for (let i = 0; i < lines.length; i++) {
1383
1609
  const line = lines[i];
1384
- if (line?.startsWith("worktree ") && line.includes(WORKTREE_DIR_NAME)) {
1610
+ if (line?.startsWith("worktree ") && await pathsReferToSameLocation(line.slice(9), worktreePath)) {
1385
1611
  foundWorktree = true;
1386
1612
  for (let j = i + 1; j < lines.length && !lines[j]?.startsWith("worktree "); j++) if (lines[j]?.startsWith("prunable")) {
1387
1613
  isPrunable = true;
@@ -1449,6 +1675,14 @@ async function checkWorktreeHealth(baseDir) {
1449
1675
  } catch {
1450
1676
  branch = null;
1451
1677
  }
1678
+ if (branch !== syncBranch) return {
1679
+ exists: true,
1680
+ valid: false,
1681
+ status: "corrupted",
1682
+ branch,
1683
+ commit,
1684
+ error: branch === null ? `Shared worktree is detached; expected branch ${syncBranch}` : `Shared worktree is on ${branch}; expected branch ${syncBranch}`
1685
+ };
1452
1686
  return {
1453
1687
  exists: true,
1454
1688
  valid: true,
@@ -1467,22 +1701,170 @@ async function checkWorktreeHealth(baseDir) {
1467
1701
  };
1468
1702
  }
1469
1703
  }
1704
+ function isLegacyDataSyncWorktreePath(path, sharedWorktreePath) {
1705
+ const normalized = normalize(path);
1706
+ if (normalized === normalize(sharedWorktreePath)) return false;
1707
+ return basename(normalized) === WORKTREE_DIR_NAME && basename(dirname(normalized)) === TBD_DIR;
1708
+ }
1709
+ async function listLegacyWorktreePaths(baseDir, sharedWorktreePath) {
1710
+ const paths = /* @__PURE__ */ new Set();
1711
+ try {
1712
+ const worktreeList = await git("-C", baseDir, "worktree", "list", "--porcelain");
1713
+ for (const line of worktreeList.split("\n")) {
1714
+ if (!line.startsWith("worktree ")) continue;
1715
+ const worktreePath = line.slice(9);
1716
+ if (isLegacyDataSyncWorktreePath(worktreePath, sharedWorktreePath)) paths.add(worktreePath);
1717
+ }
1718
+ } catch {}
1719
+ const currentCheckoutLegacyPath = join(baseDir, LEGACY_WORKTREE_DIR);
1720
+ if (currentCheckoutLegacyPath !== sharedWorktreePath && await pathExists(currentCheckoutLegacyPath)) paths.add(currentCheckoutLegacyPath);
1721
+ return Array.from(paths);
1722
+ }
1723
+ async function preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch) {
1724
+ if (!await pathExists(join(legacyPath, ".git"))) return;
1725
+ if ((await git("-C", legacyPath, "status", "--porcelain").catch(() => "")).trim()) {
1726
+ await git("-C", legacyPath, "add", "-A");
1727
+ await gitCommit(legacyPath, "--no-verify", "-m", "tbd: preserve legacy sync data").catch((error) => {
1728
+ if (!(error instanceof Error ? error.message : String(error)).includes("nothing to commit")) throw error;
1729
+ });
1730
+ }
1731
+ const head = await git("-C", legacyPath, "rev-parse", "HEAD").catch(() => "");
1732
+ if (!head) return;
1733
+ const branchHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
1734
+ if (!branchHead) {
1735
+ await git("-C", baseDir, "branch", syncBranch, head);
1736
+ return;
1737
+ }
1738
+ if (head === branchHead) return;
1739
+ if (await git("-C", baseDir, "merge-base", "--is-ancestor", branchHead, head).then(() => true).catch(() => false)) {
1740
+ await git("-C", baseDir, "update-ref", `refs/heads/${syncBranch}`, head);
1741
+ return;
1742
+ }
1743
+ if (await git("-C", baseDir, "merge-base", "--is-ancestor", head, branchHead).then(() => true).catch(() => false)) return;
1744
+ const backupBranch = `tbd-legacy-preserve-${nowFilenameTimestamp()}`;
1745
+ await git("-C", baseDir, "branch", backupBranch, head);
1746
+ 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.`);
1747
+ }
1748
+ /**
1749
+ * Preserve and remove f03 per-checkout sync worktrees before creating the shared worktree.
1750
+ */
1751
+ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH) {
1752
+ const { sharedWorktreePath } = await getSharedPaths(baseDir);
1753
+ const legacyPaths = await listLegacyWorktreePaths(baseDir, sharedWorktreePath);
1754
+ let migrated = 0;
1755
+ try {
1756
+ for (const legacyPath of legacyPaths) {
1757
+ await preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch);
1758
+ try {
1759
+ await git("-C", baseDir, "worktree", "remove", legacyPath, "--force");
1760
+ } catch {
1761
+ await rm(legacyPath, {
1762
+ recursive: true,
1763
+ force: true
1764
+ });
1765
+ }
1766
+ migrated += 1;
1767
+ }
1768
+ if (legacyPaths.length > 0) await git("-C", baseDir, "worktree", "prune");
1769
+ return {
1770
+ success: true,
1771
+ migrated
1772
+ };
1773
+ } catch (error) {
1774
+ return {
1775
+ success: false,
1776
+ migrated,
1777
+ error: error instanceof Error ? error.message : String(error)
1778
+ };
1779
+ }
1780
+ }
1781
+ /**
1782
+ * Whether the given remote is configured (has a URL) in the repo at baseDir.
1783
+ * A local-only repo (no remote) is safe to orphan and never pushes.
1784
+ */
1785
+ async function remoteIsConfigured(remote, baseDir) {
1786
+ try {
1787
+ await git("-C", baseDir, "config", "--get", `remote.${remote}.url`);
1788
+ return true;
1789
+ } catch {
1790
+ return false;
1791
+ }
1792
+ }
1793
+ /**
1794
+ * Whether the data-sync worktree carries any user issue files (is-<ulid>.md),
1795
+ * as opposed to only the initial orphan scaffold (.gitkeep / .gitattributes).
1796
+ */
1797
+ async function worktreeHasUserIssues(dataSyncPath) {
1798
+ try {
1799
+ return (await readdir(join(dataSyncPath, "issues"))).some((name) => /^is-.*\.md$/.test(name));
1800
+ } catch {
1801
+ return false;
1802
+ }
1803
+ }
1804
+ /**
1805
+ * Push a freshly-created orphan tbd-sync immediately so "first init wins"
1806
+ * (closes the #137 race window). Classifies the outcome:
1807
+ *
1808
+ * - success => pushed: true ("first init wins").
1809
+ * - transient/permanent network/auth failure => best-effort, ignored
1810
+ * (branch stays local-only until the first reachable sync).
1811
+ * - non-fast-forward rejection => a detected init race (environment B pushed
1812
+ * its own orphan first). Fetch, then:
1813
+ * - scaffold-only local => adopt the remote (reset local to remote).
1814
+ * - local has user issues => fail loudly toward `tbd doctor --fix` so the
1815
+ * local work is never silently discarded.
1816
+ *
1817
+ * MUST be called after the orphan's initial commit, while the worktree is on
1818
+ * syncBranch.
1819
+ */
1820
+ async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath) {
1821
+ try {
1822
+ await gitNoPrompt("-C", worktreePath, "push", remote, `HEAD:refs/heads/${syncBranch}`);
1823
+ return {
1824
+ pushed: true,
1825
+ adopted: false
1826
+ };
1827
+ } catch (err) {
1828
+ if (!isNonFastForward(err)) return {
1829
+ pushed: false,
1830
+ adopted: false
1831
+ };
1832
+ await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
1833
+ if (await worktreeHasUserIssues(dataSyncPath)) throw new Error(`Detected unrelated ${remote}/${syncBranch} histories during init, and the local branch already contains issues. Refusing to discard local work. Run \`tbd doctor --fix\` to reconcile the histories.`);
1834
+ await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
1835
+ return {
1836
+ pushed: false,
1837
+ adopted: true
1838
+ };
1839
+ }
1840
+ }
1470
1841
  /**
1471
1842
  * Initialize the hidden worktree for the tbd-sync branch.
1472
1843
  * Follows the decision tree from tbd-design.md §2.3.
1473
1844
  *
1845
+ * MUST be called while holding `withSharedDataSyncLock` — it migrates legacy
1846
+ * per-checkout worktrees and creates the shared attached worktree on tbd-sync,
1847
+ * so concurrent callers can otherwise race branch ownership and migration.
1848
+ *
1474
1849
  * @param baseDir - The base directory of the repository
1475
1850
  * @param remote - The remote name (default: 'origin')
1476
1851
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1477
1852
  * @returns Path to the worktree or error message
1478
1853
  */
1479
1854
  async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
1480
- const worktreePath = join(baseDir, WORKTREE_DIR);
1855
+ const paths = await getSharedPaths(baseDir);
1856
+ const worktreePath = paths.sharedWorktreePath;
1481
1857
  if (await worktreeExists(baseDir)) return {
1482
1858
  success: true,
1483
1859
  path: worktreePath,
1484
1860
  created: false
1485
1861
  };
1862
+ await mkdir(paths.sharedTbdDir, { recursive: true });
1863
+ const migrationResult = await migrateLegacyWorktreesToShared(baseDir, syncBranch);
1864
+ if (!migrationResult.success) return {
1865
+ success: false,
1866
+ error: migrationResult.error
1867
+ };
1486
1868
  try {
1487
1869
  await rm(worktreePath, {
1488
1870
  recursive: true,
@@ -1490,7 +1872,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1490
1872
  });
1491
1873
  } catch {}
1492
1874
  try {
1493
- if (await branchExists(syncBranch)) {
1875
+ if (await branchExists(syncBranch, baseDir)) {
1494
1876
  await git("-C", baseDir, "worktree", "add", worktreePath, syncBranch);
1495
1877
  return {
1496
1878
  success: true,
@@ -1498,7 +1880,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1498
1880
  created: true
1499
1881
  };
1500
1882
  }
1501
- if (await remoteBranchExists(remote, syncBranch)) {
1883
+ const hasRemote = await remoteIsConfigured(remote, baseDir);
1884
+ const probe = hasRemote ? await probeRemoteBranch(remote, syncBranch, baseDir) : "absent";
1885
+ if (probe === "check-failed") return {
1886
+ success: false,
1887
+ error: `Could not verify whether ${remote}/${syncBranch} exists (remote check failed); refusing to create a divergent local branch. Check connectivity/auth and retry.`
1888
+ };
1889
+ if (probe === "present") {
1502
1890
  await git("-C", baseDir, "fetch", remote, syncBranch);
1503
1891
  await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
1504
1892
  return {
@@ -1513,12 +1901,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1513
1901
  await mkdir(join(dataSyncPath, "issues"), { recursive: true });
1514
1902
  await mkdir(join(dataSyncPath, "mappings"), { recursive: true });
1515
1903
  await mkdir(join(dataSyncPath, "attic", "conflicts"), { recursive: true });
1516
- await writeFile(join(dataSyncPath, "meta.yml"), "schema_version: 1\n");
1904
+ await writeFile(join(dataSyncPath, "meta.yml"), `schema_version: ${DATA_SYNC_SCHEMA_VERSION}\n`);
1517
1905
  await writeFile(join(dataSyncPath, "issues", ".gitkeep"), "");
1518
1906
  await writeFile(join(dataSyncPath, "mappings", ".gitkeep"), "");
1519
1907
  await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
1520
1908
  await git("-C", worktreePath, "add", ".");
1521
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "Initialize tbd-sync branch");
1909
+ await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
1910
+ if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
1522
1911
  return {
1523
1912
  success: true,
1524
1913
  path: worktreePath,
@@ -1538,16 +1927,17 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1538
1927
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1539
1928
  * @returns Health status indicating if branch exists and has commits
1540
1929
  */
1541
- async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
1930
+ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH, baseDir) {
1931
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1542
1932
  try {
1543
1933
  return {
1544
1934
  exists: true,
1545
1935
  orphaned: false,
1546
- head: (await git("rev-parse", `refs/heads/${syncBranch}`)).trim()
1936
+ head: (await git(...dirArgs, "rev-parse", `refs/heads/${syncBranch}`)).trim()
1547
1937
  };
1548
1938
  } catch {
1549
1939
  try {
1550
- await git("show-ref", "--verify", `refs/heads/${syncBranch}`);
1940
+ await git(...dirArgs, "show-ref", "--verify", `refs/heads/${syncBranch}`);
1551
1941
  return {
1552
1942
  exists: true,
1553
1943
  orphaned: true
@@ -1568,27 +1958,36 @@ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
1568
1958
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1569
1959
  * @returns Health status indicating if remote branch exists and divergence state
1570
1960
  */
1571
- async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH) {
1961
+ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
1962
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1572
1963
  try {
1573
- await git("fetch", remote, syncBranch);
1574
- const remoteHead = (await git("rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
1964
+ await git(...dirArgs, "fetch", remote, syncBranch);
1965
+ const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
1575
1966
  let diverged = false;
1576
- try {
1577
- const mergeBase = await git("merge-base", syncBranch, `${remote}/${syncBranch}`);
1578
- const localHead = await git("rev-parse", syncBranch);
1579
- diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
1580
- } catch {
1581
- diverged = false;
1967
+ let unrelated = false;
1968
+ if (await branchExists(syncBranch, baseDir)) {
1969
+ const localHead = (await git(...dirArgs, "rev-parse", syncBranch)).trim();
1970
+ try {
1971
+ const mergeBase = (await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`)).trim();
1972
+ diverged = mergeBase !== localHead && mergeBase !== remoteHead;
1973
+ } catch (err) {
1974
+ if (exitCodeOf(err) === 1) {
1975
+ unrelated = true;
1976
+ diverged = true;
1977
+ }
1978
+ }
1582
1979
  }
1583
1980
  return {
1584
1981
  exists: true,
1585
1982
  diverged,
1983
+ unrelated,
1586
1984
  head: remoteHead
1587
1985
  };
1588
1986
  } catch {
1589
1987
  return {
1590
1988
  exists: false,
1591
- diverged: false
1989
+ diverged: false,
1990
+ unrelated: false
1592
1991
  };
1593
1992
  }
1594
1993
  }
@@ -1602,7 +2001,8 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
1602
2001
  * @returns Consistency status with HEAD comparisons and ahead/behind counts
1603
2002
  */
1604
2003
  async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote = "origin") {
1605
- const worktreeHead = await git("-C", join(baseDir, WORKTREE_DIR), "rev-parse", "HEAD").catch(() => "");
2004
+ const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
2005
+ const worktreeHead = await git("-C", worktreePath, "rev-parse", "HEAD").catch(() => "");
1606
2006
  const localHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
1607
2007
  const remoteHead = await git("-C", baseDir, "rev-parse", `${remote}/${syncBranch}`).catch(() => "");
1608
2008
  let localAhead = 0;
@@ -1626,6 +2026,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
1626
2026
  localBehind
1627
2027
  };
1628
2028
  }
2029
+ /** Read all issues from a git ref's data-sync tree (no checkout needed). */
2030
+ async function readBranchIssues(baseDir, ref) {
2031
+ let listing;
2032
+ try {
2033
+ listing = await git("-C", baseDir, "ls-tree", "-r", "--name-only", ref, "--", `${DATA_SYNC_DIR}/issues/`);
2034
+ } catch {
2035
+ return [];
2036
+ }
2037
+ const issues = [];
2038
+ for (const path of listing.split("\n").filter((p) => /\/is-.*\.md$/.test(p))) try {
2039
+ const content = await git("-C", baseDir, "show", `${ref}:${path}`);
2040
+ issues.push(parseIssue(content));
2041
+ } catch {}
2042
+ return issues;
2043
+ }
2044
+ /** Read the ID mapping from a git ref's ids.yml (empty if absent). */
2045
+ async function readBranchMapping(baseDir, ref) {
2046
+ try {
2047
+ return parseIdMappingFromYaml(await git("-C", baseDir, "show", `${ref}:${DATA_SYNC_DIR}/mappings/ids.yml`));
2048
+ } catch {
2049
+ return {
2050
+ shortToUlid: /* @__PURE__ */ new Map(),
2051
+ ulidToShort: /* @__PURE__ */ new Map()
2052
+ };
2053
+ }
2054
+ }
2055
+ /** Preserve a losing issue version explicitly under attic/conflicts/. */
2056
+ async function preserveLosingVersion(dataSyncPath, loser) {
2057
+ const conflictsDir = join(dataSyncPath, "attic", "conflicts");
2058
+ await mkdir(conflictsDir, { recursive: true });
2059
+ await writeFile(join(conflictsDir, `${loser.id}__${nowFilenameTimestamp()}.md`), serializeIssue(loser));
2060
+ }
2061
+ /**
2062
+ * Non-destructively rescue an unrelated tbd-sync history (#139).
2063
+ *
2064
+ * Reconciles at the issue-file layer (ULIDs guarantee no collisions), never via
2065
+ * a git history merge. Adopts the remote as the canonical base and replays
2066
+ * local work onto it. The only destructive step (the reset) happens AFTER a
2067
+ * backup branch is created, so the pre-rescue HEAD is always recoverable and
2068
+ * the rescue is restartable.
2069
+ *
2070
+ * MUST be called while holding `withSharedDataSyncLock`. Aborts if the
2071
+ * data-sync worktree is dirty or has a merge in progress.
2072
+ */
2073
+ async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
2074
+ const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
2075
+ if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) throw new Error("Refusing to rescue: the tbd-sync worktree has uncommitted changes. Commit or stash them, then retry.");
2076
+ if (await git("-C", worktreePath, "rev-parse", "-q", "--verify", "MERGE_HEAD").then(() => true).catch(() => false)) throw new Error("Refusing to rescue: a merge is in progress in the tbd-sync worktree. Resolve or abort it, then retry.");
2077
+ await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
2078
+ const localIssues = await listIssues(dataSyncPath);
2079
+ const localMapping = await loadIdMapping(dataSyncPath);
2080
+ const remoteIssues = await readBranchIssues(baseDir, `${remote}/${syncBranch}`);
2081
+ const remoteMapping = await readBranchMapping(baseDir, `${remote}/${syncBranch}`);
2082
+ const localHead = (await git("-C", baseDir, "rev-parse", syncBranch)).trim();
2083
+ const backupBranch = `tbd-backup-${nowFilenameTimestamp()}`;
2084
+ await git("-C", baseDir, "branch", backupBranch, localHead);
2085
+ const buckets = categorizeIssuesByUlid(localIssues, remoteIssues);
2086
+ await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
2087
+ let conflicts = 0;
2088
+ for (const issue of buckets.localOnly) await writeIssue(dataSyncPath, issue);
2089
+ for (const { local, remote: remoteIssue } of buckets.bothDifferent) {
2090
+ const { merged } = mergeIssues(null, local, remoteIssue);
2091
+ await writeIssue(dataSyncPath, merged);
2092
+ for (const side of [local, remoteIssue]) if (!issuesSubstantivelyEqual(side, merged)) {
2093
+ await preserveLosingVersion(dataSyncPath, side);
2094
+ conflicts++;
2095
+ }
2096
+ }
2097
+ const mergedMapping = mergeIdMappings(remoteMapping, localMapping);
2098
+ const allIssues = await listIssues(dataSyncPath);
2099
+ reconcileMappings(allIssues.map((i) => i.id), mergedMapping, remoteMapping);
2100
+ await saveIdMapping(dataSyncPath, mergedMapping);
2101
+ await git("-C", worktreePath, "add", "-A");
2102
+ if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) await gitCommit(worktreePath, "--no-verify", "-m", `tbd rescue: adopt remote base + reconcile ${buckets.localOnly.length + buckets.bothDifferent.length} issue(s) (${buckets.localOnly.length} local-only, ${buckets.bothDifferent.length} merged)`);
2103
+ return {
2104
+ backupBranch,
2105
+ localOnly: buckets.localOnly.length,
2106
+ merged: buckets.bothDifferent.length,
2107
+ conflicts,
2108
+ totalIssues: allIssues.length
2109
+ };
2110
+ }
1629
2111
  /**
1630
2112
  * Count issues on a remote sync branch without creating a worktree.
1631
2113
  * Used by doctor to show accurate statistics on fresh clones.
@@ -1634,10 +2116,12 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
1634
2116
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1635
2117
  * @returns Number of issue files on the remote branch, or null if branch doesn't exist
1636
2118
  */
1637
- async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
2119
+ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
2120
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1638
2121
  try {
1639
- await git("fetch", remote, syncBranch);
1640
- const output = await git("ls-tree", "-r", "--name-only", `${remote}/${syncBranch}`);
2122
+ await git(...dirArgs, "fetch", remote, syncBranch);
2123
+ const remoteBranch = `${remote}/${syncBranch}`;
2124
+ const output = await git(...dirArgs, "ls-tree", "-r", "--name-only", remoteBranch);
1641
2125
  const issuesDir = `${TBD_DIR}/${DATA_SYNC_DIR_NAME}/issues/`;
1642
2126
  return output.split("\n").filter(Boolean).filter((line) => line.startsWith(issuesDir) && line.endsWith(".md")).length;
1643
2127
  } catch {
@@ -1652,6 +2136,10 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
1652
2136
  * - CORRUPTED: backup to .tbd/backups/, remove, then recreate
1653
2137
  * - MISSING: just create
1654
2138
  *
2139
+ * MUST be called while holding `withSharedDataSyncLock` — repair mutates
2140
+ * shared worktree and branch state and shares the same locking contract as
2141
+ * `initWorktree`.
2142
+ *
1655
2143
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
1656
2144
  *
1657
2145
  * @param baseDir - The base directory of the repository
@@ -1660,13 +2148,12 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
1660
2148
  * @param syncBranch - The sync branch name (default: 'tbd-sync')
1661
2149
  */
1662
2150
  async function repairWorktree(baseDir, status, remote = "origin", syncBranch = SYNC_BRANCH) {
1663
- const worktreePath = join(baseDir, WORKTREE_DIR);
2151
+ const { sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
1664
2152
  try {
1665
2153
  if (status === "missing" || status === "prunable") await git("-C", baseDir, "worktree", "prune");
1666
2154
  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)}`);
2155
+ await mkdir(sharedBackupsDir, { recursive: true });
2156
+ const backupPath = join(sharedBackupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1670
2157
  try {
1671
2158
  await cp(worktreePath, backupPath, { recursive: true });
1672
2159
  } catch {}
@@ -1697,9 +2184,15 @@ async function repairWorktree(baseDir, status, remote = "origin", syncBranch = S
1697
2184
  * @returns true if worktree was detached and repaired, false if already attached
1698
2185
  */
1699
2186
  async function ensureWorktreeAttached(worktreePath) {
2187
+ return ensureWorktreeAttachedToBranch(worktreePath, SYNC_BRANCH);
2188
+ }
2189
+ /**
2190
+ * Ensure worktree is attached to the requested sync branch, not detached HEAD.
2191
+ */
2192
+ async function ensureWorktreeAttachedToBranch(worktreePath, syncBranch = SYNC_BRANCH) {
1700
2193
  try {
1701
- if (!await git("-C", worktreePath, "branch", "--show-current").catch(() => "")) {
1702
- await git("-C", worktreePath, "checkout", SYNC_BRANCH);
2194
+ if (await git("-C", worktreePath, "branch", "--show-current").catch(() => "") !== syncBranch) {
2195
+ await git("-C", worktreePath, "checkout", syncBranch);
1703
2196
  return true;
1704
2197
  }
1705
2198
  return false;
@@ -1725,28 +2218,23 @@ async function ensureWorktreeAttached(worktreePath) {
1725
2218
  */
1726
2219
  async function migrateDataToWorktree(baseDir, removeSource = false) {
1727
2220
  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);
2221
+ const { sharedDataSyncDir: correctPath, sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
1730
2222
  try {
1731
2223
  await ensureWorktreeAttached(worktreePath);
1732
2224
  const wrongIssuesPath = join(wrongPath, "issues");
1733
2225
  const wrongMappingsPath = join(wrongPath, "mappings");
1734
2226
  let issueFiles = [];
1735
2227
  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 {}
2228
+ issueFiles = await readdir(wrongIssuesPath).catch(() => []);
2229
+ mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
1741
2230
  issueFiles = issueFiles.filter((f) => f !== ".gitkeep");
1742
2231
  mappingFiles = mappingFiles.filter((f) => f !== ".gitkeep");
1743
2232
  if (issueFiles.length === 0 && mappingFiles.length === 0) return {
1744
2233
  success: true,
1745
2234
  migratedCount: 0
1746
2235
  };
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)}`);
2236
+ await mkdir(sharedBackupsDir, { recursive: true });
2237
+ const backupPath = join(sharedBackupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
1750
2238
  await cp(wrongPath, backupPath, { recursive: true });
1751
2239
  const correctIssuesPath = join(correctPath, "issues");
1752
2240
  const correctMappingsPath = join(correctPath, "mappings");
@@ -1754,8 +2242,6 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1754
2242
  await mkdir(correctMappingsPath, { recursive: true });
1755
2243
  for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
1756
2244
  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
2245
  const sourceMapping = resolveIdMappingConflicts(await readFile(join(wrongMappingsPath, file), "utf-8"));
1760
2246
  let targetMapping;
1761
2247
  try {
@@ -1767,7 +2253,7 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1767
2253
  } else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
1768
2254
  const totalFiles = issueFiles.length + mappingFiles.length;
1769
2255
  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`);
2256
+ 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
2257
  if (removeSource) {
1772
2258
  for (const file of issueFiles) await rm(join(wrongIssuesPath, file));
1773
2259
  for (const file of mappingFiles) await rm(join(wrongMappingsPath, file));
@@ -1786,6 +2272,83 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
1786
2272
  }
1787
2273
  }
1788
2274
 
2275
+ //#endregion
2276
+ //#region src/file/common-dir-layout.ts
2277
+ /**
2278
+ * Git common-dir layout metadata for shared issue sync machinery.
2279
+ */
2280
+ /**
2281
+ * Error thrown when common-dir layout metadata cannot be used safely.
2282
+ */
2283
+ var CommonDirLayoutError = class extends Error {
2284
+ constructor(message) {
2285
+ super(message);
2286
+ this.name = "CommonDirLayoutError";
2287
+ }
2288
+ };
2289
+ /**
2290
+ * Read $GIT_COMMON_DIR/tbd/layout.yml, returning null when it has not been created yet.
2291
+ */
2292
+ async function readCommonDirLayout(layoutPath) {
2293
+ try {
2294
+ const content = await readFile(layoutPath, "utf-8");
2295
+ return CommonDirLayoutSchema.parse(parse(content));
2296
+ } catch (error) {
2297
+ if (error.code === "ENOENT") return null;
2298
+ throw new CommonDirLayoutError(`Invalid tbd common-dir layout metadata at ${layoutPath}: ${error instanceof Error ? error.message : String(error)}`);
2299
+ }
2300
+ }
2301
+ /**
2302
+ * Validate that common-dir layout metadata matches the checkout config.
2303
+ */
2304
+ function validateCommonDirLayout(layout, config) {
2305
+ if (!isCompatibleFormat(layout.tbd_format)) throw new CommonDirLayoutError(formatUpgradeMessage("Common-dir layout", layout.tbd_format, CURRENT_FORMAT));
2306
+ 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".)`);
2307
+ const layoutStorage = layout.sync_storage;
2308
+ const configStorage = config.sync.storage;
2309
+ 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".)`);
2310
+ }
2311
+ /**
2312
+ * Write common-dir layout metadata using the synchronized tbd_format ID.
2313
+ */
2314
+ async function writeCommonDirLayout(paths, config, existing) {
2315
+ await mkdir(dirname(paths.sharedLayoutPath), { recursive: true });
2316
+ const timestamp = now();
2317
+ const layout = CommonDirLayoutSchema.parse({
2318
+ tbd_format: config.tbd_format,
2319
+ sync_storage: config.sync.storage,
2320
+ data_sync_worktree: "data-sync-worktree",
2321
+ lock_profile: "data-sync-v1",
2322
+ created_at: existing?.created_at ?? timestamp,
2323
+ updated_at: timestamp
2324
+ });
2325
+ const yaml = stringifyYaml(sortKeys(layout, COMMON_DIR_LAYOUT_FIELD_ORDER), {
2326
+ lineWidth: 0,
2327
+ sortMapEntries: false
2328
+ });
2329
+ await writeFile(paths.sharedLayoutPath, yaml);
2330
+ return layout;
2331
+ }
2332
+ /**
2333
+ * Ensure layout metadata exists and matches the checkout config.
2334
+ */
2335
+ async function ensureCommonDirLayout(paths, config) {
2336
+ const existing = await readCommonDirLayout(paths.sharedLayoutPath);
2337
+ if (existing) {
2338
+ validateCommonDirLayout(existing, config);
2339
+ return existing;
2340
+ }
2341
+ return writeCommonDirLayout(paths, config);
2342
+ }
2343
+ /**
2344
+ * Run a critical section while holding the repo-scoped data-sync lock.
2345
+ */
2346
+ async function withSharedDataSyncLock(tbdRoot, fn) {
2347
+ const paths = await resolveSharedTbdPaths(tbdRoot);
2348
+ await mkdir(paths.sharedLocksDir, { recursive: true });
2349
+ return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
2350
+ }
2351
+
1789
2352
  //#endregion
1790
2353
  //#region src/cli/commands/init.ts
1791
2354
  /**
@@ -1816,7 +2379,7 @@ Example:
1816
2379
  tbd init --prefix=${prefix} --force`);
1817
2380
  if (this.checkDryRun("Would initialize tbd repository", options)) return;
1818
2381
  await this.execute(async () => {
1819
- await initConfig(cwd, VERSION, options.prefix);
2382
+ const config = await initConfig(cwd, VERSION, options.prefix);
1820
2383
  this.output.debug(`Created ${TBD_DIR}/config.yml with prefix '${options.prefix}'`);
1821
2384
  await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
1822
2385
  "# Installed documentation (regenerated on setup)",
@@ -1854,152 +2417,43 @@ Example:
1854
2417
  if (!supported) throw new CLIError(`Git ${`${version.major}.${version.minor}.${version.patch}`} detected. Git ${MIN_GIT_VERSION}+ is required for tbd.\n\ntbd requires Git 2.42+ for orphan worktree support.\nPlease upgrade Git: https://git-scm.com/downloads`);
1855
2418
  this.output.debug(`Git version ${version.major}.${version.minor}.${version.patch} OK`);
1856
2419
  } catch (error) {
1857
- if (error instanceof CLIError) throw error;
1858
- this.output.debug(`Git version check skipped: ${error.message}`);
1859
- }
1860
- const worktreeResult = await initWorktree(cwd, remote, syncBranch);
1861
- 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);
1865
- if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
1866
- } else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
1867
- }, "Failed to initialize tbd");
1868
- this.output.data({
1869
- initialized: true,
1870
- version: VERSION,
1871
- prefix: options.prefix
1872
- }, () => {
1873
- this.output.success(`Initialized tbd repository (prefix: ${options.prefix})`);
1874
- if (!this.output.isQuiet()) {
1875
- console.log("");
1876
- console.log("Next steps:");
1877
- console.log(" git add .tbd/ && git commit -m \"Initialize tbd\"");
1878
- console.log(" tbd setup --auto # Optional: configure agent integrations");
1879
- }
1880
- });
1881
- }
1882
- };
1883
- const initCommand = new Command("init").description("Initialize tbd in a git repository").option("--prefix <name>", "Project prefix for display IDs (2-8 alphabetic recommended)").option("--force", "Allow non-recommended prefix format").option("--sync-branch <name>", "Sync branch name (default: tbd-sync)").option("--remote <name>", "Remote name (default: origin)").action(async (options, command) => {
1884
- await new InitHandler(command).run(options);
1885
- });
1886
-
1887
- //#endregion
1888
- //#region src/utils/zod-error-utils.ts
1889
- /**
1890
- * Helpers for rendering Zod errors without relying on object inspection.
1891
- */
1892
- /**
1893
- * Format a ZodError as concise path-qualified messages for CLI output.
1894
- */
1895
- function formatZodError(error) {
1896
- const messages = error.issues.map((issue) => {
1897
- return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
1898
- });
1899
- return messages.length > 0 ? messages.join("; ") : error.message;
1900
- }
1901
- /**
1902
- * Format unknown thrown values as safe strings for warnings and diagnostics.
1903
- */
1904
- function formatUnknownError(error) {
1905
- if (error instanceof ZodError) return formatZodError(error);
1906
- if (error instanceof Error) return error.message;
1907
- return String(error);
1908
- }
1909
-
1910
- //#endregion
1911
- //#region src/file/storage.ts
1912
- /**
1913
- * Storage layer for issue files.
1914
- *
1915
- * Provides atomic file operations and issue CRUD operations.
1916
- * All operations work on the hidden worktree at .tbd/data-sync/issues/.
1917
- *
1918
- * See: tbd-design.md §3.2 Storage Layer
1919
- */
1920
- /**
1921
- * Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
1922
- */
1923
- const ISSUE_READ_BATCH_SIZE = 200;
1924
- /**
1925
- * Get the path to an issue file.
1926
- */
1927
- function getIssuePath(baseDir, id) {
1928
- return join(baseDir, "issues", `${id}.md`);
1929
- }
1930
- /**
1931
- * Read an issue from the worktree.
1932
- * @throws If the issue file doesn't exist or is invalid.
1933
- */
1934
- async function readIssue(baseDir, id) {
1935
- return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
1936
- }
1937
- /**
1938
- * Write an issue to the worktree.
1939
- * Uses atomic write to prevent corruption.
1940
- */
1941
- async function writeIssue(baseDir, issue) {
1942
- const validIssue = IssueSchema.parse(issue);
1943
- await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
1944
- }
1945
- /**
1946
- * List all issues in the worktree.
1947
- * Returns empty array if issues directory doesn't exist.
1948
- *
1949
- * Uses parallel file reading for better performance with many issues.
1950
- */
1951
- async function listIssues(baseDir, options = {}) {
1952
- const warnOnInvalid = options.warnOnInvalid ?? true;
1953
- const issuesDir = join(baseDir, "issues");
1954
- let files;
1955
- try {
1956
- files = await readdir(issuesDir);
1957
- } catch {
1958
- return [];
1959
- }
1960
- const mdFiles = files.filter((f) => f.endsWith(".md"));
1961
- const issues = [];
1962
- for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
1963
- const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
1964
- const fileContents = await Promise.all(batch.map(async (file) => {
1965
- const filePath = join(issuesDir, file);
1966
- try {
1967
- return {
1968
- file,
1969
- content: await readFile(filePath, "utf-8")
1970
- };
1971
- } catch (error) {
1972
- return {
1973
- file,
1974
- error: formatUnknownError(error)
1975
- };
1976
- }
1977
- }));
1978
- for (const result of fileContents) {
1979
- if ("error" in result) {
1980
- reportInvalidIssueFile({
1981
- file: result.file,
1982
- reason: `failed to read file: ${result.error}`
1983
- }, warnOnInvalid, options.onInvalidIssue);
1984
- continue;
1985
- }
1986
- try {
1987
- const issue = parseIssue(result.content);
1988
- issues.push(issue);
1989
- } catch (error) {
1990
- reportInvalidIssueFile({
1991
- file: result.file,
1992
- reason: formatUnknownError(error)
1993
- }, warnOnInvalid, options.onInvalidIssue);
2420
+ if (error instanceof CLIError) throw error;
2421
+ this.output.debug(`Git version check skipped: ${error.message}`);
1994
2422
  }
1995
- }
2423
+ const { worktreeResult, sharedPaths } = await withSharedDataSyncLock(cwd, async () => {
2424
+ const result = await initWorktree(cwd, remote, syncBranch);
2425
+ const paths = await resolveSharedTbdPaths(cwd);
2426
+ if (result.success) await writeCommonDirLayout(paths, config);
2427
+ return {
2428
+ worktreeResult: result,
2429
+ sharedPaths: paths
2430
+ };
2431
+ });
2432
+ if (worktreeResult.success) {
2433
+ if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${sharedPaths.sharedWorktreePath}`);
2434
+ else this.output.debug(`Worktree already exists at ${sharedPaths.sharedWorktreePath}`);
2435
+ const health = await checkWorktreeHealth(cwd, syncBranch);
2436
+ if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
2437
+ } else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
2438
+ }, "Failed to initialize tbd");
2439
+ this.output.data({
2440
+ initialized: true,
2441
+ version: VERSION,
2442
+ prefix: options.prefix
2443
+ }, () => {
2444
+ this.output.success(`Initialized tbd repository (prefix: ${options.prefix})`);
2445
+ if (!this.output.isQuiet()) {
2446
+ console.log("");
2447
+ console.log("Next steps:");
2448
+ console.log(" git add .tbd/ && git commit -m \"Initialize tbd\"");
2449
+ console.log(" tbd setup --auto # Optional: configure agent integrations");
2450
+ }
2451
+ });
1996
2452
  }
1997
- return issues;
1998
- }
1999
- function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
2000
- onInvalidIssue?.(invalidIssue);
2001
- if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
2002
- }
2453
+ };
2454
+ const initCommand = new Command("init").description("Initialize tbd in a git repository").option("--prefix <name>", "Project prefix for display IDs (2-8 alphabetic recommended)").option("--force", "Allow non-recommended prefix format").option("--sync-branch <name>", "Sync branch name (default: tbd-sync)").option("--remote <name>", "Remote name (default: origin)").action(async (options, command) => {
2455
+ await new InitHandler(command).run(options);
2456
+ });
2003
2457
 
2004
2458
  //#endregion
2005
2459
  //#region src/lib/priority.ts
@@ -2208,6 +2662,152 @@ function validateIssueTitle(title, options) {
2208
2662
  return title;
2209
2663
  }
2210
2664
 
2665
+ //#endregion
2666
+ //#region src/cli/lib/data-context.ts
2667
+ async function probeDataSyncReadiness(tbdRoot) {
2668
+ const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
2669
+ const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
2670
+ const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
2671
+ if (layout) validateCommonDirLayout(layout, config);
2672
+ const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
2673
+ return {
2674
+ config,
2675
+ migrated,
2676
+ fromFormat,
2677
+ sharedPaths,
2678
+ layout,
2679
+ health,
2680
+ ready: !migrated && layout !== null && health.valid
2681
+ };
2682
+ }
2683
+ /**
2684
+ * Apply any pending first-use initialization, migration, or repair to the shared
2685
+ * data-sync layout. MUST be called while holding `withSharedDataSyncLock` so that
2686
+ * worktree repair, layout writes, and migrated-config writes are serialized.
2687
+ */
2688
+ async function ensureSharedDataSyncLayout(tbdRoot, probe) {
2689
+ let repairedWorktreeStatus;
2690
+ if (!probe.health.valid) if (probe.health.status === "missing" || probe.health.status === "prunable") {
2691
+ const repairResult = await repairWorktree(tbdRoot, probe.health.status, probe.config.sync.remote, probe.config.sync.branch);
2692
+ if (!repairResult.success) throw new Error(`Failed to initialize shared data-sync worktree: ${repairResult.error}`);
2693
+ repairedWorktreeStatus = probe.health.status;
2694
+ } else throw new Error(`Shared data-sync worktree is ${probe.health.status}: ${probe.health.error ?? "unknown error"}. Run 'tbd doctor --fix' to repair.`);
2695
+ await ensureCommonDirLayout(probe.sharedPaths, probe.config);
2696
+ if (probe.migrated) {
2697
+ await writeConfig(tbdRoot, probe.config);
2698
+ notifyConfigMigrated(probe.fromFormat, CURRENT_FORMAT);
2699
+ }
2700
+ return repairedWorktreeStatus;
2701
+ }
2702
+ /**
2703
+ * Emit a one-time stderr notice when this checkout's `.tbd/config.yml` was migrated
2704
+ * (typically `fXX → fYY`). The config bump is the "publish" step of the format
2705
+ * migration and lands as a tracked diff on the current branch; users on a sibling
2706
+ * worktree (and even on main) deserve to know that without having to discover the
2707
+ * diff themselves later.
2708
+ *
2709
+ * See: docs/tbd-format-versioning.md (internal contributor guide) and
2710
+ * plan-2026-05-17-shared-common-dir-sync-worktree.md.
2711
+ */
2712
+ function notifyConfigMigrated(fromFormat, toFormat) {
2713
+ if (fromFormat === toFormat) return;
2714
+ const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
2715
+ 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`);
2716
+ }
2717
+ async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
2718
+ const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
2719
+ return {
2720
+ dataSyncDir,
2721
+ mapping: await loadIdMapping(dataSyncDir),
2722
+ config: probe.config,
2723
+ prefix: probe.config.display.id_prefix,
2724
+ sharedPaths: probe.sharedPaths,
2725
+ repairedWorktreeStatus
2726
+ };
2727
+ }
2728
+ /**
2729
+ * Load all common data context needed by tbd commands.
2730
+ *
2731
+ * For writers this is called inside `withSharedDataSyncLock` by
2732
+ * `withDataSyncContext({ lock: true }, ...)`, so any ensure/migrate/repair work
2733
+ * is serialized.
2734
+ */
2735
+ async function prepareDataSyncContext(tbdRoot) {
2736
+ const probe = await probeDataSyncReadiness(tbdRoot);
2737
+ return assembleDataContext(tbdRoot, probe, probe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, probe));
2738
+ }
2739
+ /**
2740
+ * Prepare shared data-sync context, optionally holding the repo-scoped lock.
2741
+ *
2742
+ * - `{ lock: true }` (writers): always acquire the lock, then prepare under it.
2743
+ * - `{ lock: false }` (readers): probe first; only acquire the lock if first-use
2744
+ * init/migrate/repair is actually required. Steady-state reads take no lock.
2745
+ */
2746
+ async function withDataSyncContext(tbdRoot, options, fn) {
2747
+ if (options.lock) return withSharedDataSyncLock(tbdRoot, async () => fn(await prepareDataSyncContext(tbdRoot)));
2748
+ const probe = await probeDataSyncReadiness(tbdRoot);
2749
+ if (probe.ready) return fn(await assembleDataContext(tbdRoot, probe));
2750
+ return withSharedDataSyncLock(tbdRoot, async () => {
2751
+ const reProbe = await probeDataSyncReadiness(tbdRoot);
2752
+ return fn(await assembleDataContext(tbdRoot, reProbe, reProbe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, reProbe)));
2753
+ });
2754
+ }
2755
+ /**
2756
+ * Load the shared data-sync context for a read-only command.
2757
+ *
2758
+ * Read commands skip the shared lock when the layout and worktree are already
2759
+ * valid and the on-disk config needs no migration. When first-use
2760
+ * init/migrate/repair IS required, the underlying `withDataSyncContext` takes
2761
+ * the lock and runs the ensure path so concurrent readers cannot race
2762
+ * migration or worktree repair.
2763
+ */
2764
+ async function loadDataContext(tbdRoot) {
2765
+ return withDataSyncContext(tbdRoot, { lock: false }, async (context) => context);
2766
+ }
2767
+ /**
2768
+ * Load unified command context with CLI options, data, and helper methods.
2769
+ *
2770
+ * This is the recommended way to initialize command context. It:
2771
+ * 1. Checks that tbd is initialized (calls requireInit)
2772
+ * 2. Loads data context (dataSyncDir, mapping, config, prefix)
2773
+ * 3. Extracts CLI context from Commander
2774
+ * 4. Provides helper methods like displayId() and resolveId()
2775
+ *
2776
+ * Usage:
2777
+ * ```ts
2778
+ * class MyHandler extends BaseCommand {
2779
+ * async run(id: string): Promise<void> {
2780
+ * const ctx = await loadFullContext(this.command);
2781
+ * const internalId = ctx.resolveId(id);
2782
+ * const issue = await readIssue(ctx.dataSyncDir, internalId);
2783
+ * console.log(ctx.displayId(issue.id));
2784
+ * }
2785
+ * }
2786
+ * ```
2787
+ *
2788
+ * @param command - The Commander command instance
2789
+ * @throws Error if tbd is not initialized or resources fail to load
2790
+ */
2791
+ async function loadFullContext(command) {
2792
+ const tbdRoot = await requireInit();
2793
+ const cli = getCommandContext(command);
2794
+ const dataCtx = await loadDataContext(tbdRoot);
2795
+ return {
2796
+ ...dataCtx,
2797
+ cli,
2798
+ displayId(internalId) {
2799
+ return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
2800
+ },
2801
+ resolveId(inputId) {
2802
+ try {
2803
+ return resolveToInternalId(inputId, dataCtx.mapping);
2804
+ } catch {
2805
+ throw new NotFoundError("Issue", inputId);
2806
+ }
2807
+ }
2808
+ };
2809
+ }
2810
+
2211
2811
  //#endregion
2212
2812
  //#region src/cli/commands/create.ts
2213
2813
  /**
@@ -2252,52 +2852,52 @@ var CreateHandler = class extends BaseCommand {
2252
2852
  let prefix;
2253
2853
  let issue;
2254
2854
  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);
2855
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
2856
+ prefix = config.display.id_prefix;
2857
+ shortId = generateUniqueShortId(mapping);
2858
+ addIdMapping(mapping, ulid, shortId);
2859
+ let parentId;
2860
+ if (options.parent) try {
2861
+ parentId = resolveToInternalId(options.parent, mapping);
2862
+ } catch {
2863
+ throw new ValidationError(`Invalid parent ID: ${options.parent}`);
2299
2864
  }
2300
- } catch {}
2865
+ if (!specPath && parentId) {
2866
+ const parentIssue = await readIssue(dataSyncDir, parentId);
2867
+ if (parentIssue.spec_path) specPath = parentIssue.spec_path;
2868
+ }
2869
+ issue = {
2870
+ type: "is",
2871
+ id,
2872
+ version: 1,
2873
+ title: validatedTitle,
2874
+ kind,
2875
+ status: "open",
2876
+ priority,
2877
+ labels: options.label ?? [],
2878
+ dependencies: [],
2879
+ created_at: timestamp,
2880
+ updated_at: timestamp,
2881
+ description: description ?? void 0,
2882
+ assignee: options.assignee ?? void 0,
2883
+ due_date: options.due ?? void 0,
2884
+ deferred_until: options.defer ?? void 0,
2885
+ parent_id: parentId,
2886
+ spec_path: specPath
2887
+ };
2888
+ await writeIssue(dataSyncDir, issue);
2889
+ await saveIdMapping(dataSyncDir, mapping);
2890
+ if (parentId) try {
2891
+ const parentIssue = await readIssue(dataSyncDir, parentId);
2892
+ const hints = parentIssue.child_order_hints ?? [];
2893
+ if (!hints.includes(id)) {
2894
+ parentIssue.child_order_hints = [...hints, id];
2895
+ parentIssue.version += 1;
2896
+ parentIssue.updated_at = timestamp;
2897
+ await writeIssue(dataSyncDir, parentIssue);
2898
+ }
2899
+ } catch {}
2900
+ });
2301
2901
  }, "Failed to create issue");
2302
2902
  const displayId = `${prefix}-${shortId}`;
2303
2903
  this.output.data({
@@ -2342,77 +2942,6 @@ function applyLimit(items, limitOption) {
2342
2942
  return items.slice(0, limit);
2343
2943
  }
2344
2944
 
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
2945
  //#endregion
2417
2946
  //#region src/lib/status.ts
2418
2947
  /**
@@ -3060,81 +3589,83 @@ const showCommand = new Command("show").description("Show issue details").argume
3060
3589
  var UpdateHandler = class extends BaseCommand {
3061
3590
  async run(id, options) {
3062
3591
  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();
3592
+ let displayId = id;
3593
+ let didUpdate = false;
3112
3594
  await this.execute(async () => {
3113
- await writeIssue(dataSyncDir, issue);
3595
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3596
+ let internalId;
3597
+ try {
3598
+ internalId = resolveToInternalId(id, mapping);
3599
+ } catch {
3600
+ throw new NotFoundError("Issue", id);
3601
+ }
3602
+ let issue;
3603
+ try {
3604
+ issue = await readIssue(dataSyncDir, internalId);
3605
+ } catch {
3606
+ throw new NotFoundError("Issue", id);
3607
+ }
3608
+ const updates = await this.parseUpdates(options, mapping, tbdRoot);
3609
+ if (updates === null) return;
3610
+ if (this.checkDryRun("Would update issue", {
3611
+ id: internalId,
3612
+ ...updates
3613
+ })) return;
3614
+ const oldSpecPath = issue.spec_path;
3615
+ if (updates.title !== void 0) issue.title = updates.title;
3616
+ if (updates.status !== void 0) issue.status = updates.status;
3617
+ if (updates.kind !== void 0) issue.kind = updates.kind;
3618
+ if (updates.priority !== void 0) issue.priority = updates.priority;
3619
+ if (updates.assignee !== void 0) issue.assignee = updates.assignee;
3620
+ if (updates.description !== void 0) issue.description = updates.description;
3621
+ if (updates.notes !== void 0) issue.notes = updates.notes;
3622
+ if (updates.due_date !== void 0) issue.due_date = updates.due_date;
3623
+ if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
3624
+ if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
3625
+ if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
3626
+ if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
3627
+ if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
3628
+ const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3629
+ if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
3630
+ } catch {}
3631
+ if (updates.labels !== void 0) issue.labels = updates.labels;
3632
+ if (updates.addLabels && updates.addLabels.length > 0) {
3633
+ const labelsSet = new Set(issue.labels);
3634
+ for (const label of updates.addLabels) labelsSet.add(label);
3635
+ issue.labels = [...labelsSet];
3636
+ }
3637
+ if (updates.removeLabels && updates.removeLabels.length > 0) {
3638
+ const removeSet = new Set(updates.removeLabels);
3639
+ issue.labels = issue.labels.filter((l) => !removeSet.has(l));
3640
+ }
3641
+ issue.version += 1;
3642
+ issue.updated_at = now();
3643
+ await writeIssue(dataSyncDir, issue);
3644
+ if (updates.parent_id) try {
3645
+ const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
3646
+ const hints = parentIssue.child_order_hints ?? [];
3647
+ if (!hints.includes(internalId)) {
3648
+ parentIssue.child_order_hints = [...hints, internalId];
3649
+ parentIssue.version += 1;
3650
+ parentIssue.updated_at = now();
3651
+ await writeIssue(dataSyncDir, parentIssue);
3652
+ }
3653
+ } catch {}
3654
+ if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
3655
+ const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
3656
+ const timestamp = now();
3657
+ for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
3658
+ child.spec_path = issue.spec_path;
3659
+ child.version += 1;
3660
+ child.updated_at = timestamp;
3661
+ await writeIssue(dataSyncDir, child);
3662
+ }
3663
+ }
3664
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3665
+ didUpdate = true;
3666
+ });
3114
3667
  }, "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);
3668
+ if (!didUpdate) return;
3138
3669
  this.output.data({
3139
3670
  id: displayId,
3140
3671
  updated: true
@@ -3262,48 +3793,47 @@ const updateCommand = new Command("update").description("Update an issue").argum
3262
3793
  var CloseHandler = class extends BaseCommand {
3263
3794
  async run(id, options) {
3264
3795
  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();
3796
+ let displayId = id;
3797
+ let alreadyClosed = false;
3798
+ let didClose = false;
3301
3799
  await this.execute(async () => {
3302
- await writeIssue(dataSyncDir, issue);
3800
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3801
+ let internalId;
3802
+ try {
3803
+ internalId = resolveToInternalId(id, mapping);
3804
+ } catch {
3805
+ throw new NotFoundError("Issue", id);
3806
+ }
3807
+ let issue;
3808
+ try {
3809
+ issue = await readIssue(dataSyncDir, internalId);
3810
+ } catch {
3811
+ throw new NotFoundError("Issue", id);
3812
+ }
3813
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3814
+ if (issue.status === "closed") {
3815
+ alreadyClosed = true;
3816
+ didClose = true;
3817
+ return;
3818
+ }
3819
+ if (this.checkDryRun("Would close issue", {
3820
+ id: internalId,
3821
+ reason: options.reason
3822
+ })) return;
3823
+ issue.status = "closed";
3824
+ issue.closed_at = now();
3825
+ issue.close_reason = options.reason ?? null;
3826
+ issue.version += 1;
3827
+ issue.updated_at = now();
3828
+ await writeIssue(dataSyncDir, issue);
3829
+ didClose = true;
3830
+ });
3303
3831
  }, "Failed to close issue");
3832
+ if (!didClose) return;
3304
3833
  this.output.data({
3305
3834
  id: displayId,
3306
- closed: true
3835
+ closed: true,
3836
+ alreadyClosed
3307
3837
  }, () => {
3308
3838
  this.output.success(`Closed ${displayId}`);
3309
3839
  });
@@ -3323,40 +3853,42 @@ const closeCommand = new Command("close").description("Close an issue").argument
3323
3853
  var ReopenHandler = class extends BaseCommand {
3324
3854
  async run(id, options) {
3325
3855
  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
- }
3856
+ let displayId = id;
3857
+ let didReopen = false;
3354
3858
  await this.execute(async () => {
3355
- await writeIssue(dataSyncDir, issue);
3859
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
3860
+ let internalId;
3861
+ try {
3862
+ internalId = resolveToInternalId(id, mapping);
3863
+ } catch {
3864
+ throw new NotFoundError("Issue", id);
3865
+ }
3866
+ let issue;
3867
+ try {
3868
+ issue = await readIssue(dataSyncDir, internalId);
3869
+ } catch {
3870
+ throw new NotFoundError("Issue", id);
3871
+ }
3872
+ if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
3873
+ if (this.checkDryRun("Would reopen issue", {
3874
+ id: internalId,
3875
+ reason: options.reason
3876
+ })) return;
3877
+ issue.status = "open";
3878
+ issue.closed_at = null;
3879
+ issue.close_reason = null;
3880
+ issue.version += 1;
3881
+ issue.updated_at = now();
3882
+ if (options.reason) {
3883
+ const reopenNote = `Reopened: ${options.reason}`;
3884
+ issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
3885
+ }
3886
+ await writeIssue(dataSyncDir, issue);
3887
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
3888
+ didReopen = true;
3889
+ });
3356
3890
  }, "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);
3891
+ if (!didReopen) return;
3360
3892
  this.output.data({
3361
3893
  id: displayId,
3362
3894
  reopened: true
@@ -3604,43 +4136,45 @@ const staleCommand = new Command("stale").description("List issues not updated r
3604
4136
  var LabelAddHandler = class extends BaseCommand {
3605
4137
  async run(id, labels) {
3606
4138
  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();
4139
+ let displayId = id;
4140
+ let didAdd = false;
3638
4141
  await this.execute(async () => {
3639
- await writeIssue(dataSyncDir, issue);
4142
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4143
+ let internalId;
4144
+ try {
4145
+ internalId = resolveToInternalId(id, mapping);
4146
+ } catch {
4147
+ throw new NotFoundError("Issue", id);
4148
+ }
4149
+ let issue;
4150
+ try {
4151
+ issue = await readIssue(dataSyncDir, internalId);
4152
+ } catch {
4153
+ throw new NotFoundError("Issue", id);
4154
+ }
4155
+ if (this.checkDryRun("Would add labels", {
4156
+ id: internalId,
4157
+ labels
4158
+ })) return;
4159
+ const labelsSet = new Set(issue.labels);
4160
+ let added = 0;
4161
+ for (const label of labels) if (!labelsSet.has(label)) {
4162
+ labelsSet.add(label);
4163
+ added++;
4164
+ }
4165
+ if (added === 0) {
4166
+ this.output.info("All labels already present");
4167
+ return;
4168
+ }
4169
+ issue.labels = [...labelsSet];
4170
+ issue.version += 1;
4171
+ issue.updated_at = now();
4172
+ await writeIssue(dataSyncDir, issue);
4173
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
4174
+ didAdd = true;
4175
+ });
3640
4176
  }, "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);
4177
+ if (!didAdd) return;
3644
4178
  this.output.data({
3645
4179
  id: displayId,
3646
4180
  addedLabels: labels
@@ -3652,39 +4186,41 @@ var LabelAddHandler = class extends BaseCommand {
3652
4186
  var LabelRemoveHandler = class extends BaseCommand {
3653
4187
  async run(id, labels) {
3654
4188
  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();
4189
+ let displayId = id;
4190
+ let didRemove = false;
3682
4191
  await this.execute(async () => {
3683
- await writeIssue(dataSyncDir, issue);
4192
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4193
+ let internalId;
4194
+ try {
4195
+ internalId = resolveToInternalId(id, mapping);
4196
+ } catch {
4197
+ throw new NotFoundError("Issue", id);
4198
+ }
4199
+ let issue;
4200
+ try {
4201
+ issue = await readIssue(dataSyncDir, internalId);
4202
+ } catch {
4203
+ throw new NotFoundError("Issue", id);
4204
+ }
4205
+ if (this.checkDryRun("Would remove labels", {
4206
+ id: internalId,
4207
+ labels
4208
+ })) return;
4209
+ const removeSet = new Set(labels);
4210
+ const originalCount = issue.labels.length;
4211
+ issue.labels = issue.labels.filter((l) => !removeSet.has(l));
4212
+ if (originalCount - issue.labels.length === 0) {
4213
+ this.output.info("No matching labels found");
4214
+ return;
4215
+ }
4216
+ issue.version += 1;
4217
+ issue.updated_at = now();
4218
+ await writeIssue(dataSyncDir, issue);
4219
+ displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
4220
+ didRemove = true;
4221
+ });
3684
4222
  }, "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);
4223
+ if (!didRemove) return;
3688
4224
  this.output.data({
3689
4225
  id: displayId,
3690
4226
  removedLabels: labels
@@ -3695,7 +4231,7 @@ var LabelRemoveHandler = class extends BaseCommand {
3695
4231
  };
3696
4232
  var LabelListHandler = class extends BaseCommand {
3697
4233
  async run() {
3698
- const dataSyncDir = await resolveDataSyncDir(await requireInit());
4234
+ const { dataSyncDir } = await loadDataContext(await requireInit());
3699
4235
  let issues;
3700
4236
  try {
3701
4237
  issues = await listIssues(dataSyncDir);
@@ -3743,53 +4279,56 @@ const labelCommand = new Command("label").description("Manage issue labels").add
3743
4279
  var DependsAddHandler = class extends BaseCommand {
3744
4280
  async run(issueId, dependsOnId) {
3745
4281
  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();
4282
+ let displayIssueId = issueId;
4283
+ let displayDependsOnId = dependsOnId;
4284
+ let didAdd = false;
3786
4285
  await this.execute(async () => {
3787
- await writeIssue(dataSyncDir, blockerIssue);
4286
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4287
+ let internalIssueId;
4288
+ let internalDependsOnId;
4289
+ try {
4290
+ internalIssueId = resolveToInternalId(issueId, mapping);
4291
+ } catch {
4292
+ throw new NotFoundError("Issue", issueId);
4293
+ }
4294
+ try {
4295
+ internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
4296
+ } catch {
4297
+ throw new NotFoundError("Issue", dependsOnId);
4298
+ }
4299
+ try {
4300
+ await readIssue(dataSyncDir, internalIssueId);
4301
+ } catch {
4302
+ throw new NotFoundError("Issue", issueId);
4303
+ }
4304
+ let blockerIssue;
4305
+ try {
4306
+ blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
4307
+ } catch {
4308
+ throw new NotFoundError("Issue", dependsOnId);
4309
+ }
4310
+ if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
4311
+ if (this.checkDryRun("Would add dependency", {
4312
+ issue: internalIssueId,
4313
+ dependsOn: internalDependsOnId
4314
+ })) return;
4315
+ if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
4316
+ this.output.info("Dependency already exists");
4317
+ return;
4318
+ }
4319
+ blockerIssue.dependencies.push({
4320
+ type: "blocks",
4321
+ target: internalIssueId
4322
+ });
4323
+ blockerIssue.version += 1;
4324
+ blockerIssue.updated_at = now();
4325
+ await writeIssue(dataSyncDir, blockerIssue);
4326
+ displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
4327
+ displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
4328
+ didAdd = true;
4329
+ });
3788
4330
  }, "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);
4331
+ if (!didAdd) return;
3793
4332
  this.output.data({
3794
4333
  issue: displayIssueId,
3795
4334
  dependsOn: displayDependsOnId
@@ -3801,45 +4340,48 @@ var DependsAddHandler = class extends BaseCommand {
3801
4340
  var DependsRemoveHandler = class extends BaseCommand {
3802
4341
  async run(issueId, dependsOnId) {
3803
4342
  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();
4343
+ let displayIssueId = issueId;
4344
+ let displayDependsOnId = dependsOnId;
4345
+ let didRemove = false;
3836
4346
  await this.execute(async () => {
3837
- await writeIssue(dataSyncDir, blockerIssue);
4347
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
4348
+ let internalIssueId;
4349
+ let internalDependsOnId;
4350
+ try {
4351
+ internalIssueId = resolveToInternalId(issueId, mapping);
4352
+ } catch {
4353
+ throw new NotFoundError("Issue", issueId);
4354
+ }
4355
+ try {
4356
+ internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
4357
+ } catch {
4358
+ throw new NotFoundError("Issue", dependsOnId);
4359
+ }
4360
+ let blockerIssue;
4361
+ try {
4362
+ blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
4363
+ } catch {
4364
+ throw new NotFoundError("Issue", dependsOnId);
4365
+ }
4366
+ if (this.checkDryRun("Would remove dependency", {
4367
+ issue: internalIssueId,
4368
+ dependsOn: internalDependsOnId
4369
+ })) return;
4370
+ const initialLength = blockerIssue.dependencies.length;
4371
+ blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
4372
+ if (blockerIssue.dependencies.length === initialLength) {
4373
+ this.output.info("Dependency not found");
4374
+ return;
4375
+ }
4376
+ blockerIssue.version += 1;
4377
+ blockerIssue.updated_at = now();
4378
+ await writeIssue(dataSyncDir, blockerIssue);
4379
+ displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
4380
+ displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
4381
+ didRemove = true;
4382
+ });
3838
4383
  }, "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);
4384
+ if (!didRemove) return;
3843
4385
  this.output.data({
3844
4386
  issue: displayIssueId,
3845
4387
  removed: displayDependsOnId
@@ -3850,9 +4392,7 @@ var DependsRemoveHandler = class extends BaseCommand {
3850
4392
  };
3851
4393
  var DependsListHandler = class extends BaseCommand {
3852
4394
  async run(id) {
3853
- const tbdRoot = await requireInit();
3854
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
3855
- const mapping = await loadIdMapping(dataSyncDir);
4395
+ const { dataSyncDir, mapping, config } = await loadDataContext(await requireInit());
3856
4396
  let internalId;
3857
4397
  try {
3858
4398
  internalId = resolveToInternalId(id, mapping);
@@ -3872,7 +4412,7 @@ var DependsListHandler = class extends BaseCommand {
3872
4412
  allIssues = [];
3873
4413
  }
3874
4414
  const showDebug = this.ctx.debug;
3875
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
4415
+ const prefix = config.display.id_prefix;
3876
4416
  const deps = getDependencyDirections(issue, allIssues, (dependencyId) => showDebug ? formatDebugId(dependencyId, mapping, prefix) : formatDisplayId(dependencyId, mapping, prefix));
3877
4417
  this.output.data(deps, () => {
3878
4418
  const colors = this.output.getColors();
@@ -4896,6 +5436,8 @@ async function workspaceExists(tbdRoot, name) {
4896
5436
  var SyncHandler = class extends BaseCommand {
4897
5437
  dataSyncDir = "";
4898
5438
  tbdRoot = "";
5439
+ worktreePath = "";
5440
+ syncBranch = "";
4899
5441
  async run(options) {
4900
5442
  const tbdRoot = await requireInit();
4901
5443
  this.tbdRoot = tbdRoot;
@@ -4908,42 +5450,28 @@ var SyncHandler = class extends BaseCommand {
4908
5450
  await this.syncDocs(options.status);
4909
5451
  if (!syncIssues) return;
4910
5452
  }
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
5453
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
5454
+ this.dataSyncDir = dataSyncDir;
5455
+ this.worktreePath = sharedPaths.sharedWorktreePath;
5456
+ this.syncBranch = config.sync.branch;
5457
+ const syncBranch = config.sync.branch;
5458
+ const remote = config.sync.remote;
5459
+ if (options.status) {
5460
+ await this.showIssueStatus(syncBranch, remote);
5461
+ return;
5462
+ }
5463
+ if (this.checkDryRun("Would sync repository", {
5464
+ syncBranch,
5465
+ remote
5466
+ })) return;
5467
+ if (repairedWorktreeStatus) this.output.success("Worktree repaired successfully");
5468
+ if (options.pull) await this.pullChanges(syncBranch, remote);
5469
+ else if (options.push) await this.pushChanges(syncBranch, remote);
5470
+ else await this.fullSync(syncBranch, remote, {
5471
+ force: options.force,
5472
+ autoSave: options.autoSave,
5473
+ outbox: options.outbox
5474
+ });
4947
5475
  });
4948
5476
  }
4949
5477
  /**
@@ -4993,24 +5521,6 @@ var SyncHandler = class extends BaseCommand {
4993
5521
  if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
4994
5522
  for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
4995
5523
  }
4996
- /**
4997
- * Attempt to repair an unhealthy worktree.
4998
- * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
4999
- */
5000
- async doRepairWorktree(tbdRoot, status) {
5001
- const spinner = this.output.spinner(`Repairing worktree (${status})...`);
5002
- try {
5003
- const result = await repairWorktree(tbdRoot, status);
5004
- spinner.stop();
5005
- if (!result.success) throw new WorktreeCorruptedError(`Failed to repair worktree: ${result.error}`);
5006
- if (result.backedUp) this.output.info(`Corrupted worktree backed up to: ${result.backedUp}`);
5007
- this.output.success("Worktree repaired successfully");
5008
- } catch (error) {
5009
- spinner.stop();
5010
- if (error instanceof WorktreeCorruptedError) throw error;
5011
- throw new WorktreeCorruptedError(`Failed to repair worktree: ${error.message}`);
5012
- }
5013
- }
5014
5524
  async showIssueStatus(syncBranch, remote) {
5015
5525
  const status = await this.getSyncStatus(syncBranch, remote);
5016
5526
  this.output.data(status, () => {
@@ -5041,7 +5551,7 @@ var SyncHandler = class extends BaseCommand {
5041
5551
  let ahead = 0;
5042
5552
  let behind = 0;
5043
5553
  try {
5044
- const status = await git("-C", join(this.tbdRoot, WORKTREE_DIR), "status", "--porcelain");
5554
+ const status = await git("-C", this.worktreePath, "status", "--porcelain");
5045
5555
  if (status) for (const line of status.split("\n")) {
5046
5556
  if (!line) continue;
5047
5557
  const statusCode = line.slice(0, 2).trim();
@@ -5100,11 +5610,8 @@ var SyncHandler = class extends BaseCommand {
5100
5610
  this.output.success("Already up to date");
5101
5611
  return;
5102
5612
  }
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
- });
5613
+ await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
5614
+ await git("-C", this.worktreePath, "merge", "--ff-only", `${remote}/${syncBranch}`);
5108
5615
  this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
5109
5616
  } catch (error) {
5110
5617
  spinner.stop();
@@ -5120,15 +5627,15 @@ var SyncHandler = class extends BaseCommand {
5120
5627
  * @returns Tallies of new/updated/deleted files committed
5121
5628
  */
5122
5629
  async commitWorktreeChanges() {
5123
- const worktreePath = join(this.tbdRoot, WORKTREE_DIR);
5630
+ const worktreePath = this.worktreePath;
5124
5631
  try {
5125
- await ensureWorktreeAttached(worktreePath);
5632
+ await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);
5126
5633
  const status = await git("-C", worktreePath, "status", "--porcelain");
5127
5634
  if (!status || status.trim() === "") return emptyTallies();
5128
5635
  const tallies = parseGitStatus(status);
5129
5636
  const fileCount = tallies.new + tallies.updated + tallies.deleted;
5130
5637
  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"})`);
5638
+ await gitCommit(worktreePath, "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
5132
5639
  return tallies;
5133
5640
  } catch (error) {
5134
5641
  if (error.message.includes("nothing to commit")) return emptyTallies();
@@ -5210,7 +5717,7 @@ var SyncHandler = class extends BaseCommand {
5210
5717
  const spinner = this.output.spinner("Syncing with remote...");
5211
5718
  const summary = emptySummary();
5212
5719
  const conflicts = [];
5213
- const worktreePath = join(this.tbdRoot, WORKTREE_DIR);
5720
+ const worktreePath = this.worktreePath;
5214
5721
  try {
5215
5722
  const committedTallies = await this.commitWorktreeChanges();
5216
5723
  summary.sent.new += committedTallies.new;
@@ -5221,6 +5728,7 @@ var SyncHandler = class extends BaseCommand {
5221
5728
  this.output.debug(`Committed ${count} file(s) to sync branch`);
5222
5729
  }
5223
5730
  await git("fetch", remote, syncBranch);
5731
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
5224
5732
  let behindCommits = 0;
5225
5733
  try {
5226
5734
  const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
@@ -5238,7 +5746,6 @@ var SyncHandler = class extends BaseCommand {
5238
5746
  this.output.debug("Remote sync branch does not exist yet");
5239
5747
  }
5240
5748
  {
5241
- const { access, writeFile } = await import("node:fs/promises");
5242
5749
  const attrPath = join(this.dataSyncDir, "mappings", ".gitattributes");
5243
5750
  try {
5244
5751
  await access(attrPath);
@@ -5246,7 +5753,7 @@ var SyncHandler = class extends BaseCommand {
5246
5753
  await writeFile(attrPath, "ids.yml merge=union\n");
5247
5754
  await git("-C", worktreePath, "add", attrPath);
5248
5755
  try {
5249
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "chore: add merge=union for ids.yml");
5756
+ await gitCommit(worktreePath, "--no-verify", "-m", "chore: add merge=union for ids.yml");
5250
5757
  } catch {}
5251
5758
  }
5252
5759
  }
@@ -5272,7 +5779,7 @@ var SyncHandler = class extends BaseCommand {
5272
5779
  await saveIdMapping(this.dataSyncDir, postMergeMapping);
5273
5780
  await git("-C", worktreePath, "add", "-A");
5274
5781
  try {
5275
- await git("-C", worktreePath, "commit", "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
5782
+ await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
5276
5783
  } catch {}
5277
5784
  if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
5278
5785
  if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
@@ -5294,7 +5801,6 @@ var SyncHandler = class extends BaseCommand {
5294
5801
  const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
5295
5802
  if (remoteIdsContent) {
5296
5803
  conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
5297
- const { readFile } = await import("node:fs/promises");
5298
5804
  const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
5299
5805
  const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
5300
5806
  await saveIdMapping(this.dataSyncDir, mergedMapping);
@@ -5320,13 +5826,14 @@ var SyncHandler = class extends BaseCommand {
5320
5826
  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
5827
  }
5322
5828
  try {
5323
- await git("-C", worktreePath, "commit", "--no-verify", "-m", "tbd sync: resolved merge conflicts");
5829
+ await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
5324
5830
  } catch {
5325
5831
  this.output.debug("No merge commit needed (conflicts already resolved)");
5326
5832
  }
5327
5833
  }
5328
5834
  }
5329
5835
  } catch (error) {
5836
+ if (error instanceof SyncError) throw error;
5330
5837
  this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
5331
5838
  }
5332
5839
  let aheadCommits = 0;
@@ -5359,6 +5866,7 @@ var SyncHandler = class extends BaseCommand {
5359
5866
  summary.conflicts = conflicts.length;
5360
5867
  spinner.stop();
5361
5868
  if (pushFailed) {
5869
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
5362
5870
  let displayError = pushError;
5363
5871
  const httpMatch = /HTTP (\d+)/.exec(pushError);
5364
5872
  const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
@@ -5813,16 +6321,23 @@ function renderBeadsWarning(colors) {
5813
6321
  * Used by: status
5814
6322
  *
5815
6323
  * @param path - Worktree path
5816
- * @param healthy - Whether worktree is healthy
6324
+ * @param status - 'valid' (healthy), 'missing' (created on next sync), or 'prunable' /
6325
+ * 'corrupted' (truly unhealthy and needs repair)
5817
6326
  * @param colors - Color functions
5818
6327
  */
5819
- function renderWorktreeStatus(path, healthy, colors) {
6328
+ function renderWorktreeStatus(path, status, colors) {
5820
6329
  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");
6330
+ if (status === "valid") {
6331
+ console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
6332
+ return;
6333
+ }
6334
+ if (status === "missing") {
6335
+ console.log(`${colors.dim("Worktree:")} ${path} (not initialized)`);
6336
+ console.log(" Run: tbd sync (or tbd doctor --fix) to initialize");
6337
+ return;
5825
6338
  }
6339
+ console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
6340
+ console.log(" Run: tbd doctor --fix");
5826
6341
  }
5827
6342
  /**
5828
6343
  * Render footer with command suggestions.
@@ -6037,6 +6552,7 @@ var StatusHandler = class extends BaseCommand {
6037
6552
  display_prefix: null,
6038
6553
  worktree_path: null,
6039
6554
  worktree_healthy: null,
6555
+ worktree_status: null,
6040
6556
  workspaces: [],
6041
6557
  integrations: {
6042
6558
  portable_skill: false,
@@ -6150,10 +6666,11 @@ var StatusHandler = class extends BaseCommand {
6150
6666
  data.remote = config.sync.remote;
6151
6667
  data.display_prefix = config.display.id_prefix;
6152
6668
  } catch {}
6153
- const worktreePath = join(cwd, WORKTREE_DIR);
6154
- const worktreeHealth = await checkWorktreeHealth(cwd);
6155
- data.worktree_path = worktreePath;
6669
+ const sharedPaths = await resolveSharedTbdPaths(cwd);
6670
+ const worktreeHealth = await checkWorktreeHealth(cwd, data.sync_branch ?? void 0);
6671
+ data.worktree_path = sharedPaths.sharedWorktreePath;
6156
6672
  data.worktree_healthy = worktreeHealth.valid;
6673
+ data.worktree_status = worktreeHealth.status;
6157
6674
  try {
6158
6675
  data.workspaces = await listWorkspaces(cwd);
6159
6676
  } catch {}
@@ -6204,7 +6721,7 @@ var StatusHandler = class extends BaseCommand {
6204
6721
  console.log("");
6205
6722
  console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
6206
6723
  }
6207
- if (data.worktree_healthy !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_healthy, colors);
6724
+ if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
6208
6725
  if (data.workspaces.length > 0) {
6209
6726
  console.log("");
6210
6727
  console.log(colors.bold("WORKSPACES"));
@@ -6302,7 +6819,8 @@ var StatsHandler = class extends BaseCommand {
6302
6819
  const tbdRoot = await requireInit();
6303
6820
  let issues;
6304
6821
  try {
6305
- issues = await listIssues(await resolveDataSyncDir(tbdRoot));
6822
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
6823
+ issues = await listIssues(dataSyncDir);
6306
6824
  } catch {
6307
6825
  throw new NotInitializedError("No issue store found. Run `tbd init` first.");
6308
6826
  }
@@ -6485,6 +7003,35 @@ function renderDiagnostics(results, colors) {
6485
7003
  *
6486
7004
  * See: tbd-design.md §4.9 Doctor
6487
7005
  */
7006
+ /**
7007
+ * Map remote sync-branch health to a "Remote sync branch" diagnostic.
7008
+ *
7009
+ * Returns null when the remote branch does not exist (the caller then falls
7010
+ * through to local-branch / new-repo handling). Unrelated histories are a hard
7011
+ * ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` — push
7012
+ * can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
7013
+ */
7014
+ function classifyRemoteSyncHealth(health, remote, syncBranch) {
7015
+ if (!health.exists) return null;
7016
+ if (health.unrelated) return {
7017
+ name: "Remote sync branch",
7018
+ status: "error",
7019
+ fixable: true,
7020
+ message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) — push cannot succeed`,
7021
+ suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
7022
+ };
7023
+ if (health.diverged) return {
7024
+ name: "Remote sync branch",
7025
+ status: "warn",
7026
+ message: `${remote}/${syncBranch} has diverged`,
7027
+ suggestion: "Run: tbd sync to reconcile changes"
7028
+ };
7029
+ return {
7030
+ name: "Remote sync branch",
7031
+ status: "ok",
7032
+ message: `${remote}/${syncBranch}`
7033
+ };
7034
+ }
6488
7035
  const CONFIG_DIR = TBD_DIR;
6489
7036
  var DoctorHandler = class extends BaseCommand {
6490
7037
  dataSyncDir = "";
@@ -6495,7 +7042,7 @@ var DoctorHandler = class extends BaseCommand {
6495
7042
  async run(options) {
6496
7043
  const tbdRoot = await requireInit();
6497
7044
  this.cwd = tbdRoot;
6498
- this.dataSyncDir = await resolveDataSyncDir(tbdRoot);
7045
+ this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
6499
7046
  try {
6500
7047
  this.config = await readConfig(this.cwd);
6501
7048
  } catch {}
@@ -6519,10 +7066,11 @@ var DoctorHandler = class extends BaseCommand {
6519
7066
  healthChecks.push(await this.checkTempFiles(options.fix));
6520
7067
  healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
6521
7068
  healthChecks.push(await this.checkWorktree(options.fix));
7069
+ healthChecks.push(await this.checkCommonDirLayout(options.fix));
6522
7070
  const dataLocationResult = await this.checkDataLocation(options.fix);
6523
7071
  healthChecks.push(dataLocationResult);
6524
7072
  if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
6525
- this.dataSyncDir = await resolveDataSyncDir(this.cwd);
7073
+ this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
6526
7074
  try {
6527
7075
  this.invalidIssueFiles = [];
6528
7076
  this.issues = await listIssues(this.dataSyncDir, {
@@ -6535,7 +7083,7 @@ var DoctorHandler = class extends BaseCommand {
6535
7083
  const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
6536
7084
  healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
6537
7085
  healthChecks.push(await this.checkLocalSyncBranch());
6538
- healthChecks.push(await this.checkRemoteSyncBranch());
7086
+ healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
6539
7087
  healthChecks.push(await this.checkLocalVsRemoteData());
6540
7088
  healthChecks.push(await this.checkCloneScenarios());
6541
7089
  healthChecks.push(await this.checkSyncConsistency());
@@ -6546,6 +7094,7 @@ var DoctorHandler = class extends BaseCommand {
6546
7094
  integrationChecks.push(await this.checkCodexHooks());
6547
7095
  const allChecks = [...healthChecks, ...integrationChecks];
6548
7096
  const allOk = allChecks.every((c) => c.status === "ok");
7097
+ const hasErrors = allChecks.some((c) => c.status === "error");
6549
7098
  const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
6550
7099
  this.output.data({
6551
7100
  statusInfo,
@@ -6581,11 +7130,12 @@ var DoctorHandler = class extends BaseCommand {
6581
7130
  else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
6582
7131
  else this.output.warn("Issues found that may require manual intervention.");
6583
7132
  });
7133
+ if (hasErrors) process.exitCode = 1;
6584
7134
  }
6585
7135
  async gatherStatusInfo() {
6586
7136
  let gitBranch = null;
6587
7137
  try {
6588
- gitBranch = await getCurrentBranch();
7138
+ gitBranch = await getCurrentBranch(this.cwd);
6589
7139
  } catch {}
6590
7140
  const worktreeHealth = await checkWorktreeHealth(this.cwd);
6591
7141
  return {
@@ -6612,7 +7162,7 @@ var DoctorHandler = class extends BaseCommand {
6612
7162
  if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
6613
7163
  }
6614
7164
  let remoteTotal = null;
6615
- if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync");
7165
+ if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync", this.cwd);
6616
7166
  return {
6617
7167
  total: this.issues.length,
6618
7168
  ready: readyCount,
@@ -6670,6 +7220,13 @@ var DoctorHandler = class extends BaseCommand {
6670
7220
  path: configPath,
6671
7221
  suggestion: "Run: tbd init"
6672
7222
  };
7223
+ if (error instanceof IncompatibleFormatError) return {
7224
+ name: "Config file",
7225
+ status: "error",
7226
+ message: `requires newer tbd (found ${error.foundFormat}, supported ${error.supportedFormat})`,
7227
+ path: configPath,
7228
+ suggestion: "Upgrade: npm install -g get-tbd@latest"
7229
+ };
6673
7230
  return {
6674
7231
  name: "Config file",
6675
7232
  status: "error",
@@ -6761,7 +7318,7 @@ var DoctorHandler = class extends BaseCommand {
6761
7318
  status: "ok"
6762
7319
  };
6763
7320
  if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
6764
- const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7321
+ const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
6765
7322
  const resolved = resolveIdMappingConflicts(content);
6766
7323
  await saveIdMapping(this.dataSyncDir, resolved);
6767
7324
  return {
@@ -6810,7 +7367,7 @@ var DoctorHandler = class extends BaseCommand {
6810
7367
  status: "ok"
6811
7368
  };
6812
7369
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
6813
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-Ctfl_nc1.mjs");
7370
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
6814
7371
  const mapping = await loadIdMapping(this.dataSyncDir);
6815
7372
  await saveIdMapping(this.dataSyncDir, mapping);
6816
7373
  return {
@@ -6949,7 +7506,7 @@ var DoctorHandler = class extends BaseCommand {
6949
7506
  name: "ID mapping coverage",
6950
7507
  status: "ok"
6951
7508
  };
6952
- const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7509
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
6953
7510
  const mapping = await loadIdMapping(this.dataSyncDir);
6954
7511
  const missingIds = [];
6955
7512
  for (const issue of this.issues) {
@@ -6961,10 +7518,10 @@ var DoctorHandler = class extends BaseCommand {
6961
7518
  status: "ok"
6962
7519
  };
6963
7520
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
6964
- const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-Ctfl_nc1.mjs");
7521
+ const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
6965
7522
  let historicalMapping;
6966
7523
  try {
6967
- const syncBranch = (await import("./config-BPHcePSm.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
7524
+ const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
6968
7525
  const logArgs = ["log", "--format=%H"];
6969
7526
  if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
6970
7527
  logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
@@ -7095,24 +7652,51 @@ var DoctorHandler = class extends BaseCommand {
7095
7652
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
7096
7653
  */
7097
7654
  async checkWorktree(fix) {
7098
- const worktreePath = WORKTREE_DIR;
7099
- const worktreeHealth = await checkWorktreeHealth(this.cwd);
7655
+ const worktreePath = (await resolveSharedTbdPaths(this.cwd)).sharedWorktreePath;
7656
+ const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7657
+ const remote = this.config?.sync.remote ?? "origin";
7658
+ const worktreeHealth = await checkWorktreeHealth(this.cwd, syncBranch);
7100
7659
  switch (worktreeHealth.status) {
7101
7660
  case "valid": return {
7102
7661
  name: "Worktree",
7103
7662
  status: "ok",
7104
7663
  path: worktreePath
7105
7664
  };
7106
- case "missing": return {
7107
- name: "Worktree",
7108
- status: "ok",
7109
- message: "not created yet",
7110
- path: worktreePath
7111
- };
7665
+ case "missing":
7666
+ if (fix && !this.checkDryRun("Initialize shared data-sync worktree")) {
7667
+ try {
7668
+ await withSharedDataSyncLock(this.cwd, async () => {
7669
+ await prepareDataSyncContext(this.cwd);
7670
+ });
7671
+ } catch (error) {
7672
+ return {
7673
+ name: "Worktree",
7674
+ status: "error",
7675
+ message: `initialization failed: ${error instanceof Error ? error.message : String(error)}`,
7676
+ path: worktreePath
7677
+ };
7678
+ }
7679
+ try {
7680
+ this.config = await readConfig(this.cwd);
7681
+ } catch {}
7682
+ return {
7683
+ name: "Worktree",
7684
+ status: "ok",
7685
+ message: "initialized",
7686
+ path: worktreePath
7687
+ };
7688
+ }
7689
+ return {
7690
+ name: "Worktree",
7691
+ status: "ok",
7692
+ message: "not created yet",
7693
+ path: worktreePath
7694
+ };
7112
7695
  case "prunable":
7113
7696
  case "corrupted":
7114
7697
  if (fix && !this.checkDryRun("Repair worktree")) {
7115
- const result = await repairWorktree(this.cwd, worktreeHealth.status);
7698
+ const repairStatus = worktreeHealth.status;
7699
+ const result = await withSharedDataSyncLock(this.cwd, async () => repairWorktree(this.cwd, repairStatus, remote, syncBranch));
7116
7700
  if (result.success) return {
7117
7701
  name: "Worktree",
7118
7702
  status: "ok",
@@ -7155,10 +7739,80 @@ var DoctorHandler = class extends BaseCommand {
7155
7739
  }
7156
7740
  }
7157
7741
  /**
7742
+ * Check $GIT_COMMON_DIR/tbd/layout.yml against the checkout config.
7743
+ *
7744
+ * Reports missing (initialized on next mutating command), mismatched (rewrite
7745
+ * from config under --fix), or future-format (requires newer tbd, no fix).
7746
+ *
7747
+ * See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Format And Layout
7748
+ * Versioning.
7749
+ */
7750
+ async checkCommonDirLayout(fix) {
7751
+ if (!this.config) return {
7752
+ name: "Common-dir layout",
7753
+ status: "ok",
7754
+ message: "skipped (no config)"
7755
+ };
7756
+ const sharedPaths = await resolveSharedTbdPaths(this.cwd);
7757
+ const layoutPath = sharedPaths.sharedLayoutPath;
7758
+ let layout;
7759
+ try {
7760
+ layout = await readCommonDirLayout(layoutPath);
7761
+ } catch (error) {
7762
+ return {
7763
+ name: "Common-dir layout",
7764
+ status: "error",
7765
+ message: error instanceof Error ? error.message : String(error),
7766
+ path: layoutPath
7767
+ };
7768
+ }
7769
+ if (!layout) return {
7770
+ name: "Common-dir layout",
7771
+ status: "ok",
7772
+ message: "not initialized yet (created on first sync)",
7773
+ path: layoutPath
7774
+ };
7775
+ if (!isCompatibleFormat(layout.tbd_format)) return {
7776
+ name: "Common-dir layout",
7777
+ status: "error",
7778
+ message: `requires newer tbd (found ${layout.tbd_format})`,
7779
+ path: layoutPath,
7780
+ suggestion: "Upgrade: npm install -g get-tbd@latest"
7781
+ };
7782
+ try {
7783
+ validateCommonDirLayout(layout, this.config);
7784
+ return {
7785
+ name: "Common-dir layout",
7786
+ status: "ok",
7787
+ path: layoutPath
7788
+ };
7789
+ } catch (error) {
7790
+ if (!(error instanceof CommonDirLayoutError)) throw error;
7791
+ if (fix && !this.checkDryRun("Repair common-dir layout")) {
7792
+ const configRef = this.config;
7793
+ await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef, layout));
7794
+ return {
7795
+ name: "Common-dir layout",
7796
+ status: "ok",
7797
+ message: "rewritten from config",
7798
+ path: layoutPath
7799
+ };
7800
+ }
7801
+ return {
7802
+ name: "Common-dir layout",
7803
+ status: "error",
7804
+ message: "mismatched with config",
7805
+ path: layoutPath,
7806
+ fixable: true,
7807
+ suggestion: "Run: tbd doctor --fix"
7808
+ };
7809
+ }
7810
+ }
7811
+ /**
7158
7812
  * Check for issues in wrong location.
7159
7813
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
7160
7814
  *
7161
- * Issues should be in .tbd/data-sync-worktree/.tbd/data-sync/issues/
7815
+ * Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/issues/
7162
7816
  * If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
7163
7817
  * and data was written to the fallback path - this is a bug requiring migration.
7164
7818
  */
@@ -7176,7 +7830,7 @@ var DoctorHandler = class extends BaseCommand {
7176
7830
  if (fix && !this.checkDryRun("Migrate data to worktree")) {
7177
7831
  let worktreeHealth = await checkWorktreeHealth(this.cwd);
7178
7832
  if (worktreeHealth.status === "missing") {
7179
- const initResult = await initWorktree(this.cwd);
7833
+ const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
7180
7834
  if (!initResult.success) return {
7181
7835
  name: "Data location",
7182
7836
  status: "error",
@@ -7219,7 +7873,7 @@ var DoctorHandler = class extends BaseCommand {
7219
7873
  path: wrongIssuesPath,
7220
7874
  details: [
7221
7875
  `Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
7222
- "Issues should be in .tbd/data-sync-worktree/.tbd/data-sync/",
7876
+ "Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/",
7223
7877
  "This indicates the worktree was missing when issues were created"
7224
7878
  ],
7225
7879
  fixable: true,
@@ -7232,14 +7886,14 @@ var DoctorHandler = class extends BaseCommand {
7232
7886
  */
7233
7887
  async checkLocalSyncBranch() {
7234
7888
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7235
- const localHealth = await checkLocalBranchHealth(syncBranch);
7889
+ const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
7236
7890
  if (localHealth.exists && !localHealth.orphaned) return {
7237
7891
  name: "Local sync branch",
7238
7892
  status: "ok",
7239
7893
  message: syncBranch
7240
7894
  };
7241
7895
  if (!localHealth.exists) {
7242
- if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
7896
+ if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
7243
7897
  name: "Local sync branch",
7244
7898
  status: "warn",
7245
7899
  message: `${syncBranch} not found (remote exists)`,
@@ -7262,24 +7916,28 @@ var DoctorHandler = class extends BaseCommand {
7262
7916
  * Check remote sync branch health.
7263
7917
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
7264
7918
  */
7265
- async checkRemoteSyncBranch() {
7919
+ async checkRemoteSyncBranch(fix) {
7266
7920
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7267
7921
  const remote = this.config?.sync.remote ?? "origin";
7268
- const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
7269
- if (remoteHealth.exists) {
7270
- if (remoteHealth.diverged) return {
7922
+ const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
7923
+ if (remoteHealth.unrelated && fix && !this.checkDryRun("Rescue unrelated tbd-sync histories")) try {
7924
+ const result = await withSharedDataSyncLock(this.cwd, async () => rescueUnrelatedHistory(this.cwd, remote, syncBranch));
7925
+ return {
7271
7926
  name: "Remote sync branch",
7272
- status: "warn",
7273
- message: `${remote}/${syncBranch} has diverged`,
7274
- suggestion: "Run: tbd sync to reconcile changes"
7927
+ status: "ok",
7928
+ message: `rescued: adopted ${remote}/${syncBranch} base, reconciled ${result.localOnly} local-only + ${result.merged} merged (backup: ${result.backupBranch})`
7275
7929
  };
7930
+ } catch (error) {
7276
7931
  return {
7277
7932
  name: "Remote sync branch",
7278
- status: "ok",
7279
- message: `${remote}/${syncBranch}`
7933
+ status: "error",
7934
+ message: `rescue failed: ${error instanceof Error ? error.message : String(error)}`,
7935
+ suggestion: "Resolve manually; the pre-rescue state is on the tbd-backup-* branch"
7280
7936
  };
7281
7937
  }
7282
- if ((await checkLocalBranchHealth(syncBranch)).exists) return {
7938
+ const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
7939
+ if (diag) return diag;
7940
+ if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
7283
7941
  name: "Remote sync branch",
7284
7942
  status: "warn",
7285
7943
  message: `${remote}/${syncBranch} not found`,
@@ -7308,7 +7966,7 @@ var DoctorHandler = class extends BaseCommand {
7308
7966
  status: "ok"
7309
7967
  };
7310
7968
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7311
- if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
7969
+ if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
7312
7970
  name: "Sync status",
7313
7971
  status: "warn",
7314
7972
  message: `${localIssueCount} local issues, remote branch not found`,
@@ -7454,6 +8112,7 @@ var ConfigShowHandler = class extends BaseCommand {
7454
8112
  console.log(`${colors.dim("sync:")}`);
7455
8113
  console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
7456
8114
  console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
8115
+ console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
7457
8116
  console.log(`${colors.dim("display:")}`);
7458
8117
  console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
7459
8118
  console.log(`${colors.dim("settings:")}`);
@@ -7589,9 +8248,9 @@ async function listAtticEntries(tbdRoot, filterById) {
7589
8248
  var AtticListHandler = class extends BaseCommand {
7590
8249
  async run(id) {
7591
8250
  const tbdRoot = await requireInit();
8251
+ const { mapping, config } = await loadDataContext(tbdRoot);
7592
8252
  const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
7593
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7594
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
8253
+ const prefix = config.display.id_prefix;
7595
8254
  const showDebug = this.ctx.debug;
7596
8255
  const output = entries.map((e) => ({
7597
8256
  id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
@@ -7616,10 +8275,10 @@ var AtticListHandler = class extends BaseCommand {
7616
8275
  var AtticShowHandler = class extends BaseCommand {
7617
8276
  async run(id, timestamp) {
7618
8277
  const tbdRoot = await requireInit();
8278
+ const { mapping, config } = await loadDataContext(tbdRoot);
7619
8279
  const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7620
8280
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
7621
- const mapping = await loadIdMapping(await resolveDataSyncDir(tbdRoot));
7622
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
8281
+ const prefix = config.display.id_prefix;
7623
8282
  const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
7624
8283
  this.output.data(entry, () => {
7625
8284
  const colors = this.output.getColors();
@@ -7645,6 +8304,7 @@ var AtticShowHandler = class extends BaseCommand {
7645
8304
  var AtticRestoreHandler = class extends BaseCommand {
7646
8305
  async run(id, timestamp) {
7647
8306
  const tbdRoot = await requireInit();
8307
+ await loadDataContext(tbdRoot);
7648
8308
  const normalizedId = normalizeIssueId(id);
7649
8309
  const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
7650
8310
  if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
@@ -7652,24 +8312,24 @@ var AtticRestoreHandler = class extends BaseCommand {
7652
8312
  id: normalizedId,
7653
8313
  field: entry.field
7654
8314
  })) return;
7655
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
7656
- let issue;
7657
- try {
7658
- issue = await readIssue(dataSyncDir, normalizedId);
7659
- } catch {
7660
- throw new NotFoundError("Issue", id);
7661
- }
7662
- const field = entry.field;
7663
- if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
7664
- else throw new ValidationError(`Cannot restore field: ${entry.field}`);
7665
- issue.version += 1;
7666
- issue.updated_at = now();
8315
+ let displayId = id;
7667
8316
  await this.execute(async () => {
7668
- await writeIssue(dataSyncDir, issue);
8317
+ await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
8318
+ let issue;
8319
+ try {
8320
+ issue = await readIssue(dataSyncDir, normalizedId);
8321
+ } catch {
8322
+ throw new NotFoundError("Issue", id);
8323
+ }
8324
+ const field = entry.field;
8325
+ if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
8326
+ else throw new ValidationError(`Cannot restore field: ${entry.field}`);
8327
+ issue.version += 1;
8328
+ issue.updated_at = now();
8329
+ await writeIssue(dataSyncDir, issue);
8330
+ displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, config.display.id_prefix) : formatDisplayId(normalizedId, mapping, config.display.id_prefix);
8331
+ });
7669
8332
  }, "Failed to restore from attic");
7670
- const mapping = await loadIdMapping(dataSyncDir);
7671
- const prefix = (await readConfig(tbdRoot)).display.id_prefix;
7672
- const displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, prefix) : formatDisplayId(normalizedId, mapping, prefix);
7673
8333
  this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
7674
8334
  }
7675
8335
  };
@@ -7789,14 +8449,18 @@ var ImportHandler = class extends BaseCommand {
7789
8449
  if (!file && !options.validate) throw new ValidationError("Provide a JSONL file path to import.\n\nFor Beads migration, use: tbd setup --from-beads\nFor workspace import, use: tbd import --workspace=<name> or --outbox");
7790
8450
  if (options.validate) {
7791
8451
  this.tbdRoot = await requireInit();
7792
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7793
- await this.validateImport(options);
8452
+ await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8453
+ this.dataSyncDir = dataSyncDir;
8454
+ await this.validateImport(options);
8455
+ });
7794
8456
  return;
7795
8457
  }
7796
8458
  if (file) {
7797
8459
  this.tbdRoot = await requireInit();
7798
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7799
- await this.importFromFile(file, options);
8460
+ await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8461
+ this.dataSyncDir = dataSyncDir;
8462
+ await this.importFromFile(file, options);
8463
+ });
7800
8464
  }
7801
8465
  }
7802
8466
  /**
@@ -7804,7 +8468,6 @@ var ImportHandler = class extends BaseCommand {
7804
8468
  */
7805
8469
  async importFromWorkspaceCmd(options) {
7806
8470
  this.tbdRoot = await requireInit();
7807
- this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
7808
8471
  const wsOptions = {
7809
8472
  workspace: options.workspace,
7810
8473
  dir: options.dir,
@@ -7815,7 +8478,10 @@ var ImportHandler = class extends BaseCommand {
7815
8478
  const spinner = this.output.spinner("Importing from workspace...");
7816
8479
  wsOptions.logger = this.output.logger(spinner);
7817
8480
  const result = await this.execute(async () => {
7818
- return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
8481
+ return await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
8482
+ this.dataSyncDir = dataSyncDir;
8483
+ return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
8484
+ });
7819
8485
  }, "Failed to import from workspace");
7820
8486
  spinner.stop();
7821
8487
  if (!result) return;
@@ -8461,7 +9127,12 @@ var UninstallHandler = class extends BaseCommand {
8461
9127
  const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
8462
9128
  const remote = config?.sync.remote ?? "origin";
8463
9129
  const tbdDir = join(tbdRoot, ".tbd");
8464
- const worktreePath = join(tbdDir, "data-sync-worktree");
9130
+ const sharedPaths = await resolveSharedTbdPaths(tbdRoot).catch((error) => {
9131
+ this.output.debug(`resolveSharedTbdPaths failed: ${error instanceof Error ? error.message : String(error)}`);
9132
+ return null;
9133
+ });
9134
+ const worktreePath = sharedPaths?.sharedWorktreePath ?? join(tbdDir, "data-sync-worktree");
9135
+ const legacyWorktreePath = join(tbdDir, "data-sync-worktree");
8465
9136
  const displayPath = (p) => relative(process.cwd(), p) || p;
8466
9137
  const items = [];
8467
9138
  let worktreeExists = false;
@@ -8471,6 +9142,13 @@ var UninstallHandler = class extends BaseCommand {
8471
9142
  const worktreeStats = await this.getDirectoryStats(worktreePath);
8472
9143
  items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
8473
9144
  } catch {}
9145
+ let legacyWorktreeExists = false;
9146
+ if (legacyWorktreePath !== worktreePath) try {
9147
+ await access(legacyWorktreePath);
9148
+ legacyWorktreeExists = true;
9149
+ const legacyStats = await this.getDirectoryStats(legacyWorktreePath);
9150
+ items.push(` - Legacy worktree: ${displayPath(legacyWorktreePath)} (${legacyStats.files} files)`);
9151
+ } catch {}
8474
9152
  let localBranchExists = false;
8475
9153
  try {
8476
9154
  execSync(`git rev-parse --verify ${syncBranch}`, {
@@ -8534,6 +9212,23 @@ var UninstallHandler = class extends BaseCommand {
8534
9212
  console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
8535
9213
  }
8536
9214
  }
9215
+ if (legacyWorktreeExists) try {
9216
+ execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
9217
+ encoding: "utf-8",
9218
+ stdio: [
9219
+ "ignore",
9220
+ "pipe",
9221
+ "ignore"
9222
+ ]
9223
+ });
9224
+ console.log(` ${colors.success("✓")} Removed legacy git worktree`);
9225
+ } catch {
9226
+ await rm(legacyWorktreePath, {
9227
+ recursive: true,
9228
+ force: true
9229
+ });
9230
+ console.log(` ${colors.success("✓")} Removed legacy worktree directory`);
9231
+ }
8537
9232
  if (localBranchExists && !options.keepBranch) try {
8538
9233
  execSync(`git branch -D ${syncBranch}`, {
8539
9234
  encoding: "utf-8",
@@ -8579,6 +9274,15 @@ var UninstallHandler = class extends BaseCommand {
8579
9274
  } catch (error) {
8580
9275
  throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
8581
9276
  }
9277
+ if (sharedPaths) try {
9278
+ await rm(sharedPaths.sharedTbdDir, {
9279
+ recursive: true,
9280
+ force: true
9281
+ });
9282
+ console.log(` ${colors.success("✓")} Removed shared common-dir metadata`);
9283
+ } catch {
9284
+ console.log(` ${colors.warn("⚠")} Could not remove shared common-dir metadata`);
9285
+ }
8582
9286
  console.log("");
8583
9287
  this.output.success("tbd has been uninstalled from this repository.");
8584
9288
  if (options.keepBranch && localBranchExists) {
@@ -9061,6 +9765,7 @@ var PrimeHandler = class extends BaseCommand {
9061
9765
  console.log(`${colors.success("✓")} Initialized in this repo`);
9062
9766
  if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
9063
9767
  else console.log(`${colors.dim("✗")} Hooks not installed (run: tbd setup --auto)`);
9768
+ console.log(colors.dim(" Run `tbd setup --auto` to refresh skills and settings (e.g. after upgrading tbd)."));
9064
9769
  console.log("");
9065
9770
  console.log(colors.bold("=== PROJECT STATUS ==="));
9066
9771
  try {
@@ -9149,7 +9854,8 @@ var PrimeHandler = class extends BaseCommand {
9149
9854
  */
9150
9855
  async getIssueStats(tbdRoot) {
9151
9856
  try {
9152
- const issues = await listIssues(await resolveDataSyncDir(tbdRoot));
9857
+ const { dataSyncDir } = await loadDataContext(tbdRoot);
9858
+ const issues = await listIssues(dataSyncDir);
9153
9859
  let open = 0;
9154
9860
  let inProgress = 0;
9155
9861
  const blockedIds = /* @__PURE__ */ new Set();
@@ -10002,9 +10708,11 @@ async function getShortcutDirectory(quiet = false) {
10002
10708
  }
10003
10709
  /**
10004
10710
  * DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
10711
+ * Carries the shared `format=fNN` integration code so the forward-compatibility
10712
+ * guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
10005
10713
  * Formatted to match flowmark output.
10006
10714
  */
10007
- const SKILL_DO_NOT_EDIT_MARKER = "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->";
10715
+ const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;
10008
10716
  /**
10009
10717
  * Build the full generated SKILL.md payload: the bundled skill content with the
10010
10718
  * shortcut/guideline directory appended and a DO NOT EDIT marker after the
@@ -10022,8 +10730,19 @@ async function buildSkillPayload(quiet = false) {
10022
10730
  }
10023
10731
  /**
10024
10732
  * Write a generated SKILL.md payload to a target path, creating parent dirs.
10733
+ *
10734
+ * Forward-compatibility guard: if an existing skill carries a newer
10735
+ * `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
10736
+ * the user to upgrade. Guarding each generated surface at its own write point
10737
+ * means an older tbd cannot partial-downgrade a newer committed skill,
10738
+ * regardless of the order in which surfaces are written.
10025
10739
  */
10026
10740
  async function writeSkillFile(targetPath, payload) {
10741
+ let existing = null;
10742
+ try {
10743
+ existing = await readFile(targetPath, "utf-8");
10744
+ } catch {}
10745
+ if (existing !== null) assertNotNewerFormat(existing, targetPath);
10027
10746
  await mkdir(dirname(targetPath), { recursive: true });
10028
10747
  await writeFile(targetPath, payload);
10029
10748
  }
@@ -10825,25 +11544,26 @@ var SetupDefaultHandler = class extends BaseCommand {
10825
11544
  if (!gitRoot) throw new CLIError("Could not determine git repository root.");
10826
11545
  const projectDir = gitRoot;
10827
11546
  const hasTbd = await isInitialized(projectDir);
10828
- const hasBeads = await pathExists(join(projectDir, ".beads"));
11547
+ const hasBeads = await pathExists$1(join(projectDir, ".beads"));
10829
11548
  if (options.fromBeads && !hasBeads) throw new CLIError("The --from-beads flag requires a .beads/ directory to migrate from.\nFor fresh setup, use: tbd setup --auto --prefix=<name>");
10830
11549
  console.log("Checking repository...");
10831
11550
  console.log(` ${colors.success("✓")} Git repository detected`);
10832
11551
  if (hasTbd) {
10833
11552
  const { config, migrated, changes } = await readConfigWithMigration(projectDir);
10834
11553
  console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
10835
- let needsConfigWrite = migrated;
11554
+ let ghCliChanged = false;
10836
11555
  if (options.ghCli === false && config.settings.use_gh_cli !== false) {
10837
11556
  config.settings.use_gh_cli = false;
10838
- needsConfigWrite = true;
11557
+ ghCliChanged = true;
10839
11558
  }
10840
- if (needsConfigWrite) {
11559
+ if (migrated) {
11560
+ await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
11561
+ console.log(` ${colors.success("✓")} Config migrated to latest format`);
11562
+ for (const change of changes) console.log(` ${colors.dim(change)}`);
11563
+ }
11564
+ if (ghCliChanged) {
10841
11565
  await writeConfig(projectDir, config);
10842
- if (migrated) {
10843
- console.log(` ${colors.success("✓")} Config migrated to latest format`);
10844
- for (const change of changes) console.log(` ${colors.dim(change)}`);
10845
- }
10846
- if (options.ghCli === false) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
11566
+ console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
10847
11567
  }
10848
11568
  console.log("");
10849
11569
  await this.handleAlreadyInitialized(projectDir, isAutoMode);
@@ -10991,7 +11711,7 @@ Example:
10991
11711
  }
10992
11712
  async initializeTbd(cwd, prefix) {
10993
11713
  const colors = this.output.getColors();
10994
- await initConfig(cwd, VERSION, prefix);
11714
+ const config = await initConfig(cwd, VERSION, prefix);
10995
11715
  console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
10996
11716
  const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
10997
11717
  "# Synced documentation cache (regenerated by tbd sync --docs)",
@@ -11022,7 +11742,10 @@ Example:
11022
11742
  if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
11023
11743
  else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
11024
11744
  try {
11025
- await initWorktree(cwd);
11745
+ await withSharedDataSyncLock(cwd, async () => {
11746
+ await initWorktree(cwd);
11747
+ await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
11748
+ });
11026
11749
  const health = await checkWorktreeHealth(cwd);
11027
11750
  if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
11028
11751
  else {
@@ -11220,18 +11943,18 @@ var SetupAutoHandler = class extends BaseCommand {
11220
11943
  };
11221
11944
  if (mode === "off") return result;
11222
11945
  if (mode === "auto") {
11223
- const hasClaudeDir = await pathExists(GLOBAL_CLAUDE_DIR);
11946
+ const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
11224
11947
  const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
11225
11948
  if (!hasClaudeDir && !hasClaudeEnv) return result;
11226
11949
  }
11227
11950
  result.detected = true;
11228
11951
  const claudePaths = getClaudePaths(cwd);
11229
11952
  try {
11230
- if (await pathExists(claudePaths.settings)) {
11953
+ if (await pathExists$1(claudePaths.settings)) {
11231
11954
  const content = await readFile(claudePaths.settings, "utf-8");
11232
11955
  const hooks = JSON.parse(content).hooks;
11233
11956
  if (hooks) {
11234
- if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists(claudePaths.skill)) result.alreadyInstalled = true;
11957
+ if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists$1(claudePaths.skill)) result.alreadyInstalled = true;
11235
11958
  }
11236
11959
  }
11237
11960
  const handler = new SetupClaudeHandler(this.cmd);
@@ -11252,7 +11975,7 @@ var SetupAutoHandler = class extends BaseCommand {
11252
11975
  };
11253
11976
  if (mode === "off") return result;
11254
11977
  const agentsPath = getAgentsMdPath(cwd);
11255
- const hasAgentsMd = await pathExists(agentsPath);
11978
+ const hasAgentsMd = await pathExists$1(agentsPath);
11256
11979
  if (mode === "auto") {
11257
11980
  const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
11258
11981
  if (!hasAgentsMd && !hasCodexEnv) return result;
@@ -11322,7 +12045,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
11322
12045
  var SaveHandler = class extends BaseCommand {
11323
12046
  async run(options) {
11324
12047
  const tbdRoot = await requireInit();
11325
- const dataSyncDir = await resolveDataSyncDir(tbdRoot);
11326
12048
  if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
11327
12049
  const saveOptions = {
11328
12050
  workspace: options.workspace,
@@ -11334,7 +12056,9 @@ var SaveHandler = class extends BaseCommand {
11334
12056
  const spinner = this.output.spinner("Saving issues...");
11335
12057
  saveOptions.logger = this.output.logger(spinner);
11336
12058
  const result = await this.execute(async () => {
11337
- return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
12059
+ return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
12060
+ return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
12061
+ });
11338
12062
  }, "Failed to save issues");
11339
12063
  spinner.stop();
11340
12064
  if (!result) return;