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/tbd CHANGED
@@ -11,7 +11,7 @@ import tty from "node:tty";
11
11
  import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
12
12
  import { Readable } from "node:stream";
13
13
  import { promisify } from "node:util";
14
- import crypto, { randomBytes } from "node:crypto";
14
+ import crypto, { randomBytes, randomUUID } from "node:crypto";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import prettyBytes from "pretty-bytes";
17
17
  import prettyMs from "pretty-ms";
@@ -14104,7 +14104,7 @@ function serializeIssue(issue) {
14104
14104
  * Package version, derived from git at build time.
14105
14105
  * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.
14106
14106
  */
14107
- const VERSION$1 = "0.2.0";
14107
+ const VERSION$1 = "0.2.2";
14108
14108
 
14109
14109
  //#endregion
14110
14110
  //#region src/cli/lib/version.ts
@@ -98354,6 +98354,18 @@ var SyncError = class extends CLIError {
98354
98354
  }
98355
98355
  };
98356
98356
  /**
98357
+ * Unrelated-history error - the local and remote tbd-sync share no common
98358
+ * ancestor, so a push can never fast-forward and a plain git merge refuses.
98359
+ * `tbd sync` cannot resolve this; the rescue lives in `tbd doctor --fix`.
98360
+ * See: plan-2026-05-29-tbd-sync-unrelated-history-hardening.md
98361
+ */
98362
+ var UnrelatedHistoriesError = class extends SyncError {
98363
+ constructor(remote = "origin", syncBranch = "tbd-sync") {
98364
+ 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).`);
98365
+ this.name = "UnrelatedHistoriesError";
98366
+ }
98367
+ };
98368
+ /**
98357
98369
  * Classify a sync error to determine appropriate recovery action.
98358
98370
  *
98359
98371
  * Used by `tbd sync` to decide whether to:
@@ -98651,6 +98663,124 @@ function nowFilenameTimestamp() {
98651
98663
  return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
98652
98664
  }
98653
98665
 
98666
+ //#endregion
98667
+ //#region src/utils/zod-error-utils.ts
98668
+ /**
98669
+ * Helpers for rendering Zod errors without relying on object inspection.
98670
+ */
98671
+ /**
98672
+ * Format a ZodError as concise path-qualified messages for CLI output.
98673
+ */
98674
+ function formatZodError(error) {
98675
+ const messages = error.issues.map((issue) => {
98676
+ return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
98677
+ });
98678
+ return messages.length > 0 ? messages.join("; ") : error.message;
98679
+ }
98680
+ /**
98681
+ * Format unknown thrown values as safe strings for warnings and diagnostics.
98682
+ */
98683
+ function formatUnknownError(error) {
98684
+ if (error instanceof ZodError) return formatZodError(error);
98685
+ if (error instanceof Error) return error.message;
98686
+ return String(error);
98687
+ }
98688
+
98689
+ //#endregion
98690
+ //#region src/file/storage.ts
98691
+ /**
98692
+ * Storage layer for issue files.
98693
+ *
98694
+ * Provides atomic file operations and issue CRUD operations.
98695
+ * All operations work on the data-sync directory selected by the caller. In production
98696
+ * that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
98697
+ *
98698
+ * See: tbd-design.md §3.2 Storage Layer
98699
+ */
98700
+ /**
98701
+ * Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
98702
+ */
98703
+ const ISSUE_READ_BATCH_SIZE = 200;
98704
+ /**
98705
+ * Get the path to an issue file.
98706
+ */
98707
+ function getIssuePath(baseDir, id) {
98708
+ return join(baseDir, "issues", `${id}.md`);
98709
+ }
98710
+ /**
98711
+ * Read an issue from the worktree.
98712
+ * @throws If the issue file doesn't exist or is invalid.
98713
+ */
98714
+ async function readIssue(baseDir, id) {
98715
+ return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
98716
+ }
98717
+ /**
98718
+ * Write an issue to the worktree.
98719
+ * Uses atomic write to prevent corruption.
98720
+ */
98721
+ async function writeIssue(baseDir, issue) {
98722
+ const validIssue = IssueSchema.parse(issue);
98723
+ await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
98724
+ }
98725
+ /**
98726
+ * List all issues in the worktree.
98727
+ * Returns empty array if issues directory doesn't exist.
98728
+ *
98729
+ * Uses parallel file reading for better performance with many issues.
98730
+ */
98731
+ async function listIssues(baseDir, options = {}) {
98732
+ const warnOnInvalid = options.warnOnInvalid ?? true;
98733
+ const issuesDir = join(baseDir, "issues");
98734
+ let files;
98735
+ try {
98736
+ files = await readdir(issuesDir);
98737
+ } catch {
98738
+ return [];
98739
+ }
98740
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
98741
+ const issues = [];
98742
+ for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
98743
+ const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
98744
+ const fileContents = await Promise.all(batch.map(async (file) => {
98745
+ const filePath = join(issuesDir, file);
98746
+ try {
98747
+ return {
98748
+ file,
98749
+ content: await readFile(filePath, "utf-8")
98750
+ };
98751
+ } catch (error) {
98752
+ return {
98753
+ file,
98754
+ error: formatUnknownError(error)
98755
+ };
98756
+ }
98757
+ }));
98758
+ for (const result of fileContents) {
98759
+ if ("error" in result) {
98760
+ reportInvalidIssueFile({
98761
+ file: result.file,
98762
+ reason: `failed to read file: ${result.error}`
98763
+ }, warnOnInvalid, options.onInvalidIssue);
98764
+ continue;
98765
+ }
98766
+ try {
98767
+ const issue = parseIssue(result.content);
98768
+ issues.push(issue);
98769
+ } catch (error) {
98770
+ reportInvalidIssueFile({
98771
+ file: result.file,
98772
+ reason: formatUnknownError(error)
98773
+ }, warnOnInvalid, options.onInvalidIssue);
98774
+ }
98775
+ }
98776
+ }
98777
+ return issues;
98778
+ }
98779
+ function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
98780
+ onInvalidIssue?.(invalidIssue);
98781
+ if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
98782
+ }
98783
+
98654
98784
  //#endregion
98655
98785
  //#region src/utils/lockfile.ts
98656
98786
  /**
@@ -98682,10 +98812,15 @@ function nowFilenameTimestamp() {
98682
98812
  *
98683
98813
  * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process
98684
98814
  * 2. **Hold**: Execute the critical section
98685
- * 3. **Release**: `rmdir(lockDir)` — in a finally block
98815
+ * 3. **Release**: `rmdir(lockDir)` — in a finally block, with a bounded retry to
98816
+ * absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering
98817
+ * handles) that would otherwise orphan the lock directory.
98686
98818
  * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder
98687
- * crashed and break the lock. This is a heuristic safe when the critical
98688
- * section is short-lived (sub-second for file I/O).
98819
+ * crashed and break the lock. Breaking is done **atomically** by renaming the
98820
+ * stale directory aside (only one waiter can win the rename), so two waiters can
98821
+ * never both break the same lock and end up running concurrently. This is a
98822
+ * heuristic — safe when the critical section is short-lived (sub-second for
98823
+ * file I/O).
98689
98824
  *
98690
98825
  * ## Failure on timeout
98691
98826
  *
@@ -98733,6 +98868,53 @@ var LockAcquisitionError = class extends Error {
98733
98868
  this.name = "LockAcquisitionError";
98734
98869
  }
98735
98870
  };
98871
+ /** Filesystem error codes that are transient on Windows and worth retrying. */
98872
+ const TRANSIENT_RMDIR_CODES = new Set([
98873
+ "EBUSY",
98874
+ "EPERM",
98875
+ "EACCES",
98876
+ "ENOTEMPTY"
98877
+ ]);
98878
+ /**
98879
+ * Remove a lock directory, tolerating transient Windows failures.
98880
+ *
98881
+ * `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners
98882
+ * or lingering directory handles). A few short retries make release reliable; if it
98883
+ * still fails, we give up and let stale detection reclaim the directory rather than
98884
+ * throwing from a best-effort cleanup path.
98885
+ */
98886
+ async function removeLockDir(lockPath, attempts = 5) {
98887
+ for (let attempt = 0; attempt < attempts; attempt++) try {
98888
+ await rmdir(lockPath);
98889
+ return;
98890
+ } catch (error) {
98891
+ const code = error.code;
98892
+ if (code === "ENOENT") return;
98893
+ if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {
98894
+ await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
98895
+ continue;
98896
+ }
98897
+ return;
98898
+ }
98899
+ }
98900
+ /**
98901
+ * Atomically break a stale lock.
98902
+ *
98903
+ * Renames the stale directory to a unique sidecar path and removes it. `rename` is
98904
+ * atomic, so when several waiters race to break the same stale lock only one wins the
98905
+ * rename; the losers see ENOENT and simply retry. This prevents the classic
98906
+ * non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both
98907
+ * acquire it, defeating mutual exclusion.
98908
+ */
98909
+ async function breakStaleLock(lockPath) {
98910
+ const sidecar = `${lockPath}.stale-${randomUUID()}`;
98911
+ try {
98912
+ await rename(lockPath, sidecar);
98913
+ } catch {
98914
+ return;
98915
+ }
98916
+ await removeLockDir(sidecar);
98917
+ }
98736
98918
  /**
98737
98919
  * Execute `fn` while holding a lockfile.
98738
98920
  *
@@ -98771,26 +98953,24 @@ async function withLockfile(lockPath, fn, options) {
98771
98953
  break;
98772
98954
  } catch (error) {
98773
98955
  if (error.code !== "EEXIST") throw error;
98956
+ let lockStat;
98774
98957
  try {
98775
- const lockStat = await stat(lockPath);
98776
- if (Date.now() - lockStat.mtimeMs > staleMs) {
98777
- try {
98778
- await rmdir(lockPath);
98779
- } catch {}
98780
- continue;
98781
- }
98958
+ lockStat = await stat(lockPath);
98782
98959
  } catch {
98783
98960
  continue;
98784
98961
  }
98962
+ if (!lockStat.isDirectory()) throw new Error(`Lock path exists but is not a directory: ${lockPath}. Refusing to break it; remove the conflicting file and retry.`);
98963
+ if (Date.now() - lockStat.mtimeMs > staleMs) {
98964
+ await breakStaleLock(lockPath);
98965
+ continue;
98966
+ }
98785
98967
  await new Promise((resolve) => setTimeout(resolve, pollMs));
98786
98968
  }
98787
98969
  if (!acquired) throw new LockAcquisitionError(lockPath, timeoutMs);
98788
98970
  try {
98789
98971
  return await fn();
98790
98972
  } finally {
98791
- try {
98792
- await rmdir(lockPath);
98793
- } catch {}
98973
+ await removeLockDir(lockPath);
98794
98974
  }
98795
98975
  }
98796
98976
 
@@ -99521,6 +99701,55 @@ function resolveIdMappingConflicts(content) {
99521
99701
  */
99522
99702
  const execFileAsync$1 = promisify(execFile);
99523
99703
  /**
99704
+ * Error thrown by {@link git} when a git command exits non-zero.
99705
+ *
99706
+ * Carries the process `exitCode` so callers can branch on git's exit status
99707
+ * (e.g. `ls-remote --exit-code` => 2 means "ref absent", `merge-base` => 1
99708
+ * means "no common ancestor") instead of string-matching stderr. The original
99709
+ * message/stderr is preserved so existing message-based classifiers
99710
+ * (classifySyncError) keep working.
99711
+ */
99712
+ var GitError = class GitError extends Error {
99713
+ /** Process exit code, or null for a spawn failure (e.g. git not found). */
99714
+ exitCode;
99715
+ stderr;
99716
+ stdout;
99717
+ args;
99718
+ constructor(message, opts) {
99719
+ super(message);
99720
+ this.name = "GitError";
99721
+ this.exitCode = opts.exitCode;
99722
+ this.stderr = opts.stderr;
99723
+ this.stdout = opts.stdout;
99724
+ this.args = opts.args;
99725
+ }
99726
+ /**
99727
+ * Wrap a raw execFile rejection into a GitError.
99728
+ *
99729
+ * Node's execFile rejection carries `code` (numeric exit code for a normal
99730
+ * exit, or a string like 'ENOENT' for a spawn failure), plus `stderr`/`stdout`.
99731
+ */
99732
+ static from(err, args) {
99733
+ const raw = err;
99734
+ const exitCode = typeof raw.code === "number" ? raw.code : null;
99735
+ const stderr = typeof raw.stderr === "string" ? raw.stderr : "";
99736
+ const stdout = typeof raw.stdout === "string" ? raw.stdout : "";
99737
+ return new GitError(raw.message ?? `git ${args.join(" ")} failed`, {
99738
+ exitCode,
99739
+ stderr,
99740
+ stdout,
99741
+ args
99742
+ });
99743
+ }
99744
+ };
99745
+ /**
99746
+ * Read the git exit code from a thrown value, or null if it is not a GitError
99747
+ * (or carries no numeric code, e.g. a spawn failure).
99748
+ */
99749
+ function exitCodeOf(err) {
99750
+ return err instanceof GitError ? err.exitCode : null;
99751
+ }
99752
+ /**
99524
99753
  * Maximum buffer size for git command output.
99525
99754
  *
99526
99755
  * Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
@@ -99535,8 +99764,31 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
99535
99764
  * Uses execFile for security - prevents shell injection attacks.
99536
99765
  */
99537
99766
  async function git(...args) {
99538
- const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
99539
- return stdout.trim();
99767
+ try {
99768
+ const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
99769
+ return stdout.trim();
99770
+ } catch (err) {
99771
+ throw GitError.from(err, args);
99772
+ }
99773
+ }
99774
+ /**
99775
+ * Like {@link git} but with `GIT_TERMINAL_PROMPT=0` so a network operation
99776
+ * (e.g. a best-effort push) fails fast instead of blocking on a credential
99777
+ * prompt in a non-interactive environment.
99778
+ */
99779
+ async function gitNoPrompt(...args) {
99780
+ try {
99781
+ const { stdout } = await execFileAsync$1("git", args, {
99782
+ maxBuffer: GIT_MAX_BUFFER,
99783
+ env: {
99784
+ ...process.env,
99785
+ GIT_TERMINAL_PROMPT: "0"
99786
+ }
99787
+ });
99788
+ return stdout.trim();
99789
+ } catch (err) {
99790
+ throw GitError.from(err, args);
99791
+ }
99540
99792
  }
99541
99793
  /**
99542
99794
  * Run `git commit` in a worktree with gpg signing disabled at the command level.
@@ -99834,15 +100086,42 @@ function mergeIssues(base, local, remote) {
99834
100086
  };
99835
100087
  }
99836
100088
  /**
100089
+ * Categorize issues from two unrelated tbd-sync roots by id (which embeds the
100090
+ * globally unique ULID). Pure and git-free so the rescue is robust to the
100091
+ * missing merge base. Substantive equality ignores version/updated_at so
100092
+ * trivial bumps don't masquerade as conflicts.
100093
+ */
100094
+ function categorizeIssuesByUlid(local, remote) {
100095
+ const localById = new Map(local.map((i) => [i.id, i]));
100096
+ const remoteById = new Map(remote.map((i) => [i.id, i]));
100097
+ const buckets = {
100098
+ localOnly: [],
100099
+ remoteOnly: [],
100100
+ bothIdentical: [],
100101
+ bothDifferent: []
100102
+ };
100103
+ for (const [id, localIssue] of localById) {
100104
+ const remoteIssue = remoteById.get(id);
100105
+ if (!remoteIssue) buckets.localOnly.push(localIssue);
100106
+ else if (issuesSubstantivelyEqual(localIssue, remoteIssue)) buckets.bothIdentical.push(remoteIssue);
100107
+ else buckets.bothDifferent.push({
100108
+ local: localIssue,
100109
+ remote: remoteIssue
100110
+ });
100111
+ }
100112
+ for (const [id, remoteIssue] of remoteById) if (!localById.has(id)) buckets.remoteOnly.push(remoteIssue);
100113
+ return buckets;
100114
+ }
100115
+ /**
99837
100116
  * Maximum retry attempts for push operations.
99838
100117
  */
99839
100118
  const MAX_PUSH_RETRIES = 3;
99840
100119
  /**
99841
- * Check if error is a non-fast-forward rejection.
100120
+ * Check if error is a non-fast-forward rejection (also the init race signal).
99842
100121
  */
99843
100122
  function isNonFastForward(error) {
99844
- const msg = error instanceof Error ? error.message : String(error);
99845
- return msg.includes("non-fast-forward") || msg.includes("fetch first") || msg.includes("rejected");
100123
+ const msg = error instanceof GitError ? `${error.stderr}\n${error.message}` : error instanceof Error ? error.message : String(error);
100124
+ return /non-fast-forward|fetch first|rejected|updates were rejected/i.test(msg);
99846
100125
  }
99847
100126
  /**
99848
100127
  * Push with retry and merge on conflict.
@@ -99905,14 +100184,22 @@ async function branchExists(branch, baseDir) {
99905
100184
  }
99906
100185
  }
99907
100186
  /**
99908
- * Check if a remote branch exists.
100187
+ * Probe whether a remote branch exists, distinguishing a clean "absent" from a
100188
+ * "check failed".
100189
+ *
100190
+ * `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
100191
+ * matched; any other failure (auth/network/transient, or git not found) is a
100192
+ * check failure. Orphan-creating callers MUST branch on all three states and
100193
+ * never treat `check-failed` as `absent` — doing so risks creating a divergent
100194
+ * local branch when the remote is merely unreachable.
99909
100195
  */
99910
- async function remoteBranchExists(remote, branch, baseDir) {
100196
+ async function probeRemoteBranch(remote, branch, baseDir) {
100197
+ const dirArgs = baseDir ? ["-C", baseDir] : [];
99911
100198
  try {
99912
- await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
99913
- return true;
99914
- } catch {
99915
- return false;
100199
+ await git(...dirArgs, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
100200
+ return "present";
100201
+ } catch (err) {
100202
+ return exitCodeOf(err) === 2 ? "absent" : "check-failed";
99916
100203
  }
99917
100204
  }
99918
100205
  async function pathExists(path) {
@@ -100132,6 +100419,66 @@ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH)
100132
100419
  }
100133
100420
  }
100134
100421
  /**
100422
+ * Whether the given remote is configured (has a URL) in the repo at baseDir.
100423
+ * A local-only repo (no remote) is safe to orphan and never pushes.
100424
+ */
100425
+ async function remoteIsConfigured(remote, baseDir) {
100426
+ try {
100427
+ await git("-C", baseDir, "config", "--get", `remote.${remote}.url`);
100428
+ return true;
100429
+ } catch {
100430
+ return false;
100431
+ }
100432
+ }
100433
+ /**
100434
+ * Whether the data-sync worktree carries any user issue files (is-<ulid>.md),
100435
+ * as opposed to only the initial orphan scaffold (.gitkeep / .gitattributes).
100436
+ */
100437
+ async function worktreeHasUserIssues(dataSyncPath) {
100438
+ try {
100439
+ return (await readdir(join(dataSyncPath, "issues"))).some((name) => /^is-.*\.md$/.test(name));
100440
+ } catch {
100441
+ return false;
100442
+ }
100443
+ }
100444
+ /**
100445
+ * Push a freshly-created orphan tbd-sync immediately so "first init wins"
100446
+ * (closes the #137 race window). Classifies the outcome:
100447
+ *
100448
+ * - success => pushed: true ("first init wins").
100449
+ * - transient/permanent network/auth failure => best-effort, ignored
100450
+ * (branch stays local-only until the first reachable sync).
100451
+ * - non-fast-forward rejection => a detected init race (environment B pushed
100452
+ * its own orphan first). Fetch, then:
100453
+ * - scaffold-only local => adopt the remote (reset local to remote).
100454
+ * - local has user issues => fail loudly toward `tbd doctor --fix` so the
100455
+ * local work is never silently discarded.
100456
+ *
100457
+ * MUST be called after the orphan's initial commit, while the worktree is on
100458
+ * syncBranch.
100459
+ */
100460
+ async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath) {
100461
+ try {
100462
+ await gitNoPrompt("-C", worktreePath, "push", remote, `HEAD:refs/heads/${syncBranch}`);
100463
+ return {
100464
+ pushed: true,
100465
+ adopted: false
100466
+ };
100467
+ } catch (err) {
100468
+ if (!isNonFastForward(err)) return {
100469
+ pushed: false,
100470
+ adopted: false
100471
+ };
100472
+ await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
100473
+ 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.`);
100474
+ await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
100475
+ return {
100476
+ pushed: false,
100477
+ adopted: true
100478
+ };
100479
+ }
100480
+ }
100481
+ /**
100135
100482
  * Initialize the hidden worktree for the tbd-sync branch.
100136
100483
  * Follows the decision tree from tbd-design.md §2.3.
100137
100484
  *
@@ -100173,7 +100520,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
100173
100520
  created: true
100174
100521
  };
100175
100522
  }
100176
- if (await remoteBranchExists(remote, syncBranch, baseDir)) {
100523
+ const hasRemote = await remoteIsConfigured(remote, baseDir);
100524
+ const probe = hasRemote ? await probeRemoteBranch(remote, syncBranch, baseDir) : "absent";
100525
+ if (probe === "check-failed") return {
100526
+ success: false,
100527
+ error: `Could not verify whether ${remote}/${syncBranch} exists (remote check failed); refusing to create a divergent local branch. Check connectivity/auth and retry.`
100528
+ };
100529
+ if (probe === "present") {
100177
100530
  await git("-C", baseDir, "fetch", remote, syncBranch);
100178
100531
  await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
100179
100532
  return {
@@ -100194,6 +100547,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
100194
100547
  await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
100195
100548
  await git("-C", worktreePath, "add", ".");
100196
100549
  await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
100550
+ if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
100197
100551
  return {
100198
100552
  success: true,
100199
100553
  path: worktreePath,
@@ -100250,22 +100604,30 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
100250
100604
  await git(...dirArgs, "fetch", remote, syncBranch);
100251
100605
  const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
100252
100606
  let diverged = false;
100253
- try {
100254
- const mergeBase = await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`);
100255
- const localHead = await git(...dirArgs, "rev-parse", syncBranch);
100256
- diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
100257
- } catch {
100258
- diverged = false;
100607
+ let unrelated = false;
100608
+ if (await branchExists(syncBranch, baseDir)) {
100609
+ const localHead = (await git(...dirArgs, "rev-parse", syncBranch)).trim();
100610
+ try {
100611
+ const mergeBase = (await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`)).trim();
100612
+ diverged = mergeBase !== localHead && mergeBase !== remoteHead;
100613
+ } catch (err) {
100614
+ if (exitCodeOf(err) === 1) {
100615
+ unrelated = true;
100616
+ diverged = true;
100617
+ }
100618
+ }
100259
100619
  }
100260
100620
  return {
100261
100621
  exists: true,
100262
100622
  diverged,
100623
+ unrelated,
100263
100624
  head: remoteHead
100264
100625
  };
100265
100626
  } catch {
100266
100627
  return {
100267
100628
  exists: false,
100268
- diverged: false
100629
+ diverged: false,
100630
+ unrelated: false
100269
100631
  };
100270
100632
  }
100271
100633
  }
@@ -100304,6 +100666,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
100304
100666
  localBehind
100305
100667
  };
100306
100668
  }
100669
+ /** Read all issues from a git ref's data-sync tree (no checkout needed). */
100670
+ async function readBranchIssues(baseDir, ref) {
100671
+ let listing;
100672
+ try {
100673
+ listing = await git("-C", baseDir, "ls-tree", "-r", "--name-only", ref, "--", `${DATA_SYNC_DIR}/issues/`);
100674
+ } catch {
100675
+ return [];
100676
+ }
100677
+ const issues = [];
100678
+ for (const path of listing.split("\n").filter((p) => /\/is-.*\.md$/.test(p))) try {
100679
+ const content = await git("-C", baseDir, "show", `${ref}:${path}`);
100680
+ issues.push(parseIssue(content));
100681
+ } catch {}
100682
+ return issues;
100683
+ }
100684
+ /** Read the ID mapping from a git ref's ids.yml (empty if absent). */
100685
+ async function readBranchMapping(baseDir, ref) {
100686
+ try {
100687
+ return parseIdMappingFromYaml(await git("-C", baseDir, "show", `${ref}:${DATA_SYNC_DIR}/mappings/ids.yml`));
100688
+ } catch {
100689
+ return {
100690
+ shortToUlid: /* @__PURE__ */ new Map(),
100691
+ ulidToShort: /* @__PURE__ */ new Map()
100692
+ };
100693
+ }
100694
+ }
100695
+ /** Preserve a losing issue version explicitly under attic/conflicts/. */
100696
+ async function preserveLosingVersion(dataSyncPath, loser) {
100697
+ const conflictsDir = join(dataSyncPath, "attic", "conflicts");
100698
+ await mkdir(conflictsDir, { recursive: true });
100699
+ await writeFile(join(conflictsDir, `${loser.id}__${nowFilenameTimestamp()}.md`), serializeIssue(loser));
100700
+ }
100701
+ /**
100702
+ * Non-destructively rescue an unrelated tbd-sync history (#139).
100703
+ *
100704
+ * Reconciles at the issue-file layer (ULIDs guarantee no collisions), never via
100705
+ * a git history merge. Adopts the remote as the canonical base and replays
100706
+ * local work onto it. The only destructive step (the reset) happens AFTER a
100707
+ * backup branch is created, so the pre-rescue HEAD is always recoverable and
100708
+ * the rescue is restartable.
100709
+ *
100710
+ * MUST be called while holding `withSharedDataSyncLock`. Aborts if the
100711
+ * data-sync worktree is dirty or has a merge in progress.
100712
+ */
100713
+ async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
100714
+ const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
100715
+ 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.");
100716
+ 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.");
100717
+ await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
100718
+ const localIssues = await listIssues(dataSyncPath);
100719
+ const localMapping = await loadIdMapping(dataSyncPath);
100720
+ const remoteIssues = await readBranchIssues(baseDir, `${remote}/${syncBranch}`);
100721
+ const remoteMapping = await readBranchMapping(baseDir, `${remote}/${syncBranch}`);
100722
+ const localHead = (await git("-C", baseDir, "rev-parse", syncBranch)).trim();
100723
+ const backupBranch = `tbd-backup-${nowFilenameTimestamp()}`;
100724
+ await git("-C", baseDir, "branch", backupBranch, localHead);
100725
+ const buckets = categorizeIssuesByUlid(localIssues, remoteIssues);
100726
+ await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
100727
+ let conflicts = 0;
100728
+ for (const issue of buckets.localOnly) await writeIssue(dataSyncPath, issue);
100729
+ for (const { local, remote: remoteIssue } of buckets.bothDifferent) {
100730
+ const { merged } = mergeIssues(null, local, remoteIssue);
100731
+ await writeIssue(dataSyncPath, merged);
100732
+ for (const side of [local, remoteIssue]) if (!issuesSubstantivelyEqual(side, merged)) {
100733
+ await preserveLosingVersion(dataSyncPath, side);
100734
+ conflicts++;
100735
+ }
100736
+ }
100737
+ const mergedMapping = mergeIdMappings(remoteMapping, localMapping);
100738
+ const allIssues = await listIssues(dataSyncPath);
100739
+ reconcileMappings(allIssues.map((i) => i.id), mergedMapping, remoteMapping);
100740
+ await saveIdMapping(dataSyncPath, mergedMapping);
100741
+ await git("-C", worktreePath, "add", "-A");
100742
+ 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)`);
100743
+ return {
100744
+ backupBranch,
100745
+ localOnly: buckets.localOnly.length,
100746
+ merged: buckets.bothDifferent.length,
100747
+ conflicts,
100748
+ totalIssues: allIssues.length
100749
+ };
100750
+ }
100307
100751
  /**
100308
100752
  * Count issues on a remote sync branch without creating a worktree.
100309
100753
  * Used by doctor to show accurate statistics on fresh clones.
@@ -100651,124 +101095,6 @@ const initCommand = new Command("init").description("Initialize tbd in a git rep
100651
101095
  await new InitHandler(command).run(options);
100652
101096
  });
100653
101097
 
100654
- //#endregion
100655
- //#region src/utils/zod-error-utils.ts
100656
- /**
100657
- * Helpers for rendering Zod errors without relying on object inspection.
100658
- */
100659
- /**
100660
- * Format a ZodError as concise path-qualified messages for CLI output.
100661
- */
100662
- function formatZodError(error) {
100663
- const messages = error.issues.map((issue) => {
100664
- return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
100665
- });
100666
- return messages.length > 0 ? messages.join("; ") : error.message;
100667
- }
100668
- /**
100669
- * Format unknown thrown values as safe strings for warnings and diagnostics.
100670
- */
100671
- function formatUnknownError(error) {
100672
- if (error instanceof ZodError) return formatZodError(error);
100673
- if (error instanceof Error) return error.message;
100674
- return String(error);
100675
- }
100676
-
100677
- //#endregion
100678
- //#region src/file/storage.ts
100679
- /**
100680
- * Storage layer for issue files.
100681
- *
100682
- * Provides atomic file operations and issue CRUD operations.
100683
- * All operations work on the data-sync directory selected by the caller. In production
100684
- * that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
100685
- *
100686
- * See: tbd-design.md §3.2 Storage Layer
100687
- */
100688
- /**
100689
- * Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
100690
- */
100691
- const ISSUE_READ_BATCH_SIZE = 200;
100692
- /**
100693
- * Get the path to an issue file.
100694
- */
100695
- function getIssuePath(baseDir, id) {
100696
- return join(baseDir, "issues", `${id}.md`);
100697
- }
100698
- /**
100699
- * Read an issue from the worktree.
100700
- * @throws If the issue file doesn't exist or is invalid.
100701
- */
100702
- async function readIssue(baseDir, id) {
100703
- return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
100704
- }
100705
- /**
100706
- * Write an issue to the worktree.
100707
- * Uses atomic write to prevent corruption.
100708
- */
100709
- async function writeIssue(baseDir, issue) {
100710
- const validIssue = IssueSchema.parse(issue);
100711
- await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
100712
- }
100713
- /**
100714
- * List all issues in the worktree.
100715
- * Returns empty array if issues directory doesn't exist.
100716
- *
100717
- * Uses parallel file reading for better performance with many issues.
100718
- */
100719
- async function listIssues(baseDir, options = {}) {
100720
- const warnOnInvalid = options.warnOnInvalid ?? true;
100721
- const issuesDir = join(baseDir, "issues");
100722
- let files;
100723
- try {
100724
- files = await readdir(issuesDir);
100725
- } catch {
100726
- return [];
100727
- }
100728
- const mdFiles = files.filter((f) => f.endsWith(".md"));
100729
- const issues = [];
100730
- for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
100731
- const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
100732
- const fileContents = await Promise.all(batch.map(async (file) => {
100733
- const filePath = join(issuesDir, file);
100734
- try {
100735
- return {
100736
- file,
100737
- content: await readFile(filePath, "utf-8")
100738
- };
100739
- } catch (error) {
100740
- return {
100741
- file,
100742
- error: formatUnknownError(error)
100743
- };
100744
- }
100745
- }));
100746
- for (const result of fileContents) {
100747
- if ("error" in result) {
100748
- reportInvalidIssueFile({
100749
- file: result.file,
100750
- reason: `failed to read file: ${result.error}`
100751
- }, warnOnInvalid, options.onInvalidIssue);
100752
- continue;
100753
- }
100754
- try {
100755
- const issue = parseIssue(result.content);
100756
- issues.push(issue);
100757
- } catch (error) {
100758
- reportInvalidIssueFile({
100759
- file: result.file,
100760
- reason: formatUnknownError(error)
100761
- }, warnOnInvalid, options.onInvalidIssue);
100762
- }
100763
- }
100764
- }
100765
- return issues;
100766
- }
100767
- function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
100768
- onInvalidIssue?.(invalidIssue);
100769
- if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
100770
- }
100771
-
100772
101098
  //#endregion
100773
101099
  //#region src/lib/priority.ts
100774
101100
  /**
@@ -104042,6 +104368,7 @@ var SyncHandler = class extends BaseCommand {
104042
104368
  this.output.debug(`Committed ${count} file(s) to sync branch`);
104043
104369
  }
104044
104370
  await git("fetch", remote, syncBranch);
104371
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
104045
104372
  let behindCommits = 0;
104046
104373
  try {
104047
104374
  const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
@@ -104146,6 +104473,7 @@ var SyncHandler = class extends BaseCommand {
104146
104473
  }
104147
104474
  }
104148
104475
  } catch (error) {
104476
+ if (error instanceof SyncError) throw error;
104149
104477
  this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
104150
104478
  }
104151
104479
  let aheadCommits = 0;
@@ -104178,6 +104506,7 @@ var SyncHandler = class extends BaseCommand {
104178
104506
  summary.conflicts = conflicts.length;
104179
104507
  spinner.stop();
104180
104508
  if (pushFailed) {
104509
+ if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
104181
104510
  let displayError = pushError;
104182
104511
  const httpMatch = /HTTP (\d+)/.exec(pushError);
104183
104512
  const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
@@ -105314,6 +105643,35 @@ function renderDiagnostics(results, colors) {
105314
105643
  *
105315
105644
  * See: tbd-design.md §4.9 Doctor
105316
105645
  */
105646
+ /**
105647
+ * Map remote sync-branch health to a "Remote sync branch" diagnostic.
105648
+ *
105649
+ * Returns null when the remote branch does not exist (the caller then falls
105650
+ * through to local-branch / new-repo handling). Unrelated histories are a hard
105651
+ * ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` — push
105652
+ * can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
105653
+ */
105654
+ function classifyRemoteSyncHealth(health, remote, syncBranch) {
105655
+ if (!health.exists) return null;
105656
+ if (health.unrelated) return {
105657
+ name: "Remote sync branch",
105658
+ status: "error",
105659
+ fixable: true,
105660
+ message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) — push cannot succeed`,
105661
+ suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
105662
+ };
105663
+ if (health.diverged) return {
105664
+ name: "Remote sync branch",
105665
+ status: "warn",
105666
+ message: `${remote}/${syncBranch} has diverged`,
105667
+ suggestion: "Run: tbd sync to reconcile changes"
105668
+ };
105669
+ return {
105670
+ name: "Remote sync branch",
105671
+ status: "ok",
105672
+ message: `${remote}/${syncBranch}`
105673
+ };
105674
+ }
105317
105675
  const CONFIG_DIR = TBD_DIR;
105318
105676
  var DoctorHandler = class extends BaseCommand {
105319
105677
  dataSyncDir = "";
@@ -105365,7 +105723,7 @@ var DoctorHandler = class extends BaseCommand {
105365
105723
  const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
105366
105724
  healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
105367
105725
  healthChecks.push(await this.checkLocalSyncBranch());
105368
- healthChecks.push(await this.checkRemoteSyncBranch());
105726
+ healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
105369
105727
  healthChecks.push(await this.checkLocalVsRemoteData());
105370
105728
  healthChecks.push(await this.checkCloneScenarios());
105371
105729
  healthChecks.push(await this.checkSyncConsistency());
@@ -106198,23 +106556,27 @@ var DoctorHandler = class extends BaseCommand {
106198
106556
  * Check remote sync branch health.
106199
106557
  * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
106200
106558
  */
106201
- async checkRemoteSyncBranch() {
106559
+ async checkRemoteSyncBranch(fix) {
106202
106560
  const syncBranch = this.config?.sync.branch ?? "tbd-sync";
106203
106561
  const remote = this.config?.sync.remote ?? "origin";
106204
106562
  const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
106205
- if (remoteHealth.exists) {
106206
- if (remoteHealth.diverged) return {
106563
+ if (remoteHealth.unrelated && fix && !this.checkDryRun("Rescue unrelated tbd-sync histories")) try {
106564
+ const result = await withSharedDataSyncLock(this.cwd, async () => rescueUnrelatedHistory(this.cwd, remote, syncBranch));
106565
+ return {
106207
106566
  name: "Remote sync branch",
106208
- status: "warn",
106209
- message: `${remote}/${syncBranch} has diverged`,
106210
- suggestion: "Run: tbd sync to reconcile changes"
106567
+ status: "ok",
106568
+ message: `rescued: adopted ${remote}/${syncBranch} base, reconciled ${result.localOnly} local-only + ${result.merged} merged (backup: ${result.backupBranch})`
106211
106569
  };
106570
+ } catch (error) {
106212
106571
  return {
106213
106572
  name: "Remote sync branch",
106214
- status: "ok",
106215
- message: `${remote}/${syncBranch}`
106573
+ status: "error",
106574
+ message: `rescue failed: ${error instanceof Error ? error.message : String(error)}`,
106575
+ suggestion: "Resolve manually; the pre-rescue state is on the tbd-backup-* branch"
106216
106576
  };
106217
106577
  }
106578
+ const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
106579
+ if (diag) return diag;
106218
106580
  if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
106219
106581
  name: "Remote sync branch",
106220
106582
  status: "warn",
@@ -108116,6 +108478,7 @@ var PrimeHandler = class extends BaseCommand {
108116
108478
  console.log(`${colors.success("✓")} Initialized in this repo`);
108117
108479
  if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
108118
108480
  else console.log(`${colors.dim("✗")} Hooks not installed (run: tbd setup --auto)`);
108481
+ console.log(colors.dim(" Run `tbd setup --auto` to refresh skills and settings (e.g. after upgrading tbd)."));
108119
108482
  console.log("");
108120
108483
  console.log(colors.bold("=== PROJECT STATUS ==="));
108121
108484
  try {
@@ -109058,9 +109421,11 @@ async function getShortcutDirectory(quiet = false) {
109058
109421
  }
109059
109422
  /**
109060
109423
  * DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
109424
+ * Carries the shared `format=fNN` integration code so the forward-compatibility
109425
+ * guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
109061
109426
  * Formatted to match flowmark output.
109062
109427
  */
109063
- const SKILL_DO_NOT_EDIT_MARKER = "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->";
109428
+ const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;
109064
109429
  /**
109065
109430
  * Build the full generated SKILL.md payload: the bundled skill content with the
109066
109431
  * shortcut/guideline directory appended and a DO NOT EDIT marker after the
@@ -109078,8 +109443,19 @@ async function buildSkillPayload(quiet = false) {
109078
109443
  }
109079
109444
  /**
109080
109445
  * Write a generated SKILL.md payload to a target path, creating parent dirs.
109446
+ *
109447
+ * Forward-compatibility guard: if an existing skill carries a newer
109448
+ * `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
109449
+ * the user to upgrade. Guarding each generated surface at its own write point
109450
+ * means an older tbd cannot partial-downgrade a newer committed skill,
109451
+ * regardless of the order in which surfaces are written.
109081
109452
  */
109082
109453
  async function writeSkillFile(targetPath, payload) {
109454
+ let existing = null;
109455
+ try {
109456
+ existing = await readFile(targetPath, "utf-8");
109457
+ } catch {}
109458
+ if (existing !== null) assertNotNewerFormat(existing, targetPath);
109083
109459
  await mkdir(dirname(targetPath), { recursive: true });
109084
109460
  await writeFile(targetPath, payload);
109085
109461
  }
@@ -110124,10 +110500,16 @@ var SetupAutoHandler = class extends BaseCommand {
110124
110500
  const entries = await readdir(scriptsDir, { withFileTypes: true });
110125
110501
  for (const entry of entries) if (entry.isFile()) {
110126
110502
  const filename = entry.name;
110127
- if (LEGACY_TBD_SCRIPTS.includes(filename)) try {
110128
- await rm(join(scriptsDir, filename));
110129
- scriptsRemoved.push(filename);
110130
- } catch {}
110503
+ if (LEGACY_TBD_SCRIPTS.includes(filename)) {
110504
+ if (this.ctx.dryRun) {
110505
+ scriptsRemoved.push(filename);
110506
+ continue;
110507
+ }
110508
+ try {
110509
+ await rm(join(scriptsDir, filename));
110510
+ scriptsRemoved.push(filename);
110511
+ } catch {}
110512
+ }
110131
110513
  }
110132
110514
  } catch {}
110133
110515
  return scriptsRemoved;
@@ -110171,7 +110553,7 @@ var SetupAutoHandler = class extends BaseCommand {
110171
110553
  modified = true;
110172
110554
  }
110173
110555
  }
110174
- if (modified) {
110556
+ if (modified && !this.ctx.dryRun) {
110175
110557
  if (Object.keys(hooks).length === 0) delete settings.hooks;
110176
110558
  await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
110177
110559
  }
@@ -110189,7 +110571,8 @@ var SetupAutoHandler = class extends BaseCommand {
110189
110571
  const parts = [];
110190
110572
  if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
110191
110573
  if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
110192
- console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
110574
+ if (this.ctx.dryRun) this.output.dryRun(`Would clean up legacy ${parts.join(" and ")}`);
110575
+ else console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
110193
110576
  }
110194
110577
  await this.syncDocs(cwd);
110195
110578
  const targeting = this.resolveTargeting();