get-tbd 0.2.0 → 0.2.2

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 (37) hide show
  1. package/dist/bin.mjs +551 -168
  2. package/dist/bin.mjs.map +1 -1
  3. package/dist/cli.mjs +492 -159
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/docs/SKILL.md +2 -2
  6. package/dist/docs/guidelines/bun-monorepo-patterns.md +65 -66
  7. package/dist/docs/guidelines/cli-agent-skill-patterns.md +396 -158
  8. package/dist/docs/guidelines/common-doc-guidelines.md +2 -2
  9. package/dist/docs/guidelines/convex-limits-best-practices.md +39 -39
  10. package/dist/docs/guidelines/convex-rules.md +13 -13
  11. package/dist/docs/guidelines/electron-app-development-patterns.md +18 -18
  12. package/dist/docs/guidelines/general-comment-rules.md +1 -1
  13. package/dist/docs/guidelines/general-tdd-guidelines.md +4 -4
  14. package/dist/docs/guidelines/golden-testing-guidelines.md +9 -9
  15. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +49 -49
  16. package/dist/docs/guidelines/python-cli-patterns.md +1 -1
  17. package/dist/docs/guidelines/python-modern-guidelines.md +4 -4
  18. package/dist/docs/guidelines/release-notes-guidelines.md +18 -2
  19. package/dist/docs/guidelines/supply-chain-hardening.md +84 -29
  20. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +3 -3
  21. package/dist/docs/guidelines/typescript-cli-tool-rules.md +17 -17
  22. package/dist/docs/guidelines/typescript-code-coverage.md +5 -5
  23. package/dist/docs/guidelines/typescript-rules.md +3 -3
  24. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +3 -3
  25. package/dist/docs/shortcuts/system/skill-baseline.md +2 -2
  26. package/dist/docs/tbd-design.md +40 -40
  27. package/dist/docs/tbd-docs.md +1 -1
  28. package/dist/docs/tbd-prime.md +3 -3
  29. package/dist/{id-mapping-CtfTfGIh.mjs → id-mapping-687_UEsy.mjs} +66 -16
  30. package/dist/id-mapping-687_UEsy.mjs.map +1 -0
  31. package/dist/{id-mapping-CFoPVinz.mjs → id-mapping-mtoSP9Qt.mjs} +1 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/{src-rIE4xSVs.mjs → src-BpvcrLnq.mjs} +2 -2
  34. package/dist/{src-rIE4xSVs.mjs.map → src-BpvcrLnq.mjs.map} +1 -1
  35. package/dist/tbd +551 -168
  36. package/package.json +1 -1
  37. package/dist/id-mapping-CtfTfGIh.mjs.map +0 -1
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
2
- import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-rIE4xSVs.mjs";
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-BpvcrLnq.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
4
  import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
5
- import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-CtfTfGIh.mjs";
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";
@@ -620,6 +620,18 @@ var SyncError = class extends CLIError {
620
620
  }
621
621
  };
622
622
  /**
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
627
+ */
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";
632
+ }
633
+ };
634
+ /**
623
635
  * Classify a sync error to determine appropriate recovery action.
624
636
  *
625
637
  * Used by `tbd sync` to decide whether to:
@@ -917,6 +929,124 @@ function nowFilenameTimestamp() {
917
929
  return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
918
930
  }
919
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
+
920
1050
  //#endregion
921
1051
  //#region src/file/git.ts
922
1052
  /**
@@ -931,6 +1061,55 @@ function nowFilenameTimestamp() {
931
1061
  */
932
1062
  const execFileAsync$1 = promisify(execFile);
933
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
+ /**
934
1113
  * Maximum buffer size for git command output.
935
1114
  *
936
1115
  * Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
@@ -945,8 +1124,31 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
945
1124
  * Uses execFile for security - prevents shell injection attacks.
946
1125
  */
947
1126
  async function git(...args) {
948
- const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
949
- 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
+ }
950
1152
  }
951
1153
  /**
952
1154
  * Run `git commit` in a worktree with gpg signing disabled at the command level.
@@ -1244,15 +1446,42 @@ function mergeIssues(base, local, remote) {
1244
1446
  };
1245
1447
  }
1246
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
+ /**
1247
1476
  * Maximum retry attempts for push operations.
1248
1477
  */
1249
1478
  const MAX_PUSH_RETRIES = 3;
1250
1479
  /**
1251
- * Check if error is a non-fast-forward rejection.
1480
+ * Check if error is a non-fast-forward rejection (also the init race signal).
1252
1481
  */
1253
1482
  function isNonFastForward(error) {
1254
- const msg = error instanceof Error ? error.message : String(error);
1255
- 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);
1256
1485
  }
1257
1486
  /**
1258
1487
  * Push with retry and merge on conflict.
@@ -1315,14 +1544,22 @@ async function branchExists(branch, baseDir) {
1315
1544
  }
1316
1545
  }
1317
1546
  /**
1318
- * 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.
1319
1555
  */
1320
- async function remoteBranchExists(remote, branch, baseDir) {
1556
+ async function probeRemoteBranch(remote, branch, baseDir) {
1557
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
1321
1558
  try {
1322
- await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
1323
- return true;
1324
- } catch {
1325
- return false;
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";
1326
1563
  }
1327
1564
  }
1328
1565
  async function pathExists(path) {
@@ -1542,6 +1779,66 @@ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH)
1542
1779
  }
1543
1780
  }
1544
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
+ }
1841
+ /**
1545
1842
  * Initialize the hidden worktree for the tbd-sync branch.
1546
1843
  * Follows the decision tree from tbd-design.md §2.3.
1547
1844
  *
@@ -1583,7 +1880,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1583
1880
  created: true
1584
1881
  };
1585
1882
  }
1586
- if (await remoteBranchExists(remote, syncBranch, baseDir)) {
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") {
1587
1890
  await git("-C", baseDir, "fetch", remote, syncBranch);
1588
1891
  await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
1589
1892
  return {
@@ -1604,6 +1907,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
1604
1907
  await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
1605
1908
  await git("-C", worktreePath, "add", ".");
1606
1909
  await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
1910
+ if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
1607
1911
  return {
1608
1912
  success: true,
1609
1913
  path: worktreePath,
@@ -1660,22 +1964,30 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
1660
1964
  await git(...dirArgs, "fetch", remote, syncBranch);
1661
1965
  const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
1662
1966
  let diverged = false;
1663
- try {
1664
- const mergeBase = await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`);
1665
- const localHead = await git(...dirArgs, "rev-parse", syncBranch);
1666
- diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
1667
- } catch {
1668
- 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
+ }
1669
1979
  }
1670
1980
  return {
1671
1981
  exists: true,
1672
1982
  diverged,
1983
+ unrelated,
1673
1984
  head: remoteHead
1674
1985
  };
1675
1986
  } catch {
1676
1987
  return {
1677
1988
  exists: false,
1678
- diverged: false
1989
+ diverged: false,
1990
+ unrelated: false
1679
1991
  };
1680
1992
  }
1681
1993
  }
@@ -1714,6 +2026,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
1714
2026
  localBehind
1715
2027
  };
1716
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
+ }
1717
2111
  /**
1718
2112
  * Count issues on a remote sync branch without creating a worktree.
1719
2113
  * Used by doctor to show accurate statistics on fresh clones.
@@ -2061,124 +2455,6 @@ const initCommand = new Command("init").description("Initialize tbd in a git rep
2061
2455
  await new InitHandler(command).run(options);
2062
2456
  });
2063
2457
 
2064
- //#endregion
2065
- //#region src/utils/zod-error-utils.ts
2066
- /**
2067
- * Helpers for rendering Zod errors without relying on object inspection.
2068
- */
2069
- /**
2070
- * Format a ZodError as concise path-qualified messages for CLI output.
2071
- */
2072
- function formatZodError(error) {
2073
- const messages = error.issues.map((issue) => {
2074
- return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
2075
- });
2076
- return messages.length > 0 ? messages.join("; ") : error.message;
2077
- }
2078
- /**
2079
- * Format unknown thrown values as safe strings for warnings and diagnostics.
2080
- */
2081
- function formatUnknownError(error) {
2082
- if (error instanceof ZodError) return formatZodError(error);
2083
- if (error instanceof Error) return error.message;
2084
- return String(error);
2085
- }
2086
-
2087
- //#endregion
2088
- //#region src/file/storage.ts
2089
- /**
2090
- * Storage layer for issue files.
2091
- *
2092
- * Provides atomic file operations and issue CRUD operations.
2093
- * All operations work on the data-sync directory selected by the caller. In production
2094
- * that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
2095
- *
2096
- * See: tbd-design.md §3.2 Storage Layer
2097
- */
2098
- /**
2099
- * Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
2100
- */
2101
- const ISSUE_READ_BATCH_SIZE = 200;
2102
- /**
2103
- * Get the path to an issue file.
2104
- */
2105
- function getIssuePath(baseDir, id) {
2106
- return join(baseDir, "issues", `${id}.md`);
2107
- }
2108
- /**
2109
- * Read an issue from the worktree.
2110
- * @throws If the issue file doesn't exist or is invalid.
2111
- */
2112
- async function readIssue(baseDir, id) {
2113
- return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
2114
- }
2115
- /**
2116
- * Write an issue to the worktree.
2117
- * Uses atomic write to prevent corruption.
2118
- */
2119
- async function writeIssue(baseDir, issue) {
2120
- const validIssue = IssueSchema.parse(issue);
2121
- await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
2122
- }
2123
- /**
2124
- * List all issues in the worktree.
2125
- * Returns empty array if issues directory doesn't exist.
2126
- *
2127
- * Uses parallel file reading for better performance with many issues.
2128
- */
2129
- async function listIssues(baseDir, options = {}) {
2130
- const warnOnInvalid = options.warnOnInvalid ?? true;
2131
- const issuesDir = join(baseDir, "issues");
2132
- let files;
2133
- try {
2134
- files = await readdir(issuesDir);
2135
- } catch {
2136
- return [];
2137
- }
2138
- const mdFiles = files.filter((f) => f.endsWith(".md"));
2139
- const issues = [];
2140
- for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
2141
- const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
2142
- const fileContents = await Promise.all(batch.map(async (file) => {
2143
- const filePath = join(issuesDir, file);
2144
- try {
2145
- return {
2146
- file,
2147
- content: await readFile(filePath, "utf-8")
2148
- };
2149
- } catch (error) {
2150
- return {
2151
- file,
2152
- error: formatUnknownError(error)
2153
- };
2154
- }
2155
- }));
2156
- for (const result of fileContents) {
2157
- if ("error" in result) {
2158
- reportInvalidIssueFile({
2159
- file: result.file,
2160
- reason: `failed to read file: ${result.error}`
2161
- }, warnOnInvalid, options.onInvalidIssue);
2162
- continue;
2163
- }
2164
- try {
2165
- const issue = parseIssue(result.content);
2166
- issues.push(issue);
2167
- } catch (error) {
2168
- reportInvalidIssueFile({
2169
- file: result.file,
2170
- reason: formatUnknownError(error)
2171
- }, warnOnInvalid, options.onInvalidIssue);
2172
- }
2173
- }
2174
- }
2175
- return issues;
2176
- }
2177
- function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
2178
- onInvalidIssue?.(invalidIssue);
2179
- if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
2180
- }
2181
-
2182
2458
  //#endregion
2183
2459
  //#region src/lib/priority.ts
2184
2460
  /**
@@ -5452,6 +5728,7 @@ var SyncHandler = class extends BaseCommand {
5452
5728
  this.output.debug(`Committed ${count} file(s) to sync branch`);
5453
5729
  }
5454
5730
  await git("fetch", remote, syncBranch);
5731
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
5455
5732
  let behindCommits = 0;
5456
5733
  try {
5457
5734
  const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
@@ -5556,6 +5833,7 @@ var SyncHandler = class extends BaseCommand {
5556
5833
  }
5557
5834
  }
5558
5835
  } catch (error) {
5836
+ if (error instanceof SyncError) throw error;
5559
5837
  this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
5560
5838
  }
5561
5839
  let aheadCommits = 0;
@@ -5588,6 +5866,7 @@ var SyncHandler = class extends BaseCommand {
5588
5866
  summary.conflicts = conflicts.length;
5589
5867
  spinner.stop();
5590
5868
  if (pushFailed) {
5869
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
5591
5870
  let displayError = pushError;
5592
5871
  const httpMatch = /HTTP (\d+)/.exec(pushError);
5593
5872
  const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
@@ -6724,6 +7003,35 @@ function renderDiagnostics(results, colors) {
6724
7003
  *
6725
7004
  * See: tbd-design.md §4.9 Doctor
6726
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
+ }
6727
7035
  const CONFIG_DIR = TBD_DIR;
6728
7036
  var DoctorHandler = class extends BaseCommand {
6729
7037
  dataSyncDir = "";
@@ -6775,7 +7083,7 @@ var DoctorHandler = class extends BaseCommand {
6775
7083
  const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
6776
7084
  healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
6777
7085
  healthChecks.push(await this.checkLocalSyncBranch());
6778
- healthChecks.push(await this.checkRemoteSyncBranch());
7086
+ healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
6779
7087
  healthChecks.push(await this.checkLocalVsRemoteData());
6780
7088
  healthChecks.push(await this.checkCloneScenarios());
6781
7089
  healthChecks.push(await this.checkSyncConsistency());
@@ -7010,7 +7318,7 @@ var DoctorHandler = class extends BaseCommand {
7010
7318
  status: "ok"
7011
7319
  };
7012
7320
  if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
7013
- const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
7321
+ const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
7014
7322
  const resolved = resolveIdMappingConflicts(content);
7015
7323
  await saveIdMapping(this.dataSyncDir, resolved);
7016
7324
  return {
@@ -7059,7 +7367,7 @@ var DoctorHandler = class extends BaseCommand {
7059
7367
  status: "ok"
7060
7368
  };
7061
7369
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
7062
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
7370
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
7063
7371
  const mapping = await loadIdMapping(this.dataSyncDir);
7064
7372
  await saveIdMapping(this.dataSyncDir, mapping);
7065
7373
  return {
@@ -7198,7 +7506,7 @@ var DoctorHandler = class extends BaseCommand {
7198
7506
  name: "ID mapping coverage",
7199
7507
  status: "ok"
7200
7508
  };
7201
- const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-CFoPVinz.mjs");
7509
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
7202
7510
  const mapping = await loadIdMapping(this.dataSyncDir);
7203
7511
  const missingIds = [];
7204
7512
  for (const issue of this.issues) {
@@ -7210,7 +7518,7 @@ var DoctorHandler = class extends BaseCommand {
7210
7518
  status: "ok"
7211
7519
  };
7212
7520
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
7213
- const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-CFoPVinz.mjs");
7521
+ const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
7214
7522
  let historicalMapping;
7215
7523
  try {
7216
7524
  const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
@@ -7608,23 +7916,27 @@ var DoctorHandler = class extends BaseCommand {
7608
7916
  * Check remote sync branch health.
7609
7917
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
7610
7918
  */
7611
- async checkRemoteSyncBranch() {
7919
+ async checkRemoteSyncBranch(fix) {
7612
7920
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
7613
7921
  const remote = this.config?.sync.remote ?? "origin";
7614
7922
  const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
7615
- if (remoteHealth.exists) {
7616
- if (remoteHealth.diverged) return {
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 {
7617
7926
  name: "Remote sync branch",
7618
- status: "warn",
7619
- message: `${remote}/${syncBranch} has diverged`,
7620
- 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})`
7621
7929
  };
7930
+ } catch (error) {
7622
7931
  return {
7623
7932
  name: "Remote sync branch",
7624
- status: "ok",
7625
- 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"
7626
7936
  };
7627
7937
  }
7938
+ const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
7939
+ if (diag) return diag;
7628
7940
  if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
7629
7941
  name: "Remote sync branch",
7630
7942
  status: "warn",
@@ -9453,6 +9765,7 @@ var PrimeHandler = class extends BaseCommand {
9453
9765
  console.log(`${colors.success("✓")} Initialized in this repo`);
9454
9766
  if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
9455
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)."));
9456
9769
  console.log("");
9457
9770
  console.log(colors.bold("=== PROJECT STATUS ==="));
9458
9771
  try {
@@ -10395,9 +10708,11 @@ async function getShortcutDirectory(quiet = false) {
10395
10708
  }
10396
10709
  /**
10397
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}).
10398
10713
  * Formatted to match flowmark output.
10399
10714
  */
10400
- 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-->`;
10401
10716
  /**
10402
10717
  * Build the full generated SKILL.md payload: the bundled skill content with the
10403
10718
  * shortcut/guideline directory appended and a DO NOT EDIT marker after the
@@ -10415,8 +10730,19 @@ async function buildSkillPayload(quiet = false) {
10415
10730
  }
10416
10731
  /**
10417
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.
10418
10739
  */
10419
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);
10420
10746
  await mkdir(dirname(targetPath), { recursive: true });
10421
10747
  await writeFile(targetPath, payload);
10422
10748
  }
@@ -11461,10 +11787,16 @@ var SetupAutoHandler = class extends BaseCommand {
11461
11787
  const entries = await readdir(scriptsDir, { withFileTypes: true });
11462
11788
  for (const entry of entries) if (entry.isFile()) {
11463
11789
  const filename = entry.name;
11464
- if (LEGACY_TBD_SCRIPTS.includes(filename)) try {
11465
- await rm(join(scriptsDir, filename));
11466
- scriptsRemoved.push(filename);
11467
- } catch {}
11790
+ if (LEGACY_TBD_SCRIPTS.includes(filename)) {
11791
+ if (this.ctx.dryRun) {
11792
+ scriptsRemoved.push(filename);
11793
+ continue;
11794
+ }
11795
+ try {
11796
+ await rm(join(scriptsDir, filename));
11797
+ scriptsRemoved.push(filename);
11798
+ } catch {}
11799
+ }
11468
11800
  }
11469
11801
  } catch {}
11470
11802
  return scriptsRemoved;
@@ -11508,7 +11840,7 @@ var SetupAutoHandler = class extends BaseCommand {
11508
11840
  modified = true;
11509
11841
  }
11510
11842
  }
11511
- if (modified) {
11843
+ if (modified && !this.ctx.dryRun) {
11512
11844
  if (Object.keys(hooks).length === 0) delete settings.hooks;
11513
11845
  await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
11514
11846
  }
@@ -11526,7 +11858,8 @@ var SetupAutoHandler = class extends BaseCommand {
11526
11858
  const parts = [];
11527
11859
  if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
11528
11860
  if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
11529
- console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
11861
+ if (this.ctx.dryRun) this.output.dryRun(`Would clean up legacy ${parts.join(" and ")}`);
11862
+ else console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
11530
11863
  }
11531
11864
  await this.syncDocs(cwd);
11532
11865
  const targeting = this.resolveTargeting();