runlist 0.0.0 → 0.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/util.mjs ADDED
@@ -0,0 +1,337 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dim } from './color.mjs';
7
+
8
+ // The one list of environment variables that can name the session runlist is
9
+ // running inside. It lives here, in a leaf module, because both consumers must
10
+ // read the same list: `currentSessionId` below (journal attribution, which falls
11
+ // back to the shell) and `authoritativeSessionId` in pickup.mjs (plan ownership,
12
+ // which fails closed). Two hand-maintained copies is how the OpenCode entries
13
+ // drifted into naming variables OpenCode does not set.
14
+ //
15
+ // Order is most-specific-first. `OPENCODE_PID` is a real fallback, not a guess:
16
+ // OpenCode's CLI middleware sets it on the process every tool shell inherits,
17
+ // and it is per-OpenCode-process rather than per-session, so it sits below the
18
+ // session-scoped names above it and above `TERM_SESSION_ID` — a terminal id is
19
+ // shared by every agent run in that window and outlives all of them.
20
+ // `scope: 'session'` means the variable names one agent session; `'process'`
21
+ // and `'terminal'` are coarser — several sessions can share one, so they cannot
22
+ // tell two of them apart. `runlist doctor --session` reports that distinction, and
23
+ // it is the whole reason `runlist install opencode` exists.
24
+ const SESSION_ID_SOURCES = [
25
+ { variable: 'RUNLIST_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
26
+ { variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
27
+ { variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
28
+ { variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
29
+ // Verified against codex-cli 0.153.4 (2026-09-07): `codex exec` ran
30
+ // `env | grep ^CODEX` in a tool shell and printed both variables, each equal
31
+ // to the "session id:" line Codex prints in its own header, so they name one
32
+ // Codex thread (session), not the Codex process. THREAD_ID is what the exec
33
+ // path (core/src/exec.rs) sets first; SESSION_ID is its older twin. No
34
+ // CODEX_PID exists, so a Codex claim records no owning process and reads as
35
+ // 'unverifiable' — a takeover stays an explicit --force.
36
+ { variable: 'CODEX_THREAD_ID', prefix: null, scope: 'session', host: 'Codex' },
37
+ { variable: 'CODEX_SESSION_ID', prefix: null, scope: 'session', host: 'Codex' },
38
+ { variable: 'OPENCODE_SESSION_ID', prefix: null, scope: 'session', host: 'OpenCode' },
39
+ { variable: 'OPENCODE_SESSION', prefix: null, scope: 'session', host: 'OpenCode' },
40
+ { variable: 'OPENCODE_PID', prefix: 'opencode', scope: 'process', host: 'OpenCode' },
41
+ { variable: 'TERM_SESSION_ID', prefix: 'term', scope: 'terminal', host: 'terminal' },
42
+ ];
43
+
44
+ // Which source named the session, with the id it produced — or null when the
45
+ // environment names none.
46
+ export function hostSessionSource(env = process.env) {
47
+ for (const source of SESSION_ID_SOURCES) {
48
+ const value = env[source.variable]?.trim();
49
+ if (!value) continue;
50
+ return { ...source, id: source.prefix ? `${source.prefix}:${value}` : value };
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // The session id the environment names, or null when it names none.
56
+ export function hostSessionId(env = process.env) {
57
+ return hostSessionSource(env)?.id ?? null;
58
+ }
59
+
60
+ // Stable identifier for the current shell/agent session. Used for journal
61
+ // attribution and hint de-duplication — not for any plan locking.
62
+ export function currentSessionId() {
63
+ return hostSessionId() ?? `shell:${os.userInfo().username}@${os.hostname()}`;
64
+ }
65
+
66
+ // The running CLI's version, read once and memoized — several surfaces compare
67
+ // it against what a host integration was generated from.
68
+ let cachedVersion;
69
+ export function runlistVersion() {
70
+ if (cachedVersion === undefined) {
71
+ try {
72
+ const pkgPath = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json');
73
+ cachedVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null;
74
+ } catch {
75
+ cachedVersion = null;
76
+ }
77
+ }
78
+ return cachedVersion;
79
+ }
80
+
81
+ // Public compatibility alias for callers of older builds.
82
+ export const dotmdVersion = runlistVersion;
83
+
84
+ // Is `bin` runnable from PATH? Used to decide whether runlist can drive a host's
85
+ // own CLI or must print the in-session commands for the user to run instead.
86
+ export function which(bin) {
87
+ try {
88
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
89
+ return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ // Windows resolves `foo` to `foo.cmd` only through a shell; spawn needs the
96
+ // real name.
97
+ export function executableName(bin) {
98
+ return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
99
+ }
100
+
101
+ export function escapeTable(value) {
102
+ return String(value).replace(/\|/g, '\\|');
103
+ }
104
+
105
+ export function asString(value) {
106
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
107
+ }
108
+
109
+ export function capitalize(value) {
110
+ return value.charAt(0).toUpperCase() + value.slice(1);
111
+ }
112
+
113
+ export function toSlug(plan) {
114
+ return path.basename(plan.path, '.md');
115
+ }
116
+
117
+ export function truncate(text, max) {
118
+ if (text.length <= max) return text;
119
+ return text.slice(0, max - 3) + '...';
120
+ }
121
+
122
+ export function normalizeStringList(value) {
123
+ if (Array.isArray(value)) {
124
+ return value.map(item => String(item).trim()).filter(Boolean);
125
+ }
126
+ if (typeof value === 'string' && value.trim()) {
127
+ return [value.trim()];
128
+ }
129
+ return [];
130
+ }
131
+
132
+ export function normalizeBlockers(blockers) {
133
+ if (Array.isArray(blockers)) {
134
+ return blockers.map(item => String(item));
135
+ }
136
+ if (typeof blockers === 'string' && blockers.trim()) {
137
+ return [blockers.trim()];
138
+ }
139
+ return [];
140
+ }
141
+
142
+ export function mergeUniqueStrings(...lists) {
143
+ return [...new Set(lists.flat().filter(Boolean))];
144
+ }
145
+
146
+ export function escapeRegex(str) {
147
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ }
149
+
150
+ export function toRepoPath(absolutePath, repoRoot) {
151
+ return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
152
+ }
153
+
154
+ // True when any path segment equals `config.archiveDir`. Covers both
155
+ // `docs/plans/archived/foo.md` and `docs/prompts/archived/foo.md` regardless
156
+ // of whether the layout is single-root or flat-array. Read-side commands use
157
+ // this to skip files whose frontmatter `status:` has drifted out of sync with
158
+ // their archive location (issue #13).
159
+ export function isArchivedPath(repoPath, config) {
160
+ if (!repoPath || !config?.archiveDir) return false;
161
+ return repoPath.split('/').includes(config.archiveDir);
162
+ }
163
+
164
+ // Emit a `files: a b c` line to stderr listing every doc / index path
165
+ // the command touched (deduped, sorted, repo-relative). Lets agents do
166
+ // `git add` with the exact set instead of guessing. Opt-in via
167
+ // `--show-files` on lifecycle commands; default off to keep output stable.
168
+ export function emitFilesFooter(paths, config) {
169
+ const rel = [...new Set(paths.filter(Boolean))]
170
+ .map(p => path.isAbsolute(p) ? toRepoPath(p, config.repoRoot) : p)
171
+ .sort();
172
+ if (rel.length === 0) return;
173
+ process.stderr.write(`files: ${rel.join(' ')}\n`);
174
+ }
175
+
176
+ export function nowIso() {
177
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
178
+ }
179
+
180
+ // Coarse "how long ago", for messages where the exact interval matters less than
181
+ // which order of magnitude it is — a claim held 3d is a dead session, one held
182
+ // 4m is a colleague mid-edit. Lives here rather than in either caller because
183
+ // `hud` and `pickup` both need it and util is a leaf both already import.
184
+ export function relTime(ts, now = Date.now()) {
185
+ const t = new Date(ts).getTime();
186
+ if (!Number.isFinite(t)) return '?';
187
+ const delta = Math.max(0, now - t);
188
+ const sec = Math.floor(delta / 1000);
189
+ if (sec < 60) return `${sec}s ago`;
190
+ const min = Math.floor(sec / 60);
191
+ if (min < 60) return `${min}m ago`;
192
+ const hr = Math.floor(min / 60);
193
+ if (hr < 24) return `${hr}h ago`;
194
+ return `${Math.floor(hr / 24)}d ago`;
195
+ }
196
+
197
+ export function warn(message) {
198
+ process.stderr.write(`${dim(message)}\n`);
199
+ }
200
+
201
+ export class DotmdError extends Error {
202
+ constructor(message) {
203
+ super(message);
204
+ this.name = 'DotmdError';
205
+ }
206
+ }
207
+
208
+ export function die(message) {
209
+ throw new DotmdError(message);
210
+ }
211
+
212
+ export function levenshtein(a, b) {
213
+ if (a.length === 0) return b.length;
214
+ if (b.length === 0) return a.length;
215
+ const matrix = [];
216
+ for (let i = 0; i <= b.length; i++) matrix[i] = [i];
217
+ for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
218
+ for (let i = 1; i <= b.length; i++) {
219
+ for (let j = 1; j <= a.length; j++) {
220
+ const cost = b[i - 1] === a[j - 1] ? 0 : 1;
221
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
222
+ }
223
+ }
224
+ return matrix[b.length][a.length];
225
+ }
226
+
227
+ // Edit distance, but only as far as the caller cares. Returns the distance when
228
+ // it is <= limit and null when it provably exceeds it. The suggester asks
229
+ // "within 3 edits?" of every candidate in the index, and the full matrix
230
+ // computes an exact answer it then throws away: ~900 cells and two allocations
231
+ // per pair, several hundred thousand pairs per unresolved reference on a large
232
+ // corpus. Only cells within `limit` of the diagonal can hold a value <= limit,
233
+ // so this walks that band in two flat rows and abandons a row whose cheapest
234
+ // cell is already past the limit. Distances <= limit are identical to
235
+ // `levenshtein`'s — test/util.test.mjs checks that against it directly.
236
+ export function levenshteinWithin(a, b, limit) {
237
+ if (Math.abs(a.length - b.length) > limit) return null;
238
+ if (a.length === 0) return b.length;
239
+ if (b.length === 0) return a.length;
240
+ const over = limit + 1;
241
+ let prev = new Int32Array(a.length + 2).fill(over);
242
+ let cur = new Int32Array(a.length + 2).fill(over);
243
+ for (let j = 0; j <= Math.min(a.length, limit); j++) prev[j] = j;
244
+ for (let i = 1; i <= b.length; i++) {
245
+ const lo = Math.max(1, i - limit);
246
+ const hi = Math.min(a.length, i + limit);
247
+ // Reset the cells this row will write plus the two the next row reads as
248
+ // band neighbours, so nothing stale survives the row swap.
249
+ cur.fill(over, Math.max(0, lo - 1), hi + 2);
250
+ cur[0] = i <= limit ? i : over;
251
+ let rowMin = over;
252
+ for (let j = lo; j <= hi; j++) {
253
+ const substitution = prev[j - 1] + (b.charCodeAt(i - 1) === a.charCodeAt(j - 1) ? 0 : 1);
254
+ const deletion = prev[j] + 1;
255
+ const insertion = cur[j - 1] + 1;
256
+ let best = substitution < deletion ? substitution : deletion;
257
+ if (insertion < best) best = insertion;
258
+ if (best > limit) best = over;
259
+ cur[j] = best;
260
+ if (best < rowMin) rowMin = best;
261
+ }
262
+ if (rowMin > limit) return null;
263
+ const swap = prev; prev = cur; cur = swap;
264
+ }
265
+ const distance = prev[a.length];
266
+ return distance <= limit ? distance : null;
267
+ }
268
+
269
+ // Top-N candidates from a list, ranked for "did you mean" hints. Substring
270
+ // match wins (cheap and intent-revealing for typos that share a prefix or
271
+ // stem); Levenshtein distance ≤3 catches transpositions and small edits.
272
+ // Results are deduped and capped. Empty input or empty candidate list returns
273
+ // an empty array — callers should suppress the hint line in that case.
274
+ export function suggestCandidates(query, candidates, max = 3) {
275
+ if (!query || !candidates?.length) return [];
276
+ const lower = String(query).toLowerCase();
277
+ const scored = new Map();
278
+
279
+ for (const cand of candidates) {
280
+ if (!cand) continue;
281
+ const candLower = String(cand).toLowerCase();
282
+ if (candLower === lower) continue;
283
+ if (candLower.includes(lower) || lower.includes(candLower)) {
284
+ // Substring hits sort first; shorter candidates rank higher within that bucket.
285
+ scored.set(cand, Math.min(scored.get(cand) ?? Infinity, candLower.length));
286
+ }
287
+ }
288
+
289
+ if (scored.size < max) {
290
+ for (const cand of candidates) {
291
+ if (!cand || scored.has(cand)) continue;
292
+ const candLower = String(cand).toLowerCase();
293
+ if (candLower === lower) continue;
294
+ // The candidate list here is every basename AND every repo path in the
295
+ // index, so on a large corpus this loop ran hundreds of thousands of
296
+ // distance computations per unresolved reference. Only "within 3 edits"
297
+ // matters, so it asks for exactly that.
298
+ const dist = levenshteinWithin(lower, candLower, 3);
299
+ if (dist !== null) scored.set(cand, 1000 + dist);
300
+ }
301
+ }
302
+
303
+ return [...scored.entries()]
304
+ .sort((a, b) => a[1] - b[1])
305
+ .slice(0, max)
306
+ .map(([cand]) => cand);
307
+ }
308
+
309
+ export function resolveDocPath(input, config) {
310
+ if (!input) return null;
311
+ if (path.isAbsolute(input)) return existsSync(input) ? input : null;
312
+
313
+ let candidate = path.resolve(config.repoRoot, input);
314
+ if (existsSync(candidate)) return candidate;
315
+
316
+ const roots = config.docsRoots || [config.docsRoot];
317
+ for (const root of roots) {
318
+ candidate = path.resolve(root, input);
319
+ if (existsSync(candidate)) return candidate;
320
+ }
321
+
322
+ return null;
323
+ }
324
+
325
+ // Resolve a reference path written in frontmatter or a body link.
326
+ // Tries doc-relative first (the historical convention), then falls back to
327
+ // repo-root-relative — so paths like `docs/foo/bar.md` written from any nesting
328
+ // level resolve correctly. Returns the absolute path if either form exists,
329
+ // else null.
330
+ export function resolveRefPath(relPath, docDir, repoRoot) {
331
+ if (!relPath) return null;
332
+ const docRelative = path.resolve(docDir, relPath);
333
+ if (existsSync(docRelative)) return docRelative;
334
+ const repoRelative = path.resolve(repoRoot, relPath);
335
+ if (existsSync(repoRelative)) return repoRelative;
336
+ return null;
337
+ }