get-tbd 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +38 -6
  2. package/dist/bin.mjs +4036 -1074
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +2505 -1585
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-BJz1m9eN.mjs → config-B1w3pAkY.mjs} +142 -279
  7. package/dist/config-B1w3pAkY.mjs.map +1 -0
  8. package/dist/config-DXhifxOw.mjs +3 -0
  9. package/dist/doc-cache-CamtXfi4.mjs +1035 -0
  10. package/dist/doc-cache-CamtXfi4.mjs.map +1 -0
  11. package/dist/doc-cache-CiBwpDTf.mjs +3 -0
  12. package/dist/doc-fork-BMjqzfAs.mjs +3 -0
  13. package/dist/doc-fork-CoPi1G1N.mjs +304 -0
  14. package/dist/doc-fork-CoPi1G1N.mjs.map +1 -0
  15. package/dist/docref-C7g0sjvL.mjs +167 -0
  16. package/dist/docref-C7g0sjvL.mjs.map +1 -0
  17. package/dist/docs/README.md +38 -6
  18. package/dist/docs/SKILL.md +21 -5
  19. package/dist/docs/guidelines/backward-compatibility-rules.md +1 -0
  20. package/dist/docs/guidelines/bun-monorepo-patterns.md +1 -0
  21. package/dist/docs/guidelines/cli-agent-skill-patterns.md +228 -52
  22. package/dist/docs/guidelines/commit-conventions.md +1 -0
  23. package/dist/docs/guidelines/common-doc-guidelines.md +1 -0
  24. package/dist/docs/guidelines/convex-limits-best-practices.md +1 -0
  25. package/dist/docs/guidelines/convex-rules.md +1 -0
  26. package/dist/docs/guidelines/electron-app-development-patterns.md +1 -0
  27. package/dist/docs/guidelines/error-handling-rules.md +4 -0
  28. package/dist/docs/guidelines/general-coding-rules.md +3 -1
  29. package/dist/docs/guidelines/general-comment-rules.md +3 -1
  30. package/dist/docs/guidelines/general-eng-agent-principles.md +127 -0
  31. package/dist/docs/guidelines/general-tdd-guidelines.md +7 -0
  32. package/dist/docs/guidelines/general-testing-rules.md +5 -0
  33. package/dist/docs/guidelines/golden-testing-guidelines.md +1 -0
  34. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +1 -0
  35. package/dist/docs/guidelines/python-cli-patterns.md +5 -0
  36. package/dist/docs/guidelines/python-modern-guidelines.md +4 -0
  37. package/dist/docs/guidelines/python-rules.md +7 -0
  38. package/dist/docs/guidelines/release-notes-guidelines.md +1 -0
  39. package/dist/docs/guidelines/supply-chain-hardening.md +3 -2
  40. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +25 -2
  41. package/dist/docs/guidelines/typescript-cli-tool-rules.md +4 -3
  42. package/dist/docs/guidelines/typescript-code-coverage.md +7 -4
  43. package/dist/docs/guidelines/typescript-rules.md +8 -9
  44. package/dist/docs/guidelines/typescript-sorting-patterns.md +2 -1
  45. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +6 -5
  46. package/dist/docs/references/docmap-format.md +64 -0
  47. package/dist/docs/references/docref-format.md +71 -0
  48. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +17 -4
  49. package/dist/docs/shortcuts/standard/new-shortcut.md +17 -3
  50. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +1 -1
  51. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -1
  52. package/dist/docs/shortcuts/standard/suggest-upstream-improvements.md +69 -0
  53. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +1 -1
  54. package/dist/docs/shortcuts/standard/welcome-user.md +28 -0
  55. package/dist/docs/shortcuts/system/shortcut-explanation.md +16 -1
  56. package/dist/docs/shortcuts/system/skill-baseline.md +21 -5
  57. package/dist/docs/tbd-design.md +144 -3
  58. package/dist/docs/tbd-docs.md +169 -5
  59. package/dist/docs/tbd-prime.md +0 -1
  60. package/dist/docs/templates/qa-playbook.md +3 -1
  61. package/dist/docs/templates/research-brief.md +2 -2
  62. package/dist/fork-manifest-ByU7U2do.mjs +253 -0
  63. package/dist/fork-manifest-ByU7U2do.mjs.map +1 -0
  64. package/dist/fork-manifest-C7lGRq-6.mjs +3 -0
  65. package/dist/{id-mapping-mtoSP9Qt.mjs → id-mapping-D0iitY-F.mjs} +1 -1
  66. package/dist/{id-mapping-687_UEsy.mjs → id-mapping-DAIeLKzm.mjs} +8 -200
  67. package/dist/id-mapping-DAIeLKzm.mjs.map +1 -0
  68. package/dist/index.d.mts +90 -13
  69. package/dist/index.mjs +3 -3
  70. package/dist/lockfile-BR0laSDy.mjs +198 -0
  71. package/dist/lockfile-BR0laSDy.mjs.map +1 -0
  72. package/dist/paths-C1DpnZJW.mjs +405 -0
  73. package/dist/paths-C1DpnZJW.mjs.map +1 -0
  74. package/dist/{schemas-f0EcuAVu.mjs → schemas-lCwRk30L.mjs} +19 -6
  75. package/dist/schemas-lCwRk30L.mjs.map +1 -0
  76. package/dist/{src-BpvcrLnq.mjs → src-CxKOynr1.mjs} +3 -3
  77. package/dist/src-CxKOynr1.mjs.map +1 -0
  78. package/dist/tbd +4036 -1074
  79. package/dist/yaml-utils-BPy991by.mjs.map +1 -1
  80. package/package.json +1 -1
  81. package/dist/config-BJz1m9eN.mjs.map +0 -1
  82. package/dist/config-DlCUMyCG.mjs +0 -3
  83. package/dist/docs/guidelines/general-eng-assistant-rules.md +0 -59
  84. package/dist/id-mapping-687_UEsy.mjs.map +0 -1
  85. package/dist/schemas-f0EcuAVu.mjs.map +0 -1
  86. package/dist/src-BpvcrLnq.mjs.map +0 -1
@@ -0,0 +1,198 @@
1
+ import { mkdir, rename, rmdir, stat } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ //#region src/utils/lockfile.ts
5
+ /**
6
+ * Directory-based mutual exclusion for concurrent file access.
7
+ *
8
+ * Note: Despite the name "lockfile", this is NOT a POSIX file lock (flock/fcntl).
9
+ * It uses mkdir to create a lock *directory* as a coordination convention; no
10
+ * OS-level file locking syscalls are involved. This makes it portable across all
11
+ * filesystems, including NFS and other network mounts where flock/fcntl locks
12
+ * are unreliable or unsupported.
13
+ *
14
+ * This is the same strategy used by:
15
+ *
16
+ * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)
17
+ * See: https://git-scm.com/docs/gitrepository-layout ("lockfile protocol")
18
+ * - **npm** for package-lock.json concurrent access
19
+ *
20
+ * ## Why mkdir?
21
+ *
22
+ * `mkdir(2)` is atomic on all common filesystems (local and network): it either
23
+ * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,
24
+ * a directory lock is trivially distinguishable from normal files.
25
+ *
26
+ * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving
27
+ * the atomicity guarantee:
28
+ * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback
29
+ *
30
+ * ## Lock lifecycle
31
+ *
32
+ * 1. **Acquire**: `mkdir(lockDir)`; fails with EEXIST if held by another process
33
+ * 2. **Hold**: Execute the critical section
34
+ * 3. **Release**: `rmdir(lockDir)`, in a finally block, with a bounded retry to
35
+ * absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering
36
+ * handles) that would otherwise orphan the lock directory.
37
+ * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder
38
+ * crashed and break the lock. Breaking is done **atomically** by renaming the
39
+ * stale directory aside (only one waiter can win the rename), so two waiters can
40
+ * never both break the same lock and end up running concurrently. This is a
41
+ * heuristic; safe when the critical section is short-lived (sub-second for
42
+ * file I/O).
43
+ *
44
+ * ## Failure on timeout
45
+ *
46
+ * If the lock cannot be acquired within the timeout, a LockAcquisitionError is
47
+ * thrown. This prevents the dangerous "degraded mode" where the critical section
48
+ * runs without mutual exclusion, which can cause data loss (e.g., lost ID
49
+ * mappings during concurrent `tbd create`).
50
+ *
51
+ * IMPORTANT: `timeoutMs` must be greater than `staleMs` so stale locks from
52
+ * crashed processes are always detected and broken before the timeout expires.
53
+ */
54
+ const DEFAULT_TIMEOUT_MS = 1e4;
55
+ const DEFAULT_POLL_MS = 50;
56
+ const DEFAULT_STALE_MS = 5e3;
57
+ /**
58
+ * Lock timing profile for shared data-sync operations.
59
+ *
60
+ * Issue sync can include fetch, merge, push, and outbox import work, so it must
61
+ * not use the short stale window intended for single-file writes. `timeoutMs`
62
+ * is kept just above `staleMs` so a crashed-process lock is always broken as
63
+ * stale before the timeout expires, matching the invariant documented above.
64
+ *
65
+ * Accepted trade-off (no heartbeat): a live `tbd sync` that hangs longer than
66
+ * `staleMs` (30 min) can have its lock broken by another process mid-operation.
67
+ * For current data sizes this is acceptable; single-repo sync workloads
68
+ * complete well under the window, and adding heartbeat metadata adds
69
+ * cross-process state machinery without changing the common case. If sync
70
+ * workloads grow or the lock-break race becomes observable in practice,
71
+ * revisit by adding heartbeat metadata inside the lock directory (touch mtime
72
+ * periodically; treat as stale only if heartbeat is older than `staleMs`).
73
+ * See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Post-Review
74
+ * Hardening H6.
75
+ */
76
+ const DATA_SYNC_LOCK_OPTIONS = {
77
+ timeoutMs: 35 * 6e4,
78
+ pollMs: 250,
79
+ staleMs: 30 * 6e4
80
+ };
81
+ /**
82
+ * Error thrown when the lock cannot be acquired within the timeout.
83
+ */
84
+ var LockAcquisitionError = class extends Error {
85
+ constructor(lockPath, timeoutMs) {
86
+ super(`Failed to acquire lock at ${lockPath} within ${timeoutMs}ms. Another process may be holding the lock. If this persists, delete the lock directory manually and retry.`);
87
+ this.name = "LockAcquisitionError";
88
+ }
89
+ };
90
+ /** Filesystem error codes that are transient on Windows and worth retrying. */
91
+ const TRANSIENT_RMDIR_CODES = new Set([
92
+ "EBUSY",
93
+ "EPERM",
94
+ "EACCES",
95
+ "ENOTEMPTY"
96
+ ]);
97
+ /**
98
+ * Remove a lock directory, tolerating transient Windows failures.
99
+ *
100
+ * `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners
101
+ * or lingering directory handles). A few short retries make release reliable; if it
102
+ * still fails, we give up and let stale detection reclaim the directory rather than
103
+ * throwing from a best-effort cleanup path.
104
+ */
105
+ async function removeLockDir(lockPath, attempts = 5) {
106
+ for (let attempt = 0; attempt < attempts; attempt++) try {
107
+ await rmdir(lockPath);
108
+ return;
109
+ } catch (error) {
110
+ const code = error.code;
111
+ if (code === "ENOENT") return;
112
+ if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {
113
+ await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
114
+ continue;
115
+ }
116
+ return;
117
+ }
118
+ }
119
+ /**
120
+ * Atomically break a stale lock.
121
+ *
122
+ * Renames the stale directory to a unique sidecar path and removes it. `rename` is
123
+ * atomic, so when several waiters race to break the same stale lock only one wins the
124
+ * rename; the losers see ENOENT and simply retry. This prevents the classic
125
+ * non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both
126
+ * acquire it, defeating mutual exclusion.
127
+ */
128
+ async function breakStaleLock(lockPath) {
129
+ const sidecar = `${lockPath}.stale-${randomUUID()}`;
130
+ try {
131
+ await rename(lockPath, sidecar);
132
+ } catch {
133
+ return;
134
+ }
135
+ await removeLockDir(sidecar);
136
+ }
137
+ /**
138
+ * Execute `fn` while holding a lockfile.
139
+ *
140
+ * The lock is a directory at `lockPath` (typically `<target-file>.lock`).
141
+ * Concurrent callers will wait up to `timeoutMs` for the lock, polling
142
+ * every `pollMs`. Stale locks older than `staleMs` are broken automatically.
143
+ *
144
+ * If the lock cannot be acquired within the timeout, a LockAcquisitionError
145
+ * is thrown. This ensures mutual exclusion is never silently bypassed, which
146
+ * prevents data loss from concurrent writes.
147
+ *
148
+ * @param lockPath - Path to use as the lock directory (e.g., "/path/to/ids.yml.lock")
149
+ * @param fn - Critical section to execute under the lock
150
+ * @param options - Timing parameters for lock acquisition
151
+ * @returns The return value of `fn`
152
+ * @throws LockAcquisitionError if the lock cannot be acquired within the timeout
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * await withLockfile('/path/to/ids.yml.lock', async () => {
157
+ * const data = await readFile('/path/to/ids.yml', 'utf-8');
158
+ * const updated = mergeEntries(data, newEntries);
159
+ * await writeFile('/path/to/ids.yml', updated);
160
+ * });
161
+ * ```
162
+ */
163
+ async function withLockfile(lockPath, fn, options) {
164
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
165
+ const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;
166
+ const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;
167
+ const deadline = Date.now() + timeoutMs;
168
+ let acquired = false;
169
+ while (Date.now() < deadline) try {
170
+ await mkdir(lockPath);
171
+ acquired = true;
172
+ break;
173
+ } catch (error) {
174
+ if (error.code !== "EEXIST") throw error;
175
+ let lockStat;
176
+ try {
177
+ lockStat = await stat(lockPath);
178
+ } catch {
179
+ continue;
180
+ }
181
+ 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.`);
182
+ if (Date.now() - lockStat.mtimeMs > staleMs) {
183
+ await breakStaleLock(lockPath);
184
+ continue;
185
+ }
186
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
187
+ }
188
+ if (!acquired) throw new LockAcquisitionError(lockPath, timeoutMs);
189
+ try {
190
+ return await fn();
191
+ } finally {
192
+ await removeLockDir(lockPath);
193
+ }
194
+ }
195
+
196
+ //#endregion
197
+ export { withLockfile as n, DATA_SYNC_LOCK_OPTIONS as t };
198
+ //# sourceMappingURL=lockfile-BR0laSDy.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lockfile-BR0laSDy.mjs","names":[],"sources":["../src/utils/lockfile.ts"],"sourcesContent":["/**\n * Directory-based mutual exclusion for concurrent file access.\n *\n * Note: Despite the name \"lockfile\", this is NOT a POSIX file lock (flock/fcntl).\n * It uses mkdir to create a lock *directory* as a coordination convention; no\n * OS-level file locking syscalls are involved. This makes it portable across all\n * filesystems, including NFS and other network mounts where flock/fcntl locks\n * are unreliable or unsupported.\n *\n * This is the same strategy used by:\n *\n * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)\n * See: https://git-scm.com/docs/gitrepository-layout (\"lockfile protocol\")\n * - **npm** for package-lock.json concurrent access\n *\n * ## Why mkdir?\n *\n * `mkdir(2)` is atomic on all common filesystems (local and network): it either\n * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,\n * a directory lock is trivially distinguishable from normal files.\n *\n * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving\n * the atomicity guarantee:\n * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback\n *\n * ## Lock lifecycle\n *\n * 1. **Acquire**: `mkdir(lockDir)`; fails with EEXIST if held by another process\n * 2. **Hold**: Execute the critical section\n * 3. **Release**: `rmdir(lockDir)`, in a finally block, with a bounded retry to\n * absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering\n * handles) that would otherwise orphan the lock directory.\n * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder\n * crashed and break the lock. Breaking is done **atomically** by renaming the\n * stale directory aside (only one waiter can win the rename), so two waiters can\n * never both break the same lock and end up running concurrently. This is a\n * heuristic; safe when the critical section is short-lived (sub-second for\n * file I/O).\n *\n * ## Failure on timeout\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError is\n * thrown. This prevents the dangerous \"degraded mode\" where the critical section\n * runs without mutual exclusion, which can cause data loss (e.g., lost ID\n * mappings during concurrent `tbd create`).\n *\n * IMPORTANT: `timeoutMs` must be greater than `staleMs` so stale locks from\n * crashed processes are always detected and broken before the timeout expires.\n */\n\nimport { mkdir, rename, rmdir, stat } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\n\n/** Options for `withLockfile`. */\nexport interface LockfileOptions {\n /** Maximum time (ms) to wait for the lock. Default: 10000 */\n timeoutMs?: number;\n /** Polling interval (ms) between acquisition attempts. Default: 50 */\n pollMs?: number;\n /** Age (ms) after which a lock is considered stale. Default: 5000 */\n staleMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_POLL_MS = 50;\nconst DEFAULT_STALE_MS = 5_000;\n\n/**\n * Lock timing profile for shared data-sync operations.\n *\n * Issue sync can include fetch, merge, push, and outbox import work, so it must\n * not use the short stale window intended for single-file writes. `timeoutMs`\n * is kept just above `staleMs` so a crashed-process lock is always broken as\n * stale before the timeout expires, matching the invariant documented above.\n *\n * Accepted trade-off (no heartbeat): a live `tbd sync` that hangs longer than\n * `staleMs` (30 min) can have its lock broken by another process mid-operation.\n * For current data sizes this is acceptable; single-repo sync workloads\n * complete well under the window, and adding heartbeat metadata adds\n * cross-process state machinery without changing the common case. If sync\n * workloads grow or the lock-break race becomes observable in practice,\n * revisit by adding heartbeat metadata inside the lock directory (touch mtime\n * periodically; treat as stale only if heartbeat is older than `staleMs`).\n * See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Post-Review\n * Hardening H6.\n */\nexport const DATA_SYNC_LOCK_OPTIONS: Required<LockfileOptions> = {\n timeoutMs: 35 * 60_000,\n pollMs: 250,\n staleMs: 30 * 60_000,\n};\n\n/**\n * Error thrown when the lock cannot be acquired within the timeout.\n */\nexport class LockAcquisitionError extends Error {\n constructor(lockPath: string, timeoutMs: number) {\n super(\n `Failed to acquire lock at ${lockPath} within ${timeoutMs}ms. ` +\n `Another process may be holding the lock. If this persists, ` +\n `delete the lock directory manually and retry.`,\n );\n this.name = 'LockAcquisitionError';\n }\n}\n\n/** Filesystem error codes that are transient on Windows and worth retrying. */\nconst TRANSIENT_RMDIR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);\n\n/**\n * Remove a lock directory, tolerating transient Windows failures.\n *\n * `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners\n * or lingering directory handles). A few short retries make release reliable; if it\n * still fails, we give up and let stale detection reclaim the directory rather than\n * throwing from a best-effort cleanup path.\n */\nasync function removeLockDir(lockPath: string, attempts = 5): Promise<void> {\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n await rmdir(lockPath);\n return;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') {\n return; // Already gone; nothing to do.\n }\n if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {\n await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));\n continue;\n }\n return; // Non-transient or out of attempts: best-effort, leave for stale detection.\n }\n }\n}\n\n/**\n * Atomically break a stale lock.\n *\n * Renames the stale directory to a unique sidecar path and removes it. `rename` is\n * atomic, so when several waiters race to break the same stale lock only one wins the\n * rename; the losers see ENOENT and simply retry. This prevents the classic\n * non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both\n * acquire it, defeating mutual exclusion.\n */\nasync function breakStaleLock(lockPath: string): Promise<void> {\n const sidecar = `${lockPath}.stale-${randomUUID()}`;\n try {\n await rename(lockPath, sidecar);\n } catch {\n // Another waiter already broke or released it, or the holder is no longer stale.\n return;\n }\n await removeLockDir(sidecar);\n}\n\n/**\n * Execute `fn` while holding a lockfile.\n *\n * The lock is a directory at `lockPath` (typically `<target-file>.lock`).\n * Concurrent callers will wait up to `timeoutMs` for the lock, polling\n * every `pollMs`. Stale locks older than `staleMs` are broken automatically.\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError\n * is thrown. This ensures mutual exclusion is never silently bypassed, which\n * prevents data loss from concurrent writes.\n *\n * @param lockPath - Path to use as the lock directory (e.g., \"/path/to/ids.yml.lock\")\n * @param fn - Critical section to execute under the lock\n * @param options - Timing parameters for lock acquisition\n * @returns The return value of `fn`\n * @throws LockAcquisitionError if the lock cannot be acquired within the timeout\n *\n * @example\n * ```ts\n * await withLockfile('/path/to/ids.yml.lock', async () => {\n * const data = await readFile('/path/to/ids.yml', 'utf-8');\n * const updated = mergeEntries(data, newEntries);\n * await writeFile('/path/to/ids.yml', updated);\n * });\n * ```\n */\nexport async function withLockfile<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options?: LockfileOptions,\n): Promise<T> {\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;\n const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;\n\n const deadline = Date.now() + timeoutMs;\n let acquired = false;\n\n while (Date.now() < deadline) {\n try {\n // mkdir is atomic per POSIX.1-2017; fails with EEXIST if already held\n await mkdir(lockPath);\n acquired = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n // Unexpected error (permissions, disk full, missing parent, etc.):\n // preserve the original failure instead of misreporting lock contention.\n throw error;\n }\n\n // Lock exists; inspect it.\n let lockStat;\n try {\n lockStat = await stat(lockPath);\n } catch {\n // Lock was released between our mkdir and stat; retry immediately\n continue;\n }\n\n // A non-directory at the lock path is unexpected filesystem state. Do not\n // rename it aside: that would move the user's file out of the way and let the\n // critical section run unprotected. Fail loudly instead (mirrors how an\n // unexpected mkdir error is surfaced rather than masked as contention).\n if (!lockStat.isDirectory()) {\n throw new Error(\n `Lock path exists but is not a directory: ${lockPath}. ` +\n `Refusing to break it; remove the conflicting file and retry.`,\n );\n }\n\n if (Date.now() - lockStat.mtimeMs > staleMs) {\n // Break atomically so concurrent waiters can't both acquire.\n await breakStaleLock(lockPath);\n continue; // Retry immediately after breaking stale lock\n }\n\n // Lock is fresh; wait and retry\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n }\n }\n\n if (!acquired) {\n throw new LockAcquisitionError(lockPath, timeoutMs);\n }\n\n try {\n return await fn();\n } finally {\n // Best-effort cleanup with retry; stale lock detection handles the rest.\n await removeLockDir(lockPath);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+DA,MAAM,qBAAqB;AAC3B,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;AAqBzB,MAAa,yBAAoD;CAC/D,WAAW,KAAK;CAChB,QAAQ;CACR,SAAS,KAAK;CACf;;;;AAKD,IAAa,uBAAb,cAA0C,MAAM;CAC9C,YAAY,UAAkB,WAAmB;AAC/C,QACE,6BAA6B,SAAS,UAAU,UAAU,8GAG3D;AACD,OAAK,OAAO;;;;AAKhB,MAAM,wBAAwB,IAAI,IAAI;CAAC;CAAS;CAAS;CAAU;CAAY,CAAC;;;;;;;;;AAUhF,eAAe,cAAc,UAAkB,WAAW,GAAkB;AAC1E,MAAK,IAAI,UAAU,GAAG,UAAU,UAAU,UACxC,KAAI;AACF,QAAM,MAAM,SAAS;AACrB;UACO,OAAO;EACd,MAAM,OAAQ,MAAgC;AAC9C,MAAI,SAAS,SACX;AAEF,MAAI,UAAU,WAAW,KAAK,QAAQ,sBAAsB,IAAI,KAAK,EAAE;AACrE,SAAM,IAAI,SAAS,YAAY,WAAW,SAAS,MAAM,UAAU,GAAG,CAAC;AACvE;;AAEF;;;;;;;;;;;;AAcN,eAAe,eAAe,UAAiC;CAC7D,MAAM,UAAU,GAAG,SAAS,SAAS,YAAY;AACjD,KAAI;AACF,QAAM,OAAO,UAAU,QAAQ;SACzB;AAEN;;AAEF,OAAM,cAAc,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6B9B,eAAsB,aACpB,UACA,IACA,SACY;CACZ,MAAM,YAAY,SAAS,aAAa;CACxC,MAAM,SAAS,SAAS,UAAU;CAClC,MAAM,UAAU,SAAS,WAAW;CAEpC,MAAM,WAAW,KAAK,KAAK,GAAG;CAC9B,IAAI,WAAW;AAEf,QAAO,KAAK,KAAK,GAAG,SAClB,KAAI;AAEF,QAAM,MAAM,SAAS;AACrB,aAAW;AACX;UACO,OAAO;AACd,MAAK,MAAgC,SAAS,SAG5C,OAAM;EAIR,IAAI;AACJ,MAAI;AACF,cAAW,MAAM,KAAK,SAAS;UACzB;AAEN;;AAOF,MAAI,CAAC,SAAS,aAAa,CACzB,OAAM,IAAI,MACR,4CAA4C,SAAS,gEAEtD;AAGH,MAAI,KAAK,KAAK,GAAG,SAAS,UAAU,SAAS;AAE3C,SAAM,eAAe,SAAS;AAC9B;;AAIF,QAAM,IAAI,SAAS,YAAY,WAAW,SAAS,OAAO,CAAC;;AAI/D,KAAI,CAAC,SACH,OAAM,IAAI,qBAAqB,UAAU,UAAU;AAGrD,KAAI;AACF,SAAO,MAAM,IAAI;WACT;AAER,QAAM,cAAc,SAAS"}
@@ -0,0 +1,405 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, realpath } from "node:fs/promises";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ //#region src/lib/git-env.ts
7
+ /**
8
+ * Ambient-git-environment isolation for every git subprocess tbd spawns.
9
+ *
10
+ * tbd is a repo-local tool: it discovers its `.tbd` root by walking up from
11
+ * cwd, so its git context must come from cwd too. But git exports GIT_DIR
12
+ * (and related vars) into hook environments (always, when a hook runs) and
13
+ * an inherited *absolute* GIT_DIR overrides cwd-based discovery in every git
14
+ * child process, `-C <dir>` included. A user running tbd inside any git hook
15
+ * (post-merge, pre-push, …) would otherwise have tbd resolve the hook's
16
+ * repository instead of cwd's and read/write the WRONG repo's tbd data; this
17
+ * is the product-level half of the tbd-a1lc incident (tracked as tbd-tgwi),
18
+ * where exactly that path rewrote a real repo's data-sync state.
19
+ *
20
+ * Policy: tbd always operates on the repository containing cwd. Git location
21
+ * variables are stripped from every git subprocess, and a one-line warning is
22
+ * printed (once per process) when an ambient GIT_DIR was present, so anyone
23
+ * setting it intentionally learns it does not redirect tbd.
24
+ *
25
+ * The same variable list is used by tests/scrub-git-env.ts (vitest worker
26
+ * hygiene) and mirrored in scripts/scrub-git-env.mjs (lefthook wrapper).
27
+ */
28
+ /** Environment variables through which git overrides cwd-based discovery. */
29
+ const GIT_LOCATION_VARS = [
30
+ "GIT_DIR",
31
+ "GIT_WORK_TREE",
32
+ "GIT_INDEX_FILE",
33
+ "GIT_COMMON_DIR",
34
+ "GIT_OBJECT_DIRECTORY",
35
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
36
+ "GIT_PREFIX",
37
+ "GIT_NAMESPACE"
38
+ ];
39
+ let warnedAmbientGitDir = false;
40
+ /**
41
+ * A copy of process.env with git location variables removed (plus optional
42
+ * overrides), for use as the `env` of any spawned git process. Emits the
43
+ * one-time ambient-GIT_DIR warning as a side effect of first use.
44
+ */
45
+ function gitSafeEnv(extra) {
46
+ warnIfAmbientGitEnv();
47
+ const env = {
48
+ ...process.env,
49
+ ...extra
50
+ };
51
+ for (const name of GIT_LOCATION_VARS) delete env[name];
52
+ return env;
53
+ }
54
+ /**
55
+ * Warn once per process when an ambient GIT_DIR/GIT_WORK_TREE is being
56
+ * ignored. Stderr keeps piped stdout clean; once keeps hooks readable.
57
+ */
58
+ function warnIfAmbientGitEnv() {
59
+ if (warnedAmbientGitDir) return;
60
+ if ((process.env.GIT_DIR ?? process.env.GIT_WORK_TREE) === void 0) return;
61
+ warnedAmbientGitDir = true;
62
+ process.stderr.write(`(ignoring inherited GIT_DIR: tbd operates on the repository containing the current directory)\n`);
63
+ }
64
+
65
+ //#endregion
66
+ //#region src/lib/paths.ts
67
+ /**
68
+ * Centralized path constants for tbd.
69
+ *
70
+ * Directory structure (per spec):
71
+ *
72
+ * On main/dev branches:
73
+ * .tbd/
74
+ * Committed to the repo:
75
+ * config.yml - Project configuration
76
+ * .gitignore - Controls what's gitignored below
77
+ * workspaces/ - Persistent state (outbox, named workspaces)
78
+ * Gitignored (local only):
79
+ * state.yml - Local state
80
+ * docs/ - Installed documentation (regenerated on setup)
81
+ *
82
+ * In the Git common dir shared by all linked worktrees:
83
+ * $GIT_COMMON_DIR/tbd/
84
+ * layout.yml - Shared layout metadata
85
+ * locks/data-sync.lock/ - Repo-scoped lock directory
86
+ * backups/ - Local repair/migration backups
87
+ * data-sync-worktree/ - Hidden worktree checkout of tbd-sync branch
88
+ * .tbd/data-sync/ - issues/, mappings/, attic/, meta.yml
89
+ *
90
+ * On tbd-sync branch:
91
+ * .tbd/
92
+ * data-sync/
93
+ * issues/
94
+ * mappings/
95
+ * attic/
96
+ * meta.yml
97
+ */
98
+ /** The tbd configuration directory on main branch */
99
+ const TBD_DIR = ".tbd";
100
+ /** The config file path */
101
+ const CONFIG_FILE = join(TBD_DIR, "config.yml");
102
+ /** The local state file (gitignored) */
103
+ const STATE_FILE = join(TBD_DIR, "state.yml");
104
+ /** The worktree directory name */
105
+ const WORKTREE_DIR_NAME = "data-sync-worktree";
106
+ /** Legacy per-checkout worktree path used by f03 and earlier clients. */
107
+ const LEGACY_WORKTREE_DIR = join(TBD_DIR, WORKTREE_DIR_NAME);
108
+ /**
109
+ * @internal Primary-checkout relative path to the shared sync worktree.
110
+ *
111
+ * Only valid when `.git` is a directory (i.e., the primary checkout). Production
112
+ * code must call resolveSharedTbdPaths() instead: linked worktrees have a `.git`
113
+ * file, so this constant resolves to the wrong location for them. Intended for
114
+ * tests and the non-git fallback in resolveDataSyncDir().
115
+ */
116
+ const PRIMARY_CHECKOUT_WORKTREE_DIR = join(".git", "tbd", WORKTREE_DIR_NAME);
117
+ /** The data directory name on the sync branch */
118
+ const DATA_SYNC_DIR_NAME = "data-sync";
119
+ /**
120
+ * The data directory path as it appears on the tbd-sync branch.
121
+ * In a normal checkout this same relative path is a legacy/wrong-location fallback;
122
+ * production callers should resolve the absolute shared worktree path with
123
+ * resolveDataSyncDir().
124
+ */
125
+ const DATA_SYNC_DIR = join(TBD_DIR, DATA_SYNC_DIR_NAME);
126
+ /**
127
+ * @internal Primary-checkout relative path to the synced data via the shared worktree.
128
+ *
129
+ * Same caveat as `PRIMARY_CHECKOUT_WORKTREE_DIR`: only valid for a primary checkout.
130
+ * Production code should resolve the absolute path with resolveDataSyncDir(); this
131
+ * constant is intended for tests and the non-git fallback path.
132
+ */
133
+ const PRIMARY_CHECKOUT_DATA_SYNC_DIR = join(PRIMARY_CHECKOUT_WORKTREE_DIR, TBD_DIR, DATA_SYNC_DIR_NAME);
134
+ /** Issues directory */
135
+ const ISSUES_DIR = join(DATA_SYNC_DIR, "issues");
136
+ /** Mappings directory */
137
+ const MAPPINGS_DIR = join(DATA_SYNC_DIR, "mappings");
138
+ /** Attic directory for conflict resolution */
139
+ const ATTIC_DIR = join(DATA_SYNC_DIR, "attic");
140
+ /** Meta file for schema version */
141
+ const META_FILE = join(DATA_SYNC_DIR, "meta.yml");
142
+ /** The sync branch name */
143
+ const SYNC_BRANCH = "tbd-sync";
144
+ const execFileAsync = promisify(execFile);
145
+ /** Directory name under $GIT_COMMON_DIR for tbd local machinery. */
146
+ const GIT_COMMON_TBD_DIR_NAME = "tbd";
147
+ /** Common-dir layout metadata file name. */
148
+ const COMMON_DIR_LAYOUT_FILE_NAME = "layout.yml";
149
+ /** Shared lock directory name under $GIT_COMMON_DIR/tbd/. */
150
+ const SHARED_LOCKS_DIR_NAME = "locks";
151
+ /** Shared backups directory name under $GIT_COMMON_DIR/tbd/. */
152
+ const SHARED_BACKUPS_DIR_NAME = "backups";
153
+ /** Directory-lock name for shared data-sync operations. */
154
+ const DATA_SYNC_LOCK_DIR_NAME = "data-sync.lock";
155
+ /**
156
+ * Resolve Git's common directory from any checkout or linked worktree.
157
+ */
158
+ async function resolveGitCommonDir(cwd) {
159
+ let output;
160
+ try {
161
+ const { stdout } = await execFileAsync("git", [
162
+ "-C",
163
+ cwd,
164
+ "rev-parse",
165
+ "--git-common-dir"
166
+ ], {
167
+ env: gitSafeEnv(),
168
+ maxBuffer: 1024 * 1024
169
+ });
170
+ output = stdout.trim();
171
+ } catch {
172
+ const { stdout } = await execFileAsync("git", [
173
+ "-C",
174
+ cwd,
175
+ "rev-parse",
176
+ "--path-format=absolute",
177
+ "--git-common-dir"
178
+ ], {
179
+ env: gitSafeEnv(),
180
+ maxBuffer: 1024 * 1024
181
+ });
182
+ output = stdout.trim();
183
+ }
184
+ if (!output) throw new Error(`Unable to resolve Git common directory from ${cwd}`);
185
+ const gitCommonDir = isAbsolute(output) ? output : resolve(cwd, output);
186
+ return realpath(gitCommonDir).catch(() => gitCommonDir);
187
+ }
188
+ /**
189
+ * Build all shared tbd paths from an absolute Git common directory.
190
+ */
191
+ function buildSharedTbdPaths(gitCommonDir) {
192
+ const sharedTbdDir = join(gitCommonDir, GIT_COMMON_TBD_DIR_NAME);
193
+ const sharedWorktreePath = join(sharedTbdDir, WORKTREE_DIR_NAME);
194
+ const sharedDataSyncDir = join(sharedWorktreePath, TBD_DIR, DATA_SYNC_DIR_NAME);
195
+ const sharedLayoutPath = join(sharedTbdDir, COMMON_DIR_LAYOUT_FILE_NAME);
196
+ const sharedLocksDir = join(sharedTbdDir, SHARED_LOCKS_DIR_NAME);
197
+ return {
198
+ gitCommonDir,
199
+ sharedTbdDir,
200
+ sharedWorktreePath,
201
+ sharedDataSyncDir,
202
+ sharedLayoutPath,
203
+ sharedLocksDir,
204
+ sharedLockPath: join(sharedLocksDir, DATA_SYNC_LOCK_DIR_NAME),
205
+ sharedBackupsDir: join(sharedTbdDir, SHARED_BACKUPS_DIR_NAME)
206
+ };
207
+ }
208
+ /**
209
+ * Resolve the shared tbd paths for the repository containing baseDir.
210
+ */
211
+ async function resolveSharedTbdPaths(baseDir) {
212
+ return buildSharedTbdPaths(await resolveGitCommonDir(baseDir));
213
+ }
214
+ /**
215
+ * True when the Git common dir lives outside the project checkout, the linked
216
+ * worktree shape where the checkout can be writable while `$GIT_COMMON_DIR/tbd`
217
+ * (shared sync state + lock) is not. This is the generic signal behind the
218
+ * Codex-sandbox case in #164; it does not depend on any `CODEX_*` env var, which
219
+ * that sandbox did not expose. Used by both the `doctor` lock-writability finding
220
+ * and the write-side `SharedLockUnwritableError` so their wording matches.
221
+ */
222
+ function isCommonDirOutsideProject(gitCommonDir, projectRoot) {
223
+ const rel = relative(resolve(projectRoot), resolve(gitCommonDir));
224
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
225
+ }
226
+ /** The workspaces directory name within .tbd/ */
227
+ const WORKSPACES_DIR_NAME = "workspaces";
228
+ /** Full path to workspaces directory: .tbd/workspaces/ */
229
+ const WORKSPACES_DIR = join(TBD_DIR, WORKSPACES_DIR_NAME);
230
+ /**
231
+ * Get the path to a named workspace directory.
232
+ *
233
+ * Workspaces are stored at: .tbd/workspaces/{name}/
234
+ *
235
+ * @param workspaceName - The name of the workspace (e.g., 'outbox', 'my-feature')
236
+ * @returns Path to the workspace directory
237
+ */
238
+ function getWorkspaceDir(workspaceName) {
239
+ return join(WORKSPACES_DIR, workspaceName);
240
+ }
241
+ /**
242
+ * Validate a workspace name.
243
+ *
244
+ * Valid workspace names:
245
+ * - Lowercase alphanumeric characters
246
+ * - Hyphens and underscores allowed
247
+ * - Must not be empty
248
+ * - Must not contain path separators or dots at start
249
+ *
250
+ * @param name - The workspace name to validate
251
+ * @returns true if the name is valid
252
+ */
253
+ function isValidWorkspaceName(name) {
254
+ if (!name || name.length === 0) return false;
255
+ if (name.startsWith(".")) return false;
256
+ return /^[a-z0-9][a-z0-9_-]*$/.test(name);
257
+ }
258
+ /** Docs directory name within .tbd/ */
259
+ const DOCS_DIR = "docs";
260
+ /** Shortcuts directory name within docs/ */
261
+ const SHORTCUTS_DIR = "shortcuts";
262
+ /** System shortcuts directory name (core docs like skill-baseline.md) */
263
+ const SYSTEM_DIR = "system";
264
+ /** Standard shortcuts directory name (workflow shortcuts) */
265
+ const STANDARD_DIR = "standard";
266
+ /** Guidelines directory name (coding rules and best practices) */
267
+ const GUIDELINES_DIR = "guidelines";
268
+ /** Templates directory name (document templates) */
269
+ const TEMPLATES_DIR = "templates";
270
+ /** Full path to docs directory: .tbd/docs/ */
271
+ const TBD_DOCS_DIR = join(TBD_DIR, DOCS_DIR);
272
+ /** Full path to shortcuts directory: .tbd/docs/shortcuts/ */
273
+ const TBD_SHORTCUTS_DIR = join(TBD_DOCS_DIR, SHORTCUTS_DIR);
274
+ /** Full path to system shortcuts: .tbd/docs/shortcuts/system/ */
275
+ const TBD_SHORTCUTS_SYSTEM = join(TBD_SHORTCUTS_DIR, SYSTEM_DIR);
276
+ /** Full path to standard shortcuts: .tbd/docs/shortcuts/standard/ */
277
+ const TBD_SHORTCUTS_STANDARD = join(TBD_SHORTCUTS_DIR, STANDARD_DIR);
278
+ /** Full path to guidelines: .tbd/docs/guidelines/ (top-level, not under shortcuts) */
279
+ const TBD_GUIDELINES_DIR = join(TBD_DOCS_DIR, GUIDELINES_DIR);
280
+ /** Full path to templates: .tbd/docs/templates/ (top-level, not under shortcuts) */
281
+ const TBD_TEMPLATES_DIR = join(TBD_DOCS_DIR, TEMPLATES_DIR);
282
+ /** Built-in docs source paths (relative to package docs/) */
283
+ const BUILTIN_SHORTCUTS_SYSTEM = join(SHORTCUTS_DIR, SYSTEM_DIR);
284
+ const BUILTIN_SHORTCUTS_STANDARD = join(SHORTCUTS_DIR, STANDARD_DIR);
285
+ /** References directory name (tbd self-docs and format references). */
286
+ const REFERENCES_DIR = "references";
287
+ /** Cached reference docs directory (docref/docmap formats, tbd-docs, tbd-design). */
288
+ const TBD_REFERENCES_DIR = join(TBD_DOCS_DIR, REFERENCES_DIR);
289
+ /**
290
+ * Default fork directory (repo-relative), where forked docs are made visible.
291
+ * A POSIX literal, not join()'d: this value is committed (manifest paths) and
292
+ * printed, so it must be identical on every platform; fs access joins it with
293
+ * the root via join(), which accepts forward slashes on Windows.
294
+ */
295
+ const FORK_DIR = "docs/tbd";
296
+ /** Fork-dir kind subdirectories (repo-relative). */
297
+ const FORK_SHORTCUTS_DIR = `${FORK_DIR}/${SHORTCUTS_DIR}`;
298
+ const FORK_GUIDELINES_DIR = `${FORK_DIR}/${GUIDELINES_DIR}`;
299
+ const FORK_TEMPLATES_DIR = `${FORK_DIR}/${TEMPLATES_DIR}`;
300
+ const FORK_REFERENCES_DIR = `${FORK_DIR}/${REFERENCES_DIR}`;
301
+ /**
302
+ * Cache-only lookup paths (the gitignored `.tbd/docs/` cache), used when forking
303
+ * needs the pristine upstream content rather than a possibly-forked copy.
304
+ */
305
+ const CACHE_SHORTCUT_PATHS = [TBD_SHORTCUTS_SYSTEM, TBD_SHORTCUTS_STANDARD];
306
+ const CACHE_GUIDELINES_PATHS = [TBD_GUIDELINES_DIR];
307
+ const CACHE_TEMPLATE_PATHS = [TBD_TEMPLATES_DIR];
308
+ const CACHE_REFERENCE_PATHS = [TBD_REFERENCES_DIR];
309
+ /**
310
+ * Default shortcut lookup paths (searched in order, relative to tbd root).
311
+ * Earlier paths take precedence: the fork dir shadows the cache, so a forked doc
312
+ * is served wherever the upstream one was. Missing dirs are skipped, so repos with
313
+ * no forks behave exactly as before.
314
+ */
315
+ const DEFAULT_SHORTCUT_PATHS = [FORK_SHORTCUTS_DIR, ...CACHE_SHORTCUT_PATHS];
316
+ /**
317
+ * Default guidelines lookup paths (relative to tbd root).
318
+ */
319
+ const DEFAULT_GUIDELINES_PATHS = [FORK_GUIDELINES_DIR, ...CACHE_GUIDELINES_PATHS];
320
+ /**
321
+ * Default template lookup paths (relative to tbd root).
322
+ */
323
+ const DEFAULT_TEMPLATE_PATHS = [FORK_TEMPLATES_DIR, ...CACHE_TEMPLATE_PATHS];
324
+ /**
325
+ * Default reference-doc lookup paths (relative to tbd root).
326
+ */
327
+ const DEFAULT_REFERENCE_PATHS = [FORK_REFERENCES_DIR, ...CACHE_REFERENCE_PATHS];
328
+ /**
329
+ * Error thrown when worktree is missing and fallback is not allowed.
330
+ * Defined inline to avoid circular dependency with errors.ts.
331
+ */
332
+ var WorktreeMissingError = class extends Error {
333
+ constructor(message = "Shared worktree not found under $GIT_COMMON_DIR/tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.") {
334
+ super(message);
335
+ this.name = "WorktreeMissingError";
336
+ }
337
+ };
338
+ /**
339
+ * Cache for resolved data sync directory.
340
+ * Reset when baseDir changes.
341
+ */
342
+ let _resolvedDataSyncDir = null;
343
+ let _resolvedBaseDir = null;
344
+ let _resolvedAllowFallback = null;
345
+ /**
346
+ * Resolve the actual data sync directory path.
347
+ *
348
+ * This function detects whether we're running with a git worktree
349
+ * (production) or in a test environment without worktree.
350
+ *
351
+ * Order of preference:
352
+ * 1. Shared worktree path if it exists:
353
+ * $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/
354
+ * 2. Direct path as fallback (only if allowFallback: true, for tests/diagnostics)
355
+ *
356
+ * @param baseDir - The tbd root directory (from requireInit or findTbdRoot)
357
+ * @param options - Options for path resolution
358
+ * @returns Resolved data sync directory path
359
+ * @throws WorktreeMissingError if worktree missing and allowFallback is false
360
+ *
361
+ * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
362
+ */
363
+ async function resolveDataSyncDir(baseDir, options) {
364
+ const allowFallback = options?.allowFallback ?? true;
365
+ if (_resolvedDataSyncDir && _resolvedBaseDir === baseDir && _resolvedAllowFallback === allowFallback) return _resolvedDataSyncDir;
366
+ let worktreePath = null;
367
+ try {
368
+ worktreePath = (await resolveSharedTbdPaths(baseDir)).sharedDataSyncDir;
369
+ } catch {
370
+ worktreePath = join(baseDir, PRIMARY_CHECKOUT_DATA_SYNC_DIR);
371
+ }
372
+ const directPath = join(baseDir, DATA_SYNC_DIR);
373
+ if (worktreePath) try {
374
+ await access(worktreePath);
375
+ _resolvedDataSyncDir = worktreePath;
376
+ _resolvedBaseDir = baseDir;
377
+ _resolvedAllowFallback = allowFallback;
378
+ return worktreePath;
379
+ } catch {}
380
+ if (!allowFallback) throw new WorktreeMissingError();
381
+ if (process.env.DEBUG || process.env.TBD_DEBUG) console.warn("[tbd:paths] resolveDataSyncDir: worktree not found, falling back to direct path");
382
+ return directPath;
383
+ }
384
+ /**
385
+ * Resolve attic directory path.
386
+ */
387
+ async function resolveAtticDir(baseDir, options) {
388
+ return join(await resolveDataSyncDir(baseDir, options), "attic");
389
+ }
390
+ /**
391
+ * Characters per token ratio for estimating token counts.
392
+ *
393
+ * Based on research of OpenAI (tiktoken) and Claude tokenizers:
394
+ * - Pure English prose: ~4-5 chars/token
395
+ * - Code and symbols: ~3 chars/token
396
+ * - Mixed markdown/code docs: ~3.5 chars/token
397
+ *
398
+ * We use 3.5 as our docs are markdown with code examples.
399
+ * This provides ~15-20% accuracy, sufficient for cost estimation.
400
+ */
401
+ const CHARS_PER_TOKEN = 3.5;
402
+
403
+ //#endregion
404
+ export { isCommonDirOutsideProject as A, TBD_GUIDELINES_DIR as C, WORKSPACES_DIR as D, TBD_TEMPLATES_DIR as E, gitSafeEnv as F, resolveAtticDir as M, resolveDataSyncDir as N, WORKTREE_DIR_NAME as O, resolveSharedTbdPaths as P, TBD_DOCS_DIR as S, TBD_SHORTCUTS_SYSTEM as T, FORK_TEMPLATES_DIR as _, CHARS_PER_TOKEN as a, SYNC_BRANCH as b, DATA_SYNC_DIR_NAME as c, DEFAULT_SHORTCUT_PATHS as d, DEFAULT_TEMPLATE_PATHS as f, FORK_SHORTCUTS_DIR as g, FORK_REFERENCES_DIR as h, CACHE_TEMPLATE_PATHS as i, isValidWorkspaceName as j, getWorkspaceDir as k, DEFAULT_GUIDELINES_PATHS as l, FORK_GUIDELINES_DIR as m, CACHE_REFERENCE_PATHS as n, CONFIG_FILE as o, FORK_DIR as p, CACHE_SHORTCUT_PATHS as r, DATA_SYNC_DIR as s, CACHE_GUIDELINES_PATHS as t, DEFAULT_REFERENCE_PATHS as u, LEGACY_WORKTREE_DIR as v, TBD_SHORTCUTS_STANDARD as w, TBD_DIR as x, STATE_FILE as y };
405
+ //# sourceMappingURL=paths-C1DpnZJW.mjs.map