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 @@
1
+ {"version":3,"file":"doc-cache-CamtXfi4.mjs","names":[],"sources":["../src/file/github-fetch.ts","../src/file/doc-sync.ts","../src/lib/format-utils.ts","../src/file/doc-cache.ts"],"sourcesContent":["/**\n * GitHub URL utilities and content fetching with `gh` CLI fallback.\n *\n * Consolidates all GitHub-specific URL handling and fetching logic:\n * - GitHub blob URL → raw URL conversion\n * - Direct HTTP fetch with timeout\n * - Fallback to `gh api` on 403 errors (common in restricted environments)\n *\n * This module is reusable across doc-sync, doc-add, and any future code\n * that needs to fetch content from GitHub.\n */\n\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst execFileAsync = promisify(execFile);\n\n// =============================================================================\n// GitHub URL Conversion\n// =============================================================================\n\n/**\n * Regular expression to match GitHub blob URLs.\n *\n * Matches patterns like:\n * https://github.com/{owner}/{repo}/blob/{ref}/{path}\n */\nconst GITHUB_BLOB_RE = /^https?:\\/\\/github\\.com\\/([^/]+)\\/([^/]+)\\/blob\\/([^/]+)\\/(.+)$/;\n\n/**\n * Regular expression to match raw.githubusercontent.com URLs.\n *\n * Matches patterns like:\n * https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}\n */\nconst RAW_GITHUB_RE = /^https?:\\/\\/raw\\.githubusercontent\\.com\\/([^/]+)\\/([^/]+)\\/([^/]+)\\/(.+)$/;\n\n/**\n * Convert a GitHub blob URL to a raw.githubusercontent.com URL.\n *\n * If the URL is already a raw URL or not a GitHub blob URL, returns it unchanged.\n *\n * @example\n * githubBlobToRawUrl('https://github.com/org/repo/blob/main/docs/file.md')\n * // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md'\n *\n * @example\n * githubBlobToRawUrl('https://raw.githubusercontent.com/org/repo/main/docs/file.md')\n * // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md' (unchanged)\n */\nexport function githubBlobToRawUrl(url: string): string {\n const match = GITHUB_BLOB_RE.exec(url);\n if (!match) {\n return url;\n }\n const [, owner, repo, ref, path] = match;\n return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`;\n}\n\n/**\n * Check if a URL is a GitHub-hosted URL (github.com or raw.githubusercontent.com).\n */\nexport function isGitHubUrl(url: string): boolean {\n return /^https?:\\/\\/(github\\.com|raw\\.githubusercontent\\.com)\\//.test(url);\n}\n\n/**\n * Parse a raw.githubusercontent.com URL into its components.\n *\n * @returns The parsed components, or null if not a raw GitHub URL\n */\nexport function parseRawGitHubUrl(\n url: string,\n): { owner: string; repo: string; ref: string; path: string } | null {\n const match = RAW_GITHUB_RE.exec(url);\n if (!match) {\n return null;\n }\n return {\n owner: match[1]!,\n repo: match[2]!,\n ref: match[3]!,\n path: match[4]!,\n };\n}\n\n// =============================================================================\n// HTTP Fetching\n// =============================================================================\n\n/** Default timeout for URL fetches in milliseconds */\nconst DEFAULT_FETCH_TIMEOUT = 30000;\n\n/**\n * Options for fetching content.\n */\nexport interface FetchOptions {\n /** Timeout in milliseconds (default: 30000) */\n timeout?: number;\n}\n\n/**\n * Result of a fetch operation.\n */\nexport interface FetchResult {\n /** The fetched content */\n content: string;\n /** Whether the gh CLI was used as a fallback */\n usedGhCli: boolean;\n}\n\n/**\n * Fetch content from a URL via direct HTTP.\n *\n * @throws If the request fails or returns a non-OK status\n */\nexport async function directFetch(url: string, options?: FetchOptions): Promise<string> {\n const timeout = options?.timeout ?? DEFAULT_FETCH_TIMEOUT;\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort();\n }, timeout);\n\n try {\n const response = await fetch(url, {\n signal: controller.signal,\n headers: {\n 'User-Agent': 'get-tbd/1.0',\n Accept: 'text/plain, text/markdown, */*',\n },\n });\n\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n }\n\n return await response.text();\n } finally {\n clearTimeout(timer);\n }\n}\n\n// =============================================================================\n// gh CLI Fetching\n// =============================================================================\n\n/**\n * Fetch content from a GitHub raw URL using `gh api`.\n *\n * Converts raw.githubusercontent.com URLs to GitHub API content endpoints\n * and decodes the base64-encoded response.\n *\n * @throws If the URL is not a GitHub URL or gh CLI fails\n */\nexport async function ghCliFetch(rawUrl: string): Promise<string> {\n const parsed = parseRawGitHubUrl(rawUrl);\n\n if (parsed) {\n try {\n const { stdout } = await execFileAsync('gh', [\n 'api',\n `/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}?ref=${parsed.ref}`,\n '--jq',\n '.content',\n '-H',\n 'Accept: application/vnd.github.v3+json',\n ]);\n\n // GitHub API returns base64-encoded content\n const base64Content = stdout.trim();\n return Buffer.from(base64Content, 'base64').toString('utf-8');\n } catch (error) {\n throw new Error(\n `Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n // For non-raw-GitHub URLs, attempt a direct gh api call\n try {\n const { stdout } = await execFileAsync('gh', ['api', rawUrl]);\n return stdout;\n } catch (error) {\n throw new Error(\n `Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n}\n\n// =============================================================================\n// Combined Fetch with Fallback\n// =============================================================================\n\n/**\n * Fetch content from a URL, falling back to `gh` CLI on 403 errors.\n *\n * GitHub.com and raw.githubusercontent.com may block requests from\n * certain environments (e.g., CI runners, corporate proxies). When\n * a 403 is received, we retry using `gh api` which authenticates\n * via the user's GitHub CLI token.\n *\n * This function also auto-converts GitHub blob URLs to raw URLs.\n *\n * @returns The fetched content and whether gh CLI was used\n * @throws If both direct fetch and gh CLI fallback fail\n */\nexport async function fetchWithGhFallback(\n url: string,\n options?: FetchOptions,\n): Promise<FetchResult> {\n const rawUrl = githubBlobToRawUrl(url);\n\n // Try direct fetch first\n try {\n const content = await directFetch(rawUrl, options);\n return { content, usedGhCli: false };\n } catch (error) {\n const is403 = error instanceof Error && error.message.includes('HTTP 403');\n if (!is403) {\n throw error;\n }\n }\n\n // 403 error - try gh CLI fallback\n const content = await ghCliFetch(rawUrl);\n return { content, usedGhCli: true };\n}\n\n/**\n * Resolve a git docref (github:o/r@ref//path, gitlab:...) to a raw-content URL.\n * The ref is required for sync determinism; callers enforce that.\n */\nexport function gitDocRefToRawUrl(ref: {\n host: 'github' | 'gitlab';\n owner: string;\n repo: string;\n ref?: string;\n path: string;\n}): string {\n const rev = ref.ref ?? 'main';\n if (ref.host === 'gitlab') {\n return `https://gitlab.com/${ref.owner}/${ref.repo}/-/raw/${rev}/${ref.path}`;\n }\n return `https://raw.githubusercontent.com/${ref.owner}/${ref.repo}/${rev}/${ref.path}`;\n}\n","/**\n * DocSync - Sync documentation files from configured sources.\n *\n * Syncs docs from internal bundled sources and external URLs to .tbd/docs/.\n *\n * See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md\n */\n\nimport { readdir, readFile, rm, mkdir, access } from 'node:fs/promises';\nimport { join, dirname } from 'node:path';\nimport { writeFile } from 'atomically';\nimport { fileURLToPath } from 'node:url';\n\nimport { TBD_DOCS_DIR } from '../lib/paths.js';\nimport { fetchWithGhFallback, gitDocRefToRawUrl } from './github-fetch.js';\nimport { tryParseDocRef } from '../docref/index.js';\nimport { gitSafeEnv } from '../lib/git-env.js';\nimport { readConfig, writeConfig, updateLocalState } from './config.js';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * A parsed document source.\n */\nexport interface DocSource {\n /** Source type: internal bundled, in-repo local file, or external URL */\n type: 'internal' | 'local' | 'url';\n /** The source location - a relative path or full URL */\n location: string;\n}\n\n/** One group of config entries sharing a source root (git repo+ref, etc.). */\nexport interface SourceGroup {\n /** Group key: 'internal', 'local', a git 'host:owner/repo@ref', or a URL origin. */\n key: string;\n /** Parsed git source shared by the group, when it is a git group. */\n gitSource?: { host: 'github' | 'gitlab'; owner: string; repo: string; ref?: string };\n /** [destPath, sourceStr] config entries in the group. */\n entries: [string, string][];\n}\n\n/**\n * Group docs_cache.files entries by source root: one group per git repo+ref\n * (so failures isolate per source and revisions are captured once), with\n * internal, local, and ad-hoc URL entries in origin-keyed groups.\n */\nexport function groupSourceEntries(config: Record<string, string>): SourceGroup[] {\n const groups = new Map<string, SourceGroup>();\n for (const [destPath, sourceStr] of Object.entries(config)) {\n let key = 'url';\n let gitSource: SourceGroup['gitSource'];\n if (sourceStr.startsWith('internal:')) {\n key = 'internal';\n } else {\n const parsed = tryParseDocRef(sourceStr);\n if (parsed?.kind === 'git') {\n key = `${parsed.host}:${parsed.owner}/${parsed.repo}@${parsed.ref ?? 'main'}`;\n gitSource = {\n host: parsed.host,\n owner: parsed.owner,\n repo: parsed.repo,\n ref: parsed.ref,\n };\n } else if (parsed?.kind === 'local') {\n key = 'local';\n } else {\n try {\n key = new URL(sourceStr).origin;\n } catch {\n key = 'url';\n }\n }\n }\n let group = groups.get(key);\n if (!group) {\n group = { key, gitSource, entries: [] };\n groups.set(key, group);\n }\n group.entries.push([destPath, sourceStr]);\n }\n return [...groups.values()];\n}\n\n/**\n * Capture the current revision of a git source's ref (one ls-remote per\n * group), for provenance. Best-effort: offline or missing git returns null.\n */\nasync function captureGitRevision(\n src: NonNullable<SourceGroup['gitSource']>,\n): Promise<string | null> {\n const repoUrl =\n src.host === 'gitlab'\n ? `https://gitlab.com/${src.owner}/${src.repo}.git`\n : `https://github.com/${src.owner}/${src.repo}.git`;\n try {\n const { execFile } = await import('node:child_process');\n const { promisify } = await import('node:util');\n const out = await promisify(execFile)('git', ['ls-remote', repoUrl, src.ref ?? 'HEAD'], {\n timeout: 10_000,\n env: gitSafeEnv(),\n });\n const sha = out.stdout.split('\\t')[0]?.trim();\n return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Result of a sync operation.\n */\nexport interface SyncResult {\n /** Captured git revisions per source group key (provenance; best-effort). */\n sourceRevisions: Record<string, string>;\n /** Paths of newly downloaded/copied docs */\n added: string[];\n /** Paths of updated docs (content changed) */\n updated: string[];\n /** Paths of removed docs (no longer in config) */\n removed: string[];\n /** Errors encountered during sync */\n errors: { path: string; error: string }[];\n /** Whether the sync was successful overall */\n success: boolean;\n}\n\n/**\n * Options for doc sync operations.\n */\nexport interface DocSyncOptions {\n /** If true, don't actually write/delete files (dry run) */\n dryRun?: boolean;\n /** If true, suppress normal output (only report errors) */\n silent?: boolean;\n}\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Prefix for internal bundled doc sources */\nconst INTERNAL_SOURCE_PREFIX = 'internal:';\n\n// =============================================================================\n// DocSync Class\n// =============================================================================\n\n/**\n * Syncs documentation files from configured sources.\n *\n * Supports:\n * - Internal bundled docs (using internal: prefix)\n * - External URLs (raw.githubusercontent.com, etc.)\n */\nexport class DocSync {\n private readonly docsDir: string;\n\n /**\n * Create a new DocSync instance.\n *\n * @param tbdRoot - The tbd project root directory (parent of .tbd/)\n * @param config - The doc_cache configuration mapping dest paths to sources\n */\n constructor(\n private readonly tbdRoot: string,\n private readonly config: Record<string, string>,\n ) {\n this.docsDir = join(tbdRoot, TBD_DOCS_DIR);\n }\n\n /**\n * Parse a source string into a DocSource.\n *\n * @example\n * parseSource('internal:shortcuts/standard/code-review-and-commit.md')\n * // => { type: 'internal', location: 'shortcuts/standard/code-review-and-commit.md' }\n *\n * @example\n * parseSource('https://raw.githubusercontent.com/org/repo/main/file.md')\n * // => { type: 'url', location: 'https://...' }\n */\n parseSource(source: string): DocSource {\n if (source.startsWith(INTERNAL_SOURCE_PREFIX)) {\n return {\n type: 'internal',\n location: source.slice(INTERNAL_SOURCE_PREFIX.length),\n };\n }\n\n // Git docrefs (github:o/r@ref//path, gitlab:...) fetch via the host's\n // raw-content endpoint; the canonical docref is what config stores.\n const parsed = tryParseDocRef(source);\n if (parsed?.kind === 'git') {\n return { type: 'url', location: gitDocRefToRawUrl(parsed) };\n }\n if (parsed?.kind === 'local') {\n return { type: 'local', location: parsed.path };\n }\n\n // Anything else is treated as a URL\n return {\n type: 'url',\n location: source,\n };\n }\n\n /**\n * Fetch content from a source.\n *\n * @throws If the source cannot be fetched\n */\n async fetchContent(source: DocSource): Promise<string> {\n if (source.type === 'internal') {\n return this.fetchInternalContent(source.location);\n }\n if (source.type === 'local') {\n // Local docref: read from the repo, relative to the tbd root.\n return await readFile(join(this.tbdRoot, source.location), 'utf-8');\n }\n return this.fetchUrlContent(source.location);\n }\n\n /**\n * Fetch content from an internal bundled doc.\n */\n private async fetchInternalContent(location: string): Promise<string> {\n const basePaths = getDocsBasePath();\n\n for (const basePath of basePaths) {\n const fullPath = join(basePath, location);\n try {\n await access(fullPath);\n return await readFile(fullPath, 'utf-8');\n } catch {\n // Try next path\n }\n }\n\n throw new Error(`Internal doc not found: ${location}`);\n }\n\n /**\n * Fetch content from a URL (with gh CLI fallback on 403).\n */\n private async fetchUrlContent(url: string): Promise<string> {\n const { content } = await fetchWithGhFallback(url);\n return content;\n }\n\n /**\n * Get the current state of the docs directory.\n * Returns a set of relative paths that exist in .tbd/docs/.\n */\n async getCurrentState(): Promise<Set<string>> {\n const paths = new Set<string>();\n\n try {\n await access(this.docsDir);\n } catch {\n // Directory doesn't exist yet\n return paths;\n }\n\n await this.scanDirectory(this.docsDir, '', paths);\n return paths;\n }\n\n /**\n * Recursively scan a directory and add all .md file paths to the set.\n */\n private async scanDirectory(baseDir: string, prefix: string, paths: Set<string>): Promise<void> {\n try {\n const dirEntries = await readdir(baseDir, { withFileTypes: true });\n\n for (const entry of dirEntries) {\n const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;\n\n if (entry.isDirectory()) {\n await this.scanDirectory(join(baseDir, entry.name), relativePath, paths);\n } else if (entry.isFile() && entry.name.endsWith('.md')) {\n paths.add(relativePath);\n }\n }\n } catch {\n // Directory doesn't exist or not readable\n }\n }\n\n /**\n * Sync all docs from config to .tbd/docs/.\n *\n * - Downloads/copies new docs\n * - Updates docs whose source has changed\n * - Removes docs no longer in config\n *\n * @param options - Sync options (dryRun, silent)\n */\n async sync(options: DocSyncOptions = {}): Promise<SyncResult> {\n const result: SyncResult = {\n sourceRevisions: {},\n added: [],\n updated: [],\n removed: [],\n errors: [],\n success: true,\n };\n\n // Get current state\n const currentPaths = await this.getCurrentState();\n const configPaths = new Set(Object.keys(this.config));\n\n // Group entries by source root (one group per git repo+ref; internal,\n // local, and ad-hoc URLs each form their own group). Grouping gives\n // per-source failure isolation; one unreachable repo skips only its own\n // entries instead of timing out on each, and one revision capture per\n // git source for provenance.\n const groups = groupSourceEntries(this.config);\n const failedGroups = new Set<string>();\n for (const group of groups) {\n if (group.gitSource) {\n const revision = await captureGitRevision(group.gitSource);\n if (revision) {\n result.sourceRevisions[group.key] = revision;\n }\n }\n }\n\n // Process each doc in config (group order; failed groups short-circuit)\n for (const group of groups) {\n for (const [destPath, sourceStr] of group.entries) {\n if (failedGroups.has(group.key)) {\n result.errors.push({\n path: destPath,\n error: `skipped: source group ${group.key} unreachable`,\n });\n result.success = false;\n continue;\n }\n try {\n const source = this.parseSource(sourceStr);\n const content = await this.fetchContent(source);\n const fullPath = join(this.docsDir, destPath);\n\n // Check if file exists and compare content\n let exists = false;\n let existingContent = '';\n\n try {\n existingContent = await readFile(fullPath, 'utf-8');\n exists = true;\n } catch {\n // File doesn't exist\n }\n\n if (!exists) {\n // New file\n if (!options.dryRun) {\n await mkdir(dirname(fullPath), { recursive: true });\n await writeFile(fullPath, content);\n }\n result.added.push(destPath);\n } else if (existingContent !== content) {\n // Content changed\n if (!options.dryRun) {\n await writeFile(fullPath, content);\n }\n result.updated.push(destPath);\n }\n // else: unchanged, do nothing\n } catch (err) {\n const message = (err as Error).message;\n result.errors.push({ path: destPath, error: message });\n result.success = false;\n // Network-shaped failures poison the whole group: skip its remaining\n // entries rather than re-timing-out per file. Cached copies are never\n // pruned on fetch failure (removal below only applies to paths that\n // left the config).\n if (\n group.gitSource &&\n /fetch|network|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|HTTP/i.test(message)\n ) {\n failedGroups.add(group.key);\n }\n }\n }\n }\n\n // Remove docs not in config\n for (const existingPath of currentPaths) {\n if (!configPaths.has(existingPath)) {\n try {\n if (!options.dryRun) {\n await rm(join(this.docsDir, existingPath));\n }\n result.removed.push(existingPath);\n } catch (err) {\n result.errors.push({\n path: existingPath,\n error: `Failed to remove: ${(err as Error).message}`,\n });\n }\n }\n }\n\n return result;\n }\n\n /**\n * Get a status of what would change without actually making changes.\n */\n async status(): Promise<SyncResult> {\n return this.sync({ dryRun: true });\n }\n}\n\n// =============================================================================\n// Helper Functions\n// =============================================================================\n\n/**\n * Get base docs paths (with fallbacks for development).\n * Matches the logic in setup.ts.\n */\nfunction getDocsBasePath(): string[] {\n const __filename = fileURLToPath(import.meta.url);\n const __dirname = dirname(__filename);\n return [\n // Bundled location (dist/docs/)\n join(__dirname, 'docs'),\n // Development: packages/tbd/docs/\n join(__dirname, '..', '..', 'docs'),\n ];\n}\n\n/**\n * Generate default doc_cache config by scanning bundled docs.\n *\n * This creates a config entry for each .md file found in the bundled\n * docs directories (shortcuts, guidelines, templates).\n *\n * @returns A doc_cache config mapping destination paths to internal: sources\n */\nexport async function generateDefaultDocCacheConfig(): Promise<Record<string, string>> {\n const config: Record<string, string> = {};\n const basePaths = getDocsBasePath();\n\n // Find the first valid base path\n let docsDir: string | null = null;\n for (const path of basePaths) {\n try {\n await access(path);\n docsDir = path;\n break;\n } catch {\n // Try next path\n }\n }\n\n if (!docsDir) {\n return config;\n }\n\n // Directories to scan\n const scanDirs = [\n { subdir: 'shortcuts/system', prefix: 'shortcuts/system' },\n { subdir: 'shortcuts/standard', prefix: 'shortcuts/standard' },\n { subdir: 'guidelines', prefix: 'guidelines' },\n { subdir: 'templates', prefix: 'templates' },\n { subdir: 'references', prefix: 'references' },\n ];\n\n // Self-docs: bundled at the docs root, served as `reference` kind under\n // their existing tbd- names (the manual stays a real, forkable doc).\n config[`references/tbd-docs.md`] = `${INTERNAL_SOURCE_PREFIX}tbd-docs.md`;\n config[`references/tbd-design.md`] = `${INTERNAL_SOURCE_PREFIX}tbd-design.md`;\n\n for (const { subdir, prefix } of scanDirs) {\n const fullDir = join(docsDir, subdir);\n try {\n const entries = await readdir(fullDir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.isFile() && entry.name.endsWith('.md')) {\n const relativePath = `${prefix}/${entry.name}`;\n config[relativePath] = `${INTERNAL_SOURCE_PREFIX}${relativePath}`;\n }\n }\n } catch {\n // Directory doesn't exist, skip\n }\n }\n\n return config;\n}\n\n/**\n * Merge user's doc_cache config with default bundled docs.\n *\n * This ensures:\n * - New bundled docs from tbd updates are added to existing configs\n * - User's custom sources (URLs, etc.) are preserved\n * - User's overrides of bundled docs are respected\n *\n * @param userConfig - The user's existing doc_cache config (may be undefined/empty)\n * @param defaults - The default config from generateDefaultDocCacheConfig()\n * @returns Merged config with defaults as base, user config overlaid\n */\nexport function mergeDocCacheConfig(\n userConfig: Record<string, string> | undefined,\n defaults: Record<string, string>,\n): Record<string, string> {\n // Start with defaults, overlay user config (user takes precedence)\n return {\n ...defaults,\n ...userConfig,\n };\n}\n\n/**\n * Check if docs are stale based on last sync time and configured hours.\n *\n * @param lastSyncAt - ISO timestamp of last sync (or undefined if never synced)\n * @param autoSyncHours - Hours between auto-syncs (0 = disabled)\n * @returns true if docs should be synced\n */\nexport function isDocsStale(lastSyncAt: string | undefined, autoSyncHours: number): boolean {\n // Auto-sync disabled\n if (autoSyncHours <= 0) {\n return false;\n }\n\n // Never synced\n if (!lastSyncAt) {\n return true;\n }\n\n const lastSync = new Date(lastSyncAt).getTime();\n const now = Date.now();\n const hoursSinceSync = (now - lastSync) / (1000 * 60 * 60);\n\n return hoursSinceSync >= autoSyncHours;\n}\n\n// =============================================================================\n// Unified Doc Sync with Defaults\n// =============================================================================\n\n/**\n * Options for syncDocsWithDefaults.\n */\nexport interface SyncDocsOptions {\n /** If true, suppress output (for auto-sync) */\n quiet?: boolean;\n /** If true, don't write files or config (dry run for --status) */\n dryRun?: boolean;\n}\n\n/**\n * Result of syncDocsWithDefaults.\n */\nexport interface SyncDocsResult {\n /** Paths of newly downloaded/copied docs */\n added: string[];\n /** Paths of updated docs (content changed) */\n updated: string[];\n /** Paths of removed docs (no longer in config) */\n removed: string[];\n /** Entries removed due to missing internal sources */\n pruned: string[];\n /** Whether the config was modified (new defaults merged or stale pruned) */\n configChanged: boolean;\n /** Errors encountered during sync */\n errors: { path: string; error: string }[];\n /** Whether the sync was successful overall */\n success: boolean;\n}\n\n/**\n * Check if an internal bundled doc exists.\n *\n * @param location - The internal doc path (without 'internal:' prefix)\n * @returns true if the doc exists in any of the bundled doc paths\n */\nexport async function internalDocExists(location: string): Promise<boolean> {\n const basePaths = getDocsBasePath();\n\n for (const basePath of basePaths) {\n const fullPath = join(basePath, location);\n try {\n await access(fullPath);\n return true;\n } catch {\n // Try next path\n }\n }\n\n return false;\n}\n\n/**\n * Prune entries from config that point to non-existent internal sources.\n *\n * This handles the case where a bundled doc is removed in a tbd update -\n * the stale config entry is automatically cleaned up.\n *\n * @param config - The doc_cache config to prune\n * @returns Object with pruned config and list of removed entries\n */\nexport async function pruneStaleInternals(\n config: Record<string, string>,\n): Promise<{ config: Record<string, string>; pruned: string[] }> {\n const result: Record<string, string> = {};\n const pruned: string[] = [];\n\n for (const [dest, source] of Object.entries(config)) {\n if (source.startsWith(INTERNAL_SOURCE_PREFIX)) {\n const location = source.slice(INTERNAL_SOURCE_PREFIX.length);\n const exists = await internalDocExists(location);\n if (!exists) {\n pruned.push(dest);\n continue; // Don't include in result\n }\n }\n result[dest] = source;\n }\n\n return { config: result, pruned };\n}\n\n/**\n * Deep equality check for config objects.\n */\nfunction configsEqual(a: Record<string, string>, b: Record<string, string>): boolean {\n const keysA = Object.keys(a).sort();\n const keysB = Object.keys(b).sort();\n\n if (keysA.length !== keysB.length) {\n return false;\n }\n\n for (const key of keysA) {\n if (!Object.hasOwn(b, key) || a[key] !== b[key]) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Sync docs with merged defaults and auto-pruning.\n *\n * This is the single entry point for all doc sync operations that need\n * to pick up new bundled docs from tbd upgrades.\n *\n * Steps:\n * 1. Read current config\n * 2. Generate defaults from bundled docs\n * 3. Merge: defaults as base, user config overlays\n * 4. Prune entries with missing internal sources\n * 5. Sync files to .tbd/docs/\n * 6. Write config if changed\n * 7. Update last_doc_sync_at in state\n *\n * @param tbdRoot - The tbd project root directory\n * @param options - Sync options (quiet, dryRun)\n * @returns Sync result with added/updated/removed/pruned counts\n */\nexport async function syncDocsWithDefaults(\n tbdRoot: string,\n options: SyncDocsOptions = {},\n): Promise<SyncDocsResult> {\n // 1. Read current config\n const config = await readConfig(tbdRoot);\n const originalFiles = config.docs_cache?.files ?? {};\n\n // 2. Generate defaults from bundled docs\n const defaults = await generateDefaultDocCacheConfig();\n\n // 3. Merge: defaults as base, user config overlays\n const merged = mergeDocCacheConfig(originalFiles, defaults);\n\n // 4. Prune entries with missing internal sources\n const { config: prunedConfig, pruned } = await pruneStaleInternals(merged);\n\n // 5. Sync files to .tbd/docs/\n const docSync = new DocSync(tbdRoot, prunedConfig);\n const syncResult = await docSync.sync({ dryRun: options.dryRun });\n\n // 6. Check if config changed\n const configChanged = !configsEqual(prunedConfig, originalFiles);\n\n // 7. Write config if changed (and not dry run)\n if (configChanged && !options.dryRun) {\n // Preserve existing lookup_path or use default\n const lookupPath = config.docs_cache?.lookup_path ?? [\n '.tbd/docs/shortcuts/system',\n '.tbd/docs/shortcuts/standard',\n ];\n config.docs_cache = {\n // Preserve sibling keys (local_dirs etc.); the cache refresh owns only\n // files and lookup_path, never the rest of the docs_cache section.\n ...config.docs_cache,\n lookup_path: lookupPath,\n files: prunedConfig,\n };\n await writeConfig(tbdRoot, config);\n }\n\n // 8. Update state (and not dry run)\n if (!options.dryRun) {\n await updateLocalState(tbdRoot, {\n last_doc_sync_at: new Date().toISOString(),\n });\n }\n\n return {\n added: syncResult.added,\n updated: syncResult.updated,\n removed: syncResult.removed,\n pruned,\n configChanged,\n errors: syncResult.errors,\n success: syncResult.success,\n };\n}\n","/**\n * Human-readable formatting utilities for tbd CLI.\n *\n * Uses sindresorhus libraries for consistent, well-tested formatting:\n * - pretty-bytes: Byte sizes (1337 -> \"1.34 kB\")\n * - pretty-ms: Durations (3600000 -> \"1h\")\n */\n\nimport prettyBytes from 'pretty-bytes';\nimport prettyMs from 'pretty-ms';\n\nimport { CHARS_PER_TOKEN } from './paths.js';\n\n// Re-export for direct use when needed\nexport { prettyBytes, prettyMs };\n\n// =============================================================================\n// Token Estimation\n// =============================================================================\n\n/**\n * Estimate token count from text content.\n * Uses CHARS_PER_TOKEN (~3.5) for markdown/code content.\n */\nexport function estimateTokens(text: string): number {\n return Math.ceil(text.length / CHARS_PER_TOKEN);\n}\n\n/**\n * Format token count: \"~1.2k tok\" or \"~450 tok\"\n */\nexport function formatTokens(tokens: number): string {\n if (tokens >= 1000) {\n return `~${(tokens / 1000).toFixed(1)}k tok`;\n }\n return `~${tokens} tok`;\n}\n\n// =============================================================================\n// Size Formatting\n// =============================================================================\n\n/**\n * Format doc size for list display: \"(1.8 kB, ~450 tok)\"\n */\nexport function formatDocSize(sizeBytes: number, approxTokens: number): string {\n return `(${prettyBytes(sizeBytes)}, ${formatTokens(approxTokens)})`;\n}\n\n// =============================================================================\n// Time Formatting\n// =============================================================================\n\n/**\n * Format relative time from Date: \"2d ago\", \"3h ago\", \"5m ago\"\n * Uses compact format for concise display.\n */\nexport function formatTimeAgo(date: Date): string {\n const ms = Date.now() - date.getTime();\n if (ms < 0) return 'just now';\n if (ms < 60000) return 'just now'; // Less than 1 minute\n return `${prettyMs(ms, { compact: true })} ago`;\n}\n\n/**\n * Format relative time from ISO timestamp string.\n * Returns null if timestamp is invalid.\n */\nexport function formatTimestampAgo(timestamp: string | undefined | null): string | null {\n if (!timestamp) return null;\n const date = new Date(timestamp);\n if (isNaN(date.getTime())) return null;\n return formatTimeAgo(date);\n}\n\n/**\n * Format duration in milliseconds: \"2h 15m\", \"3d 4h\"\n * Uses verbose format for clarity.\n */\nexport function formatDuration(ms: number): string {\n return prettyMs(ms, { verbose: true });\n}\n\n/**\n * Format duration compactly: \"2h\", \"3d\"\n * Uses compact format for tables and tight spaces.\n */\nexport function formatDurationCompact(ms: number): string {\n return prettyMs(ms, { compact: true });\n}\n","/**\n * DocCache - Path-ordered markdown document cache with lookup.\n *\n * Provides document lookups for the `tbd shortcut` command, supporting\n * both exact matching by filename and fuzzy matching against metadata.\n *\n * Also provides auto-sync functionality when docs are stale (per spec).\n *\n * See: docs/project/specs/active/plan-2026-01-22-doc-cache-abstraction.md\n * See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md\n */\n\nimport { readdir, readFile } from 'node:fs/promises';\nimport { join, basename } from 'node:path';\nimport matter from 'gray-matter';\n\nimport { readConfig, readLocalState, findTbdRoot } from './config.js';\nimport { isDocsStale, syncDocsWithDefaults } from './doc-sync.js';\nimport { estimateTokens } from '../lib/format-utils.js';\n\n// =============================================================================\n// Scoring Constants\n// =============================================================================\n\n/** Score for exact filename match (with or without .md extension) */\nexport const SCORE_EXACT_MATCH = 1.0;\n\n/** Score when query is a prefix of the filename */\nexport const SCORE_PREFIX_MATCH = 0.9;\n\n/** Score when filename contains all query words */\nexport const SCORE_CONTAINS_ALL = 0.8;\n\n/** Base score for partial word matches (multiplied by matched/total ratio) */\nexport const SCORE_PARTIAL_BASE = 0.7;\n\n/** Minimum score threshold to return a fuzzy match result */\nexport const SCORE_MIN_THRESHOLD = 0.5;\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/**\n * Frontmatter fields used for shortcut documents.\n * These are the expected fields in YAML frontmatter for searchability.\n */\nexport interface DocFrontmatter {\n /** Display title for the shortcut */\n title?: string;\n /** Brief description for fuzzy matching and listing */\n description?: string;\n /** Category for filtering (e.g., planning, review, git) */\n category?: string;\n /** Optional categorization tags */\n tags?: string[];\n}\n\n/**\n * A cached document loaded from the doc path.\n */\nexport interface CachedDoc {\n /** Full filesystem path to the document */\n path: string;\n /** Filename without extension (used for lookups) */\n name: string;\n /** Parsed YAML frontmatter, if present */\n frontmatter?: DocFrontmatter;\n /** Full file content (including frontmatter for output) */\n content: string;\n /** Which directory in the path this doc came from */\n sourceDir: string;\n /** File size in bytes */\n sizeBytes: number;\n /** Estimated token count (based on ~3.5 chars/token) */\n approxTokens: number;\n}\n\n/**\n * A document match with relevance score.\n */\nexport interface DocMatch {\n /** The matched document */\n doc: CachedDoc;\n /** Match score: 1.0 = exact, lower = fuzzier */\n score: number;\n}\n\n// =============================================================================\n// DocCache Class\n// =============================================================================\n\n/**\n * Options for loading the doc cache.\n */\nexport interface DocCacheLoadOptions {\n /** If true, suppress auto-sync output (default: false) */\n quiet?: boolean;\n}\n\n/**\n * Path-ordered markdown document cache.\n *\n * Loads all .md files from configured paths in order, with earlier paths\n * taking precedence (like shell $PATH). Supports exact lookup by filename\n * and fuzzy search across filename + frontmatter metadata.\n */\nexport class DocCache {\n /** Active docs (first occurrence of each name) */\n private docs: CachedDoc[] = [];\n\n /** All docs including shadowed ones */\n private allDocs: CachedDoc[] = [];\n\n /** Track names we've seen for shadow detection */\n private seenNames = new Set<string>();\n\n /** Whether the cache has been loaded */\n private loaded = false;\n\n /**\n * Create a new DocCache.\n *\n * @param paths - Ordered array of directory paths to search (relative to baseDir)\n * @param baseDir - Base directory for resolving relative paths (default: cwd)\n */\n constructor(\n private readonly paths: string[],\n private readonly baseDir: string = process.cwd(),\n ) {}\n\n /**\n * Load all documents from configured paths.\n *\n * Reads all .md files from each path in order. Documents with the same\n * name in later paths are shadowed (tracked but not returned by default).\n *\n * If auto-sync is enabled and docs are stale, triggers a sync first.\n *\n * @param options - Load options (quiet: suppress auto-sync output)\n */\n async load(options?: DocCacheLoadOptions): Promise<void> {\n if (this.loaded) return;\n\n // Check for auto-sync before loading\n await this.checkAutoSync(options?.quiet ?? false);\n\n for (const relativePath of this.paths) {\n const dirPath = join(this.baseDir, relativePath);\n await this.loadDirectory(dirPath, relativePath);\n }\n\n this.loaded = true;\n }\n\n /**\n * Check if docs are stale and auto-sync if needed.\n * Respects the quiet option - only silent when explicitly requested.\n *\n * Uses syncDocsWithDefaults() to ensure auto-sync also picks up new bundled\n * docs from tbd upgrades, not just existing config entries.\n *\n * @param quiet - If true, suppress sync output\n */\n private async checkAutoSync(quiet: boolean): Promise<void> {\n try {\n // Find tbd root\n const tbdRoot = await findTbdRoot(this.baseDir);\n if (!tbdRoot) return;\n\n // Read config and state\n const config = await readConfig(tbdRoot);\n const state = await readLocalState(tbdRoot);\n\n // Check if auto-sync is enabled and docs are stale\n const autoSyncHours = config.settings?.doc_auto_sync_hours ?? 24;\n if (!isDocsStale(state.last_doc_sync_at, autoSyncHours)) {\n return;\n }\n\n // Use syncDocsWithDefaults to merge bundled defaults with user config\n // This ensures new bundled docs from tbd upgrades are picked up by auto-sync\n await syncDocsWithDefaults(tbdRoot, { quiet });\n } catch {\n // Auto-sync errors are silent - don't interrupt the user\n }\n }\n\n /**\n * Load documents from a single directory.\n */\n private async loadDirectory(dirPath: string, sourceDir: string): Promise<void> {\n let entries: string[];\n\n try {\n entries = await readdir(dirPath);\n } catch {\n // Directory doesn't exist or isn't readable - skip silently\n // This is expected when paths haven't been initialized yet\n return;\n }\n\n for (const entry of entries) {\n if (!entry.endsWith('.md')) continue;\n\n const filePath = join(dirPath, entry);\n const name = basename(entry, '.md');\n\n try {\n const content = await readFile(filePath, 'utf-8');\n const frontmatter = this.parseFrontmatterData(content);\n const sizeBytes = Buffer.byteLength(content, 'utf-8');\n const approxTokens = estimateTokens(content);\n\n const doc: CachedDoc = {\n path: filePath,\n name,\n frontmatter,\n content,\n sourceDir,\n sizeBytes,\n approxTokens,\n };\n\n // Track all docs\n this.allDocs.push(doc);\n\n // Only add to active docs if not shadowed\n if (!this.seenNames.has(name)) {\n this.docs.push(doc);\n this.seenNames.add(name);\n }\n } catch (error) {\n // Failed to read or parse file - skip with warning context\n console.warn(`Failed to load shortcut ${filePath}: ${(error as Error).message}`);\n }\n }\n }\n\n /**\n * Parse YAML frontmatter from content and return typed data.\n * Uses gray-matter for consistent frontmatter parsing.\n */\n private parseFrontmatterData(content: string): DocFrontmatter | undefined {\n if (!matter.test(content)) {\n return undefined;\n }\n\n try {\n const parsed = matter(content).data as Record<string, unknown>;\n return {\n title: typeof parsed.title === 'string' ? parsed.title : undefined,\n description: typeof parsed.description === 'string' ? parsed.description : undefined,\n category: typeof parsed.category === 'string' ? parsed.category : undefined,\n tags: Array.isArray(parsed.tags)\n ? parsed.tags.filter((t) => typeof t === 'string')\n : undefined,\n };\n } catch {\n // Invalid YAML in frontmatter - return undefined\n return undefined;\n }\n }\n\n /**\n * Get a document by exact name match.\n *\n * @param name - Filename to match (with or without .md extension)\n * @returns Match with score SCORE_EXACT_MATCH, or null if not found\n */\n get(name: string): DocMatch | null {\n // Strip .md extension if present\n const lookupName = name.endsWith('.md') ? name.slice(0, -3) : name;\n\n const doc = this.docs.find((d) => d.name === lookupName);\n if (!doc) return null;\n\n return { doc, score: SCORE_EXACT_MATCH };\n }\n\n /**\n * Search for documents matching a query.\n *\n * Performs fuzzy matching against filename, title, and description.\n * Returns matches sorted by score descending.\n *\n * @param query - Search query string\n * @param limit - Maximum number of results (default: 10)\n * @returns Array of matches sorted by score descending\n */\n search(query: string, limit = 10): DocMatch[] {\n const matches: DocMatch[] = [];\n\n for (const doc of this.docs) {\n const score = this.calculateScore(doc, query);\n if (score >= SCORE_MIN_THRESHOLD) {\n matches.push({ doc, score });\n }\n }\n\n // Sort by score descending, then by name for stability\n matches.sort((a, b) => {\n if (b.score !== a.score) return b.score - a.score;\n return a.doc.name.localeCompare(b.doc.name);\n });\n\n return matches.slice(0, limit);\n }\n\n /**\n * Calculate relevance score for a document against a query.\n */\n private calculateScore(doc: CachedDoc, query: string): number {\n const queryLower = query.toLowerCase().trim();\n\n // Empty query matches nothing\n if (queryLower.length === 0) {\n return 0;\n }\n\n const nameLower = doc.name.toLowerCase();\n const titleLower = doc.frontmatter?.title?.toLowerCase() ?? '';\n const descLower = doc.frontmatter?.description?.toLowerCase() ?? '';\n\n // Exact match on name\n if (nameLower === queryLower) {\n return SCORE_EXACT_MATCH;\n }\n\n // Prefix match on name\n if (nameLower.startsWith(queryLower)) {\n return SCORE_PREFIX_MATCH;\n }\n\n // Split query into words for multi-word matching\n const queryWords = queryLower.split(/\\s+/).filter((w) => w.length > 0);\n const searchableText = `${nameLower} ${titleLower} ${descLower}`;\n\n // Check if all query words are contained\n const allWordsMatch = queryWords.every((word) => searchableText.includes(word));\n if (allWordsMatch && queryWords.length > 0) {\n return SCORE_CONTAINS_ALL;\n }\n\n // Partial match - count how many words match\n const matchedWords = queryWords.filter((word) => searchableText.includes(word));\n if (matchedWords.length > 0) {\n const ratio = matchedWords.length / queryWords.length;\n return SCORE_PARTIAL_BASE * ratio;\n }\n\n return 0;\n }\n\n /**\n * List all documents.\n *\n * @param includeAll - If true, include shadowed documents\n * @returns Array of cached documents\n */\n list(includeAll = false): CachedDoc[] {\n return includeAll ? this.allDocs : this.docs;\n }\n\n /**\n * Check if a document is shadowed by an earlier path.\n *\n * @param doc - Document to check\n * @returns True if this doc is shadowed (not the first with this name)\n */\n isShadowed(doc: CachedDoc): boolean {\n const firstDoc = this.docs.find((d) => d.name === doc.name);\n return firstDoc !== doc;\n }\n\n /**\n * Check if the cache has been loaded.\n */\n isLoaded(): boolean {\n return this.loaded;\n }\n}\n\n// =============================================================================\n// Shortcut Directory Generation\n// =============================================================================\n\n/**\n * Marker comments for shortcut directory section in skill files.\n * Used for incremental updates without overwriting user content.\n */\nconst SHORTCUT_DIRECTORY_BEGIN = '<!-- BEGIN SHORTCUT DIRECTORY -->';\nconst SHORTCUT_DIRECTORY_END = '<!-- END SHORTCUT DIRECTORY -->';\n\n/**\n * Ordered guideline groups for the generated directory.\n *\n * Grouping is inferred from the guideline name so new guidelines are placed\n * automatically. A guideline is assigned to the first group whose `match`\n * returns true, so the final catch-all group must stay last.\n */\ninterface GuidelineGroup {\n heading: string;\n note?: string;\n match: (name: string) => boolean;\n}\n\nconst GUIDELINE_GROUPS: GuidelineGroup[] = [\n {\n heading: 'General engineering',\n note: 'Read all of these for any engineering work (writing or reviewing code).',\n match: (n) =>\n n.startsWith('general-') ||\n n === 'error-handling-rules' ||\n n === 'backward-compatibility-rules' ||\n n === 'commit-conventions' ||\n n.includes('tdd') ||\n n.includes('testing') ||\n n.includes('golden'),\n },\n {\n heading: 'TypeScript & JS ecosystem',\n note: 'Also load these when working in TypeScript or JavaScript.',\n match: (n) =>\n n.startsWith('typescript-') || n.endsWith('monorepo-patterns') || n.startsWith('electron-'),\n },\n {\n heading: 'Python',\n note: 'Also load these when working in Python.',\n match: (n) => n.startsWith('python-'),\n },\n {\n heading: 'Convex',\n note: 'Also load these when working with Convex.',\n match: (n) => n.startsWith('convex-'),\n },\n {\n // Catch-all, must stay last.\n heading: 'Docs, process & tooling',\n match: () => true,\n },\n];\n\n/**\n * Build table rows from docs (shared helper for shortcuts and guidelines).\n */\nfunction buildTableRows(docs: CachedDoc[], skipNames: string[] = []): string[] {\n const sortedDocs = [...docs].sort((a, b) => a.name.localeCompare(b.name));\n const rows: string[] = [];\n\n for (const doc of sortedDocs) {\n if (skipNames.includes(doc.name)) {\n continue;\n }\n\n const name = doc.name;\n const description = doc.frontmatter?.description ?? '';\n const escapedDescription = description.replace(/\\|/g, '\\\\|');\n\n rows.push(`| ${name} | ${escapedDescription} |`);\n }\n\n return rows;\n}\n\n/**\n * Generate a formatted markdown directory of shortcuts and guidelines.\n *\n * The output includes:\n * 1. Marker comments for incremental updates\n * 2. Available Shortcuts section with name and description\n * 3. Available Guidelines section, grouped by area (General engineering first, then\n * language/framework groups), with name and description (if provided)\n *\n * @param shortcuts - Array of shortcut CachedDoc objects\n * @param guidelines - Optional array of guideline CachedDoc objects\n * @returns Formatted markdown string with shortcuts and guidelines directory\n *\n * @example\n * const directory = generateShortcutDirectory(shortcutDocs, guidelineDocs);\n * // Returns:\n * // <!-- BEGIN SHORTCUT DIRECTORY -->\n * // ## Available Shortcuts\n * // | Name | Description |\n * // | --- | --- |\n * // | code-review-and-commit | Run pre-commit checks, review changes, and commit code |\n * // ...\n * // ## Available Guidelines\n * // ### General engineering\n * // | Name | Description |\n * // | --- | --- |\n * // | error-handling-rules | Rules for handling errors... |\n * // ...\n * // ### TypeScript & JS ecosystem\n * // | typescript-rules | TypeScript coding rules and best practices |\n * // ...\n * // <!-- END SHORTCUT DIRECTORY -->\n */\nexport function generateShortcutDirectory(\n shortcuts: CachedDoc[],\n guidelines: CachedDoc[] = [],\n): string {\n const lines: string[] = [SHORTCUT_DIRECTORY_BEGIN];\n\n // Shortcuts section\n const shortcutRows = buildTableRows(shortcuts, [\n 'skill-baseline',\n 'skill-brief',\n 'skill-minimal',\n 'shortcut-explanation',\n ]);\n\n lines.push('## Available Shortcuts');\n lines.push('');\n lines.push('Run `tbd shortcut <name>` to use any of these shortcuts:');\n lines.push('');\n\n if (shortcutRows.length === 0) {\n lines.push('No shortcuts available. Create shortcuts in `.tbd/docs/shortcuts/standard/`.');\n } else {\n lines.push('| Name | Description |');\n lines.push('| --- | --- |');\n lines.push(...shortcutRows);\n }\n\n // Guidelines section (if provided), organized into groups.\n if (guidelines.length > 0) {\n // Assign each guideline to the first group it matches (catch-all is last).\n const grouped = GUIDELINE_GROUPS.map((group) => ({ group, docs: [] as CachedDoc[] }));\n const catchAll = grouped[grouped.length - 1];\n for (const doc of guidelines) {\n const entry = grouped.find((g) => g.group.match(doc.name)) ?? catchAll;\n entry?.docs.push(doc);\n }\n\n if (grouped.some((g) => g.docs.length > 0)) {\n lines.push('');\n lines.push('## Available Guidelines');\n lines.push('');\n lines.push('Run `tbd guidelines <name>` to apply any of these guidelines.');\n // This directory is injected into generated files that flowmark must not\n // reformat, so keep prose paragraphs to a single line under flowmark's wrap\n // width (~88 cols); otherwise flowmark would re-wrap them and cause churn.\n lines.push(\n 'Load the **General engineering** group first, then the language or framework group.',\n );\n\n for (const { group, docs } of grouped) {\n const rows = buildTableRows(docs);\n if (rows.length === 0) {\n continue;\n }\n\n lines.push('');\n lines.push(`### ${group.heading}`);\n if (group.note) {\n lines.push('');\n lines.push(`*${group.note}*`);\n }\n lines.push('');\n lines.push('| Name | Description |');\n lines.push('| --- | --- |');\n lines.push(...rows);\n }\n }\n }\n\n lines.push('');\n lines.push(SHORTCUT_DIRECTORY_END);\n\n return lines.join('\\n');\n}\n"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { a as SCORE_PARTIAL_BASE, i as SCORE_MIN_THRESHOLD, n as SCORE_CONTAINS_ALL, o as SCORE_PREFIX_MATCH, r as SCORE_EXACT_MATCH, s as generateShortcutDirectory, t as DocCache } from "./doc-cache-CamtXfi4.mjs";
2
+
3
+ export { DocCache };
@@ -0,0 +1,3 @@
1
+ import { a as forkFilePath, c as listLocalForkFiles, d as regenerateForkDirReadme, f as unforkDoc, i as forkDoc, l as readForkBase, n as KIND_DIR, o as forkRelPath, r as computeForkDriftSummary, s as forkStatusFor, t as ForkConflictError, u as readForkFile } from "./doc-fork-CoPi1G1N.mjs";
2
+
3
+ export { forkStatusFor, listLocalForkFiles, readForkBase, readForkFile, regenerateForkDirReadme, unforkDoc };
@@ -0,0 +1,304 @@
1
+ import { i as CACHE_TEMPLATE_PATHS, r as CACHE_SHORTCUT_PATHS, t as CACHE_GUIDELINES_PATHS } from "./paths-C1DpnZJW.mjs";
2
+ import { S as upsertFork, b as removeBaseContent, c as compareVersionsLoose, d as findFork, h as hashContent, l as computeForkStatus, m as hasUnresolvedConflict, v as readBaseContent, w as writeBaseContent, x as removeFork } from "./fork-manifest-ByU7U2do.mjs";
3
+ import { t as DocCache } from "./doc-cache-CamtXfi4.mjs";
4
+ import matter from "gray-matter";
5
+ import { mkdir, readFile, readdir, rm, rmdir, stat } from "node:fs/promises";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { writeFile } from "atomically";
8
+
9
+ //#region src/file/doc-fork.ts
10
+ /**
11
+ * Fork operations: copy a managed doc into a visible, git-tracked folder, return
12
+ * it to upstream, and report fork state.
13
+ *
14
+ * Forked files live outside `.tbd/` in the fork dir (default `docs/tbd/`), laid out
15
+ * as `<fork_dir>/<kind-dir>/<name>.md`. Provenance and the merge base live in the
16
+ * committed manifest under `.tbd/doc-forks/` (see fork-manifest.ts) so the forked
17
+ * files themselves stay clean, diffable, and forkable.
18
+ *
19
+ * These operations take the upstream content and current manifest as inputs and do
20
+ * the filesystem writes; resolving which doc/source to fork is the caller's job.
21
+ */
22
+ /** Map a doc kind to its plural directory name within the fork dir. */
23
+ const KIND_DIR = {
24
+ guideline: "guidelines",
25
+ shortcut: "shortcuts",
26
+ template: "templates",
27
+ reference: "references"
28
+ };
29
+ /** Absolute path of a forked file. */
30
+ function forkFilePath(tbdRoot, forkDir, kind, name) {
31
+ return join(tbdRoot, forkDir, KIND_DIR[kind], `${name}.md`);
32
+ }
33
+ /** Repo-relative path recorded in the manifest (always POSIX-style forward slashes). */
34
+ function forkRelPath(forkDir, kind, name) {
35
+ return `${forkDir}/${KIND_DIR[kind]}/${name}.md`;
36
+ }
37
+ async function pathExists(path) {
38
+ try {
39
+ await stat(path);
40
+ return true;
41
+ } catch (err) {
42
+ if (err.code === "ENOENT") return false;
43
+ throw err;
44
+ }
45
+ }
46
+ /** Error raised when a fork/unfork would lose user content; carries a reason code. */
47
+ var ForkConflictError = class extends Error {
48
+ constructor(code, message) {
49
+ super(message);
50
+ this.code = code;
51
+ this.name = "ForkConflictError";
52
+ }
53
+ };
54
+ /**
55
+ * Fork a doc into the fork dir, recording its base snapshot and manifest entry.
56
+ *
57
+ * Refuses to overwrite a target that exists and is not an unmodified fork (e.g. a
58
+ * pre-existing user file or a customized fork), unless `force` is set. Re-forking an
59
+ * unmodified fork refreshes it to the supplied upstream content and advances the base.
60
+ */
61
+ async function forkDoc(params) {
62
+ const { tbdRoot, forkDir, kind, name, source, content, force } = params;
63
+ const absPath = forkFilePath(tbdRoot, forkDir, kind, name);
64
+ const relPath = forkRelPath(forkDir, kind, name);
65
+ const existingEntry = findFork(params.manifest, name, kind);
66
+ let action = "created";
67
+ if (await pathExists(absPath)) {
68
+ if (hashContent(await readFile(absPath, "utf-8")) === existingEntry?.base_hash) {
69
+ if (!force && existingEntry?.tbd_version !== void 0 && params.tbdVersion !== void 0 && compareVersionsLoose(params.tbdVersion, existingEntry.tbd_version) === -1) throw new ForkConflictError("version-skew", `${name}: fork point was set by tbd ${existingEntry.tbd_version} (you have ${params.tbdVersion}); upgrade tbd before re-forking, or use --force to downgrade`);
70
+ action = "refreshed";
71
+ } else if (!force) throw new ForkConflictError("overwrite", `${relPath} already exists and is not an unmodified fork`);
72
+ }
73
+ await mkdir(dirname(absPath), { recursive: true });
74
+ await writeFile(absPath, content);
75
+ await writeBaseContent(tbdRoot, kind, name, content);
76
+ const entry = {
77
+ name,
78
+ kind,
79
+ path: relPath,
80
+ source,
81
+ base_hash: hashContent(content),
82
+ ...params.tbdVersion ? { tbd_version: params.tbdVersion } : {}
83
+ };
84
+ return {
85
+ manifest: upsertFork(params.manifest, entry),
86
+ relPath,
87
+ action
88
+ };
89
+ }
90
+ /**
91
+ * Remove a fork and fall back to upstream. Refuses to discard local customizations
92
+ * (file differs from its base) unless `force` is set. Cleans up a `missing` entry
93
+ * (file already deleted) without complaint.
94
+ */
95
+ async function unforkDoc(params) {
96
+ const { tbdRoot, forkDir, manifest, name, kind, force } = params;
97
+ const entry = findFork(manifest, name, kind);
98
+ if (!entry) throw new ForkConflictError("not-forked", `${name} is not a forked doc`);
99
+ const entryKind = entry.kind;
100
+ const absPath = forkFilePath(tbdRoot, forkDir, entryKind, name);
101
+ const relPath = entry.path;
102
+ let fileRemoved = false;
103
+ if (await pathExists(absPath)) {
104
+ if (hashContent(await readFile(absPath, "utf-8")) !== entry.base_hash && !force) throw new ForkConflictError("customized", `${name} has local customizations (differs from its base)`);
105
+ await rm(absPath, { force: true });
106
+ fileRemoved = true;
107
+ }
108
+ await removeBaseContent(tbdRoot, entryKind, name);
109
+ return {
110
+ manifest: removeFork(manifest, name, entryKind),
111
+ relPath,
112
+ fileRemoved
113
+ };
114
+ }
115
+ /**
116
+ * Compute the live {@link ForkStatus} of a manifest entry by reading its forked
117
+ * file and base, and comparing against the current upstream/cache content.
118
+ *
119
+ * @param cacheContent current upstream content, or null/undefined if the source is
120
+ * gone from the cache (orphaned).
121
+ */
122
+ async function forkStatusFor(tbdRoot, forkDir, entry, cacheContent) {
123
+ const kind = entry.kind;
124
+ const absPath = forkFilePath(tbdRoot, forkDir, kind, entry.name);
125
+ let forkContent = null;
126
+ try {
127
+ forkContent = await readFile(absPath, "utf-8");
128
+ } catch {
129
+ forkContent = null;
130
+ }
131
+ return computeForkStatus({
132
+ inManifest: true,
133
+ forkFileExists: forkContent !== null,
134
+ forkHash: forkContent !== null ? hashContent(forkContent) : void 0,
135
+ baseHash: entry.base_hash,
136
+ cacheHash: cacheContent == null ? void 0 : hashContent(cacheContent),
137
+ conflictedFlag: entry.conflicted,
138
+ markersPresent: forkContent !== null ? hasUnresolvedConflict(forkContent) : false
139
+ });
140
+ }
141
+ /** Read the forked file content for an entry, or null if it is missing. */
142
+ async function readForkFile(tbdRoot, forkDir, entry) {
143
+ try {
144
+ return await readFile(forkFilePath(tbdRoot, forkDir, entry.kind, entry.name), "utf-8");
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ /** Read the stored base snapshot for an entry, or null if it is missing. */
150
+ async function readForkBase(tbdRoot, entry) {
151
+ return readBaseContent(tbdRoot, entry.kind, entry.name);
152
+ }
153
+ /**
154
+ * List fork-dir files that have no manifest entry. These are served (the fork dir
155
+ * has top lookup precedence) but have no upstream: nothing to update or unfork.
156
+ * Only the flat `<fork_dir>/<kind-dir>/*.md` layout is scanned; names are
157
+ * identity, so nested folders are deliberately not searched (documented).
158
+ */
159
+ async function listLocalForkFiles(tbdRoot, forkDir, manifest) {
160
+ const locals = [];
161
+ for (const kind of Object.keys(KIND_DIR)) {
162
+ let entries;
163
+ try {
164
+ entries = await readdir(join(tbdRoot, forkDir, KIND_DIR[kind]));
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const entry of entries) {
169
+ if (!entry.endsWith(".md")) continue;
170
+ const name = entry.slice(0, -3);
171
+ if (!findFork(manifest, name, kind)) locals.push({
172
+ kind,
173
+ name,
174
+ relPath: forkRelPath(forkDir, kind, name)
175
+ });
176
+ }
177
+ }
178
+ locals.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
179
+ return locals;
180
+ }
181
+ /** Cache-only lookup paths per kind (the pristine upstream copies). */
182
+ const KIND_CACHE_PATHS = {
183
+ guideline: CACHE_GUIDELINES_PATHS,
184
+ shortcut: CACHE_SHORTCUT_PATHS,
185
+ template: CACHE_TEMPLATE_PATHS
186
+ };
187
+ /**
188
+ * Compute the drift summary for awareness surfaces (`tbd sync` notice, status).
189
+ * Reads the manifest, the fork dir, and the doc cache; safe to call when nothing
190
+ * is forked (all zeros, no cache loads).
191
+ */
192
+ async function computeForkDriftSummary(tbdRoot, forkDir, manifest) {
193
+ const summary = {
194
+ forks: manifest.forks.length,
195
+ customized: 0,
196
+ stale: 0,
197
+ conflicted: 0,
198
+ missing: 0,
199
+ local: 0
200
+ };
201
+ summary.local = (await listLocalForkFiles(tbdRoot, forkDir, manifest)).length;
202
+ if (manifest.forks.length === 0) return summary;
203
+ const caches = /* @__PURE__ */ new Map();
204
+ for (const entry of manifest.forks) {
205
+ let cache = caches.get(entry.kind);
206
+ if (!cache) {
207
+ cache = new DocCache(KIND_CACHE_PATHS[entry.kind] ?? [], tbdRoot);
208
+ await cache.load({ quiet: true });
209
+ caches.set(entry.kind, cache);
210
+ }
211
+ const status = await forkStatusFor(tbdRoot, forkDir, entry, cache.get(entry.name)?.doc.content ?? null);
212
+ if (status.customized) summary.customized++;
213
+ if (status.stale) summary.stale++;
214
+ if (status.conflicted) summary.conflicted++;
215
+ if (status.state === "missing") summary.missing++;
216
+ }
217
+ return summary;
218
+ }
219
+ /**
220
+ * Sanitize untrusted text (a frontmatter blurb or doc name) for safe inclusion
221
+ * in the generated README list. The blurb comes from forked/local file content,
222
+ * so it must not be able to inject markdown structure, links, or raw HTML into a
223
+ * committed file rendered on GitHub: take the first line and strip the
224
+ * characters that break a single list-item context.
225
+ */
226
+ function sanitizeForReadme(text) {
227
+ return (text.split(/\r?\n/)[0] ?? "").replace(/[<>[\]`|]/g, "").replace(/\s+/g, " ").trim().slice(0, 200);
228
+ }
229
+ /** Percent-encode each path segment so odd local filenames make valid links. */
230
+ function readmeLinkPath(relPath) {
231
+ return relPath.split("/").map((seg) => encodeURIComponent(seg)).join("/");
232
+ }
233
+ /** First frontmatter description (or title) of a doc file, for the README index. */
234
+ async function docBlurb(absPath) {
235
+ try {
236
+ const data = matter(await readFile(absPath, "utf-8")).data;
237
+ const description = typeof data.description === "string" ? data.description : void 0;
238
+ const title = typeof data.title === "string" ? data.title : void 0;
239
+ const blurb = description ?? title;
240
+ return blurb ? sanitizeForReadme(blurb) : void 0;
241
+ } catch {
242
+ return;
243
+ }
244
+ }
245
+ /**
246
+ * Regenerate the fork dir's `README.md` index (what this folder is, who manages
247
+ * it, and one line per doc). Called after every fork/unfork/update. When nothing
248
+ * is forked and no local files remain, the README is removed and empty kind dirs
249
+ * are pruned so `unfork --all` leaves the repo pristine.
250
+ */
251
+ async function regenerateForkDirReadme(tbdRoot, forkDir, manifest) {
252
+ const readmePath = join(tbdRoot, forkDir, "README.md");
253
+ const locals = await listLocalForkFiles(tbdRoot, forkDir, manifest);
254
+ if (manifest.forks.length === 0 && locals.length === 0) {
255
+ await rm(readmePath, { force: true });
256
+ for (const kindDir of Object.values(KIND_DIR)) await rmdir(join(tbdRoot, forkDir, kindDir)).catch(() => void 0);
257
+ await rmdir(join(tbdRoot, forkDir)).catch(() => void 0);
258
+ return;
259
+ }
260
+ const rows = [...manifest.forks.map((f) => ({
261
+ kind: f.kind,
262
+ name: f.name,
263
+ relPath: forkRelPath(forkDir, f.kind, f.name),
264
+ suffix: ""
265
+ })), ...locals.map((l) => ({
266
+ ...l,
267
+ suffix: " *(local, not from an upstream)*"
268
+ }))].sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
269
+ const lines = [
270
+ "<!-- DO NOT EDIT: Generated by `tbd docs fork` (regenerated on fork/unfork/update). -->",
271
+ "",
272
+ "# tbd Docs (forked into this repo)",
273
+ "",
274
+ "Engineering guidelines, shortcuts, and templates managed by",
275
+ "[tbd](https://github.com/jlevy/tbd), forked here so they are visible, reviewable,",
276
+ "and editable. tbd serves these copies instead of its built-in versions.",
277
+ "",
278
+ "- Edit any doc in place; your copy is what tbd serves.",
279
+ "- `tbd docs status` shows each doc’s state; `tbd docs update` pulls in upstream",
280
+ " changes (three-way merge); `tbd docs unfork <name>` returns a doc to the",
281
+ " built-in version.",
282
+ "- Keep files at `<kind>/<name>.md`: names are how tbd identifies docs, nested",
283
+ " folders are not scanned, and renaming a file counts as delete + add.",
284
+ ""
285
+ ];
286
+ let currentKind = "";
287
+ for (const row of rows) {
288
+ if (row.kind !== currentKind) {
289
+ currentKind = row.kind;
290
+ lines.push(`## ${KIND_DIR[row.kind] ?? row.kind}`, "");
291
+ }
292
+ const blurb = await docBlurb(join(tbdRoot, row.relPath));
293
+ const fileName = row.relPath.split("/").slice(-2).join("/");
294
+ const label = sanitizeForReadme(row.name) || row.name.replace(/[<>[\]`|]/g, "");
295
+ lines.push(`- [**${label}**](./${readmeLinkPath(fileName)})${blurb ? `: ${blurb}` : ""}${row.suffix}`);
296
+ }
297
+ lines.push("");
298
+ await mkdir(join(tbdRoot, forkDir), { recursive: true });
299
+ await writeFile(readmePath, lines.join("\n"));
300
+ }
301
+
302
+ //#endregion
303
+ export { forkFilePath as a, listLocalForkFiles as c, regenerateForkDirReadme as d, unforkDoc as f, forkDoc as i, readForkBase as l, KIND_DIR as n, forkRelPath as o, computeForkDriftSummary as r, forkStatusFor as s, ForkConflictError as t, readForkFile as u };
304
+ //# sourceMappingURL=doc-fork-CoPi1G1N.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"doc-fork-CoPi1G1N.mjs","names":[],"sources":["../src/file/doc-fork.ts"],"sourcesContent":["/**\n * Fork operations: copy a managed doc into a visible, git-tracked folder, return\n * it to upstream, and report fork state.\n *\n * Forked files live outside `.tbd/` in the fork dir (default `docs/tbd/`), laid out\n * as `<fork_dir>/<kind-dir>/<name>.md`. Provenance and the merge base live in the\n * committed manifest under `.tbd/doc-forks/` (see fork-manifest.ts) so the forked\n * files themselves stay clean, diffable, and forkable.\n *\n * These operations take the upstream content and current manifest as inputs and do\n * the filesystem writes; resolving which doc/source to fork is the caller's job.\n */\n\nimport { readFile, readdir, rm, rmdir, mkdir, stat } from 'node:fs/promises';\nimport { dirname, join, relative } from 'node:path';\n\nimport { writeFile } from 'atomically';\nimport matter from 'gray-matter';\n\nimport { DocCache } from './doc-cache.js';\nimport {\n CACHE_GUIDELINES_PATHS,\n CACHE_SHORTCUT_PATHS,\n CACHE_TEMPLATE_PATHS,\n} from '../lib/paths.js';\n\nimport {\n type ForkEntry,\n type ForkKind,\n type ForkManifest,\n type ForkStatus,\n compareVersionsLoose,\n computeForkStatus,\n findFork,\n hashContent,\n hasUnresolvedConflict,\n readBaseContent,\n removeBaseContent,\n upsertFork,\n removeFork,\n writeBaseContent,\n} from './fork-manifest.js';\n\n/** Map a doc kind to its plural directory name within the fork dir. */\nexport const KIND_DIR: Record<ForkKind, string> = {\n guideline: 'guidelines',\n shortcut: 'shortcuts',\n template: 'templates',\n reference: 'references',\n};\n\n/** Absolute path of a forked file. */\nexport function forkFilePath(\n tbdRoot: string,\n forkDir: string,\n kind: ForkKind,\n name: string,\n): string {\n return join(tbdRoot, forkDir, KIND_DIR[kind], `${name}.md`);\n}\n\n/** Repo-relative path recorded in the manifest (always POSIX-style forward slashes). */\nexport function forkRelPath(forkDir: string, kind: ForkKind, name: string): string {\n return `${forkDir}/${KIND_DIR[kind]}/${name}.md`;\n}\n\nasync function pathExists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n return false;\n }\n throw err;\n }\n}\n\n/** Error raised when a fork/unfork would lose user content; carries a reason code. */\nexport class ForkConflictError extends Error {\n constructor(\n public readonly code: 'overwrite' | 'customized' | 'not-forked' | 'version-skew',\n message: string,\n ) {\n super(message);\n this.name = 'ForkConflictError';\n }\n}\n\nexport interface ForkDocParams {\n tbdRoot: string;\n forkDir: string;\n manifest: ForkManifest;\n kind: ForkKind;\n name: string;\n /** Provenance docref (e.g. \"internal:guidelines/python-rules.md\"). */\n source: string;\n /** Upstream content to fork (becomes both the file and the base snapshot). */\n content: string;\n tbdVersion?: string;\n force?: boolean;\n}\n\nexport interface ForkDocResult {\n manifest: ForkManifest;\n relPath: string;\n action: 'created' | 'refreshed';\n}\n\n/**\n * Fork a doc into the fork dir, recording its base snapshot and manifest entry.\n *\n * Refuses to overwrite a target that exists and is not an unmodified fork (e.g. a\n * pre-existing user file or a customized fork), unless `force` is set. Re-forking an\n * unmodified fork refreshes it to the supplied upstream content and advances the base.\n */\nexport async function forkDoc(params: ForkDocParams): Promise<ForkDocResult> {\n const { tbdRoot, forkDir, kind, name, source, content, force } = params;\n const absPath = forkFilePath(tbdRoot, forkDir, kind, name);\n const relPath = forkRelPath(forkDir, kind, name);\n const existingEntry = findFork(params.manifest, name, kind);\n\n let action: ForkDocResult['action'] = 'created';\n\n if (await pathExists(absPath)) {\n const current = await readFile(absPath, 'utf-8');\n const isUnmodifiedFork = hashContent(current) === existingEntry?.base_hash;\n if (isUnmodifiedFork) {\n // Refreshing an unmodified fork advances its base to this client's cache.\n // Refuse if the fork point was set by a NEWER tbd: refreshing would\n // downgrade the doc to our older bundled content (the same hazard the\n // update guard prevents). --force overrides.\n if (\n !force &&\n existingEntry?.tbd_version !== undefined &&\n params.tbdVersion !== undefined &&\n compareVersionsLoose(params.tbdVersion, existingEntry.tbd_version) === -1\n ) {\n throw new ForkConflictError(\n 'version-skew',\n `${name}: fork point was set by tbd ${existingEntry.tbd_version} (you have ` +\n `${params.tbdVersion}); upgrade tbd before re-forking, or use --force to downgrade`,\n );\n }\n action = 'refreshed';\n } else if (!force) {\n throw new ForkConflictError(\n 'overwrite',\n `${relPath} already exists and is not an unmodified fork`,\n );\n }\n }\n\n await mkdir(dirname(absPath), { recursive: true });\n await writeFile(absPath, content);\n await writeBaseContent(tbdRoot, kind, name, content);\n\n const entry: ForkEntry = {\n name,\n kind,\n path: relPath,\n source,\n base_hash: hashContent(content),\n ...(params.tbdVersion ? { tbd_version: params.tbdVersion } : {}),\n };\n\n return { manifest: upsertFork(params.manifest, entry), relPath, action };\n}\n\nexport interface UnforkDocParams {\n tbdRoot: string;\n forkDir: string;\n manifest: ForkManifest;\n name: string;\n kind?: ForkKind;\n force?: boolean;\n}\n\nexport interface UnforkDocResult {\n manifest: ForkManifest;\n relPath: string;\n /** True when the forked file was deleted (false when it was already missing). */\n fileRemoved: boolean;\n}\n\n/**\n * Remove a fork and fall back to upstream. Refuses to discard local customizations\n * (file differs from its base) unless `force` is set. Cleans up a `missing` entry\n * (file already deleted) without complaint.\n */\nexport async function unforkDoc(params: UnforkDocParams): Promise<UnforkDocResult> {\n const { tbdRoot, forkDir, manifest, name, kind, force } = params;\n const entry = findFork(manifest, name, kind);\n if (!entry) {\n throw new ForkConflictError('not-forked', `${name} is not a forked doc`);\n }\n const entryKind = entry.kind;\n const absPath = forkFilePath(tbdRoot, forkDir, entryKind, name);\n const relPath = entry.path;\n\n let fileRemoved = false;\n if (await pathExists(absPath)) {\n const current = await readFile(absPath, 'utf-8');\n if (hashContent(current) !== entry.base_hash && !force) {\n throw new ForkConflictError(\n 'customized',\n `${name} has local customizations (differs from its base)`,\n );\n }\n await rm(absPath, { force: true });\n fileRemoved = true;\n }\n\n await removeBaseContent(tbdRoot, entryKind, name);\n return { manifest: removeFork(manifest, name, entryKind), relPath, fileRemoved };\n}\n\n/**\n * Compute the live {@link ForkStatus} of a manifest entry by reading its forked\n * file and base, and comparing against the current upstream/cache content.\n *\n * @param cacheContent current upstream content, or null/undefined if the source is\n * gone from the cache (orphaned).\n */\nexport async function forkStatusFor(\n tbdRoot: string,\n forkDir: string,\n entry: ForkEntry,\n cacheContent: string | null | undefined,\n): Promise<ForkStatus> {\n const kind = entry.kind;\n const absPath = forkFilePath(tbdRoot, forkDir, kind, entry.name);\n let forkContent: string | null = null;\n try {\n forkContent = await readFile(absPath, 'utf-8');\n } catch {\n forkContent = null;\n }\n\n return computeForkStatus({\n inManifest: true,\n forkFileExists: forkContent !== null,\n forkHash: forkContent !== null ? hashContent(forkContent) : undefined,\n baseHash: entry.base_hash,\n cacheHash: cacheContent == null ? undefined : hashContent(cacheContent),\n conflictedFlag: entry.conflicted,\n markersPresent: forkContent !== null ? hasUnresolvedConflict(forkContent) : false,\n });\n}\n\n/** Read the forked file content for an entry, or null if it is missing. */\nexport async function readForkFile(\n tbdRoot: string,\n forkDir: string,\n entry: ForkEntry,\n): Promise<string | null> {\n try {\n return await readFile(forkFilePath(tbdRoot, forkDir, entry.kind, entry.name), 'utf-8');\n } catch {\n return null;\n }\n}\n\n/** Read the stored base snapshot for an entry, or null if it is missing. */\nexport async function readForkBase(tbdRoot: string, entry: ForkEntry): Promise<string | null> {\n return readBaseContent(tbdRoot, entry.kind, entry.name);\n}\n\n/** A hand-authored file in the fork dir with no manifest entry (state `local`). */\nexport interface LocalForkFile {\n kind: ForkKind;\n name: string;\n /** Repo-relative path (POSIX separators). */\n relPath: string;\n}\n\n/**\n * List fork-dir files that have no manifest entry. These are served (the fork dir\n * has top lookup precedence) but have no upstream: nothing to update or unfork.\n * Only the flat `<fork_dir>/<kind-dir>/*.md` layout is scanned; names are\n * identity, so nested folders are deliberately not searched (documented).\n */\nexport async function listLocalForkFiles(\n tbdRoot: string,\n forkDir: string,\n manifest: ForkManifest,\n): Promise<LocalForkFile[]> {\n const locals: LocalForkFile[] = [];\n for (const kind of Object.keys(KIND_DIR) as ForkKind[]) {\n let entries: string[];\n try {\n entries = await readdir(join(tbdRoot, forkDir, KIND_DIR[kind]));\n } catch {\n continue; // Kind dir absent; nothing forked or added there.\n }\n for (const entry of entries) {\n if (!entry.endsWith('.md')) continue;\n const name = entry.slice(0, -3);\n if (!findFork(manifest, name, kind)) {\n locals.push({ kind, name, relPath: forkRelPath(forkDir, kind, name) });\n }\n }\n }\n locals.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));\n return locals;\n}\n\n/** Cache-only lookup paths per kind (the pristine upstream copies). */\nconst KIND_CACHE_PATHS: Record<string, string[]> = {\n guideline: CACHE_GUIDELINES_PATHS,\n shortcut: CACHE_SHORTCUT_PATHS,\n template: CACHE_TEMPLATE_PATHS,\n};\n\n/** Aggregate drift counts across all forked docs, plus local files. */\nexport interface ForkDriftSummary {\n forks: number;\n customized: number;\n /** Upstream moved since the fork point (run `tbd docs update`). */\n stale: number;\n conflicted: number;\n /** Manifest entries whose forked file was deleted out-of-band. */\n missing: number;\n /** Fork-dir files with no manifest entry. */\n local: number;\n}\n\n/**\n * Compute the drift summary for awareness surfaces (`tbd sync` notice, status).\n * Reads the manifest, the fork dir, and the doc cache; safe to call when nothing\n * is forked (all zeros, no cache loads).\n */\nexport async function computeForkDriftSummary(\n tbdRoot: string,\n forkDir: string,\n manifest: ForkManifest,\n): Promise<ForkDriftSummary> {\n const summary: ForkDriftSummary = {\n forks: manifest.forks.length,\n customized: 0,\n stale: 0,\n conflicted: 0,\n missing: 0,\n local: 0,\n };\n summary.local = (await listLocalForkFiles(tbdRoot, forkDir, manifest)).length;\n if (manifest.forks.length === 0) {\n return summary;\n }\n\n const caches = new Map<string, DocCache>();\n for (const entry of manifest.forks) {\n let cache = caches.get(entry.kind);\n if (!cache) {\n cache = new DocCache(KIND_CACHE_PATHS[entry.kind] ?? [], tbdRoot);\n await cache.load({ quiet: true });\n caches.set(entry.kind, cache);\n }\n const status = await forkStatusFor(\n tbdRoot,\n forkDir,\n entry,\n cache.get(entry.name)?.doc.content ?? null,\n );\n if (status.customized) summary.customized++;\n if (status.stale) summary.stale++;\n if (status.conflicted) summary.conflicted++;\n if (status.state === 'missing') summary.missing++;\n }\n return summary;\n}\n\n/**\n * Sanitize untrusted text (a frontmatter blurb or doc name) for safe inclusion\n * in the generated README list. The blurb comes from forked/local file content,\n * so it must not be able to inject markdown structure, links, or raw HTML into a\n * committed file rendered on GitHub: take the first line and strip the\n * characters that break a single list-item context.\n */\nfunction sanitizeForReadme(text: string): string {\n const firstLine = text.split(/\\r?\\n/)[0] ?? '';\n return firstLine\n .replace(/[<>[\\]`|]/g, '')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 200);\n}\n\n/** Percent-encode each path segment so odd local filenames make valid links. */\nfunction readmeLinkPath(relPath: string): string {\n return relPath\n .split('/')\n .map((seg) => encodeURIComponent(seg))\n .join('/');\n}\n\n/** First frontmatter description (or title) of a doc file, for the README index. */\nasync function docBlurb(absPath: string): Promise<string | undefined> {\n try {\n const data = matter(await readFile(absPath, 'utf-8')).data as Record<string, unknown>;\n const description = typeof data.description === 'string' ? data.description : undefined;\n const title = typeof data.title === 'string' ? data.title : undefined;\n const blurb = description ?? title;\n return blurb ? sanitizeForReadme(blurb) : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Regenerate the fork dir's `README.md` index (what this folder is, who manages\n * it, and one line per doc). Called after every fork/unfork/update. When nothing\n * is forked and no local files remain, the README is removed and empty kind dirs\n * are pruned so `unfork --all` leaves the repo pristine.\n */\nexport async function regenerateForkDirReadme(\n tbdRoot: string,\n forkDir: string,\n manifest: ForkManifest,\n): Promise<void> {\n const readmePath = join(tbdRoot, forkDir, 'README.md');\n const locals = await listLocalForkFiles(tbdRoot, forkDir, manifest);\n\n if (manifest.forks.length === 0 && locals.length === 0) {\n await rm(readmePath, { force: true });\n for (const kindDir of Object.values(KIND_DIR)) {\n await rmdir(join(tbdRoot, forkDir, kindDir)).catch(() => undefined);\n }\n await rmdir(join(tbdRoot, forkDir)).catch(() => undefined);\n return;\n }\n\n interface IndexRow {\n kind: string;\n name: string;\n relPath: string;\n suffix: string;\n }\n const rows: IndexRow[] = [\n ...manifest.forks.map((f) => ({\n kind: f.kind,\n name: f.name,\n relPath: forkRelPath(forkDir, f.kind, f.name),\n suffix: '',\n })),\n ...locals.map((l) => ({ ...l, suffix: ' *(local, not from an upstream)*' })),\n ].sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));\n\n const lines: string[] = [\n '<!-- DO NOT EDIT: Generated by `tbd docs fork` (regenerated on fork/unfork/update). -->',\n '',\n '# tbd Docs (forked into this repo)',\n '',\n 'Engineering guidelines, shortcuts, and templates managed by',\n '[tbd](https://github.com/jlevy/tbd), forked here so they are visible, reviewable,',\n 'and editable. tbd serves these copies instead of its built-in versions.',\n '',\n '- Edit any doc in place; your copy is what tbd serves.',\n '- `tbd docs status` shows each doc’s state; `tbd docs update` pulls in upstream',\n ' changes (three-way merge); `tbd docs unfork <name>` returns a doc to the',\n ' built-in version.',\n '- Keep files at `<kind>/<name>.md`: names are how tbd identifies docs, nested',\n ' folders are not scanned, and renaming a file counts as delete + add.',\n '',\n ];\n let currentKind = '';\n for (const row of rows) {\n if (row.kind !== currentKind) {\n currentKind = row.kind;\n lines.push(`## ${KIND_DIR[row.kind as ForkKind] ?? row.kind}`, '');\n }\n const blurb = await docBlurb(join(tbdRoot, row.relPath));\n const fileName = row.relPath.split('/').slice(-2).join('/');\n // Local filenames are arbitrary on disk; escape the link text and encode the\n // link target so a name like `x<b>y.md` or `a b.md` can't break the README.\n const label = sanitizeForReadme(row.name) || row.name.replace(/[<>[\\]`|]/g, '');\n lines.push(\n `- [**${label}**](./${readmeLinkPath(fileName)})${blurb ? `: ${blurb}` : ''}${row.suffix}`,\n );\n }\n lines.push('');\n await mkdir(join(tbdRoot, forkDir), { recursive: true });\n await writeFile(readmePath, lines.join('\\n'));\n}\n\n/** Compute the repo-relative path for a fork dir given an absolute tbd root. */\nexport function relativeForkDir(tbdRoot: string, absForkDir: string): string {\n return relative(tbdRoot, absForkDir);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA4CA,MAAa,WAAqC;CAChD,WAAW;CACX,UAAU;CACV,UAAU;CACV,WAAW;CACZ;;AAGD,SAAgB,aACd,SACA,SACA,MACA,MACQ;AACR,QAAO,KAAK,SAAS,SAAS,SAAS,OAAO,GAAG,KAAK,KAAK;;;AAI7D,SAAgB,YAAY,SAAiB,MAAgB,MAAsB;AACjF,QAAO,GAAG,QAAQ,GAAG,SAAS,MAAM,GAAG,KAAK;;AAG9C,eAAe,WAAW,MAAgC;AACxD,KAAI;AACF,QAAM,KAAK,KAAK;AAChB,SAAO;UACA,KAAK;AACZ,MAAK,IAA8B,SAAS,SAC1C,QAAO;AAET,QAAM;;;;AAKV,IAAa,oBAAb,cAAuC,MAAM;CAC3C,YACE,AAAgB,MAChB,SACA;AACA,QAAM,QAAQ;EAHE;AAIhB,OAAK,OAAO;;;;;;;;;;AA+BhB,eAAsB,QAAQ,QAA+C;CAC3E,MAAM,EAAE,SAAS,SAAS,MAAM,MAAM,QAAQ,SAAS,UAAU;CACjE,MAAM,UAAU,aAAa,SAAS,SAAS,MAAM,KAAK;CAC1D,MAAM,UAAU,YAAY,SAAS,MAAM,KAAK;CAChD,MAAM,gBAAgB,SAAS,OAAO,UAAU,MAAM,KAAK;CAE3D,IAAI,SAAkC;AAEtC,KAAI,MAAM,WAAW,QAAQ,EAG3B;MADyB,YADT,MAAM,SAAS,SAAS,QAAQ,CACH,KAAK,eAAe,WAC3C;AAKpB,OACE,CAAC,SACD,eAAe,gBAAgB,UAC/B,OAAO,eAAe,UACtB,qBAAqB,OAAO,YAAY,cAAc,YAAY,KAAK,GAEvE,OAAM,IAAI,kBACR,gBACA,GAAG,KAAK,8BAA8B,cAAc,YAAY,aAC3D,OAAO,WAAW,+DACxB;AAEH,YAAS;aACA,CAAC,MACV,OAAM,IAAI,kBACR,aACA,GAAG,QAAQ,+CACZ;;AAIL,OAAM,MAAM,QAAQ,QAAQ,EAAE,EAAE,WAAW,MAAM,CAAC;AAClD,OAAM,UAAU,SAAS,QAAQ;AACjC,OAAM,iBAAiB,SAAS,MAAM,MAAM,QAAQ;CAEpD,MAAM,QAAmB;EACvB;EACA;EACA,MAAM;EACN;EACA,WAAW,YAAY,QAAQ;EAC/B,GAAI,OAAO,aAAa,EAAE,aAAa,OAAO,YAAY,GAAG,EAAE;EAChE;AAED,QAAO;EAAE,UAAU,WAAW,OAAO,UAAU,MAAM;EAAE;EAAS;EAAQ;;;;;;;AAwB1E,eAAsB,UAAU,QAAmD;CACjF,MAAM,EAAE,SAAS,SAAS,UAAU,MAAM,MAAM,UAAU;CAC1D,MAAM,QAAQ,SAAS,UAAU,MAAM,KAAK;AAC5C,KAAI,CAAC,MACH,OAAM,IAAI,kBAAkB,cAAc,GAAG,KAAK,sBAAsB;CAE1E,MAAM,YAAY,MAAM;CACxB,MAAM,UAAU,aAAa,SAAS,SAAS,WAAW,KAAK;CAC/D,MAAM,UAAU,MAAM;CAEtB,IAAI,cAAc;AAClB,KAAI,MAAM,WAAW,QAAQ,EAAE;AAE7B,MAAI,YADY,MAAM,SAAS,SAAS,QAAQ,CACxB,KAAK,MAAM,aAAa,CAAC,MAC/C,OAAM,IAAI,kBACR,cACA,GAAG,KAAK,mDACT;AAEH,QAAM,GAAG,SAAS,EAAE,OAAO,MAAM,CAAC;AAClC,gBAAc;;AAGhB,OAAM,kBAAkB,SAAS,WAAW,KAAK;AACjD,QAAO;EAAE,UAAU,WAAW,UAAU,MAAM,UAAU;EAAE;EAAS;EAAa;;;;;;;;;AAUlF,eAAsB,cACpB,SACA,SACA,OACA,cACqB;CACrB,MAAM,OAAO,MAAM;CACnB,MAAM,UAAU,aAAa,SAAS,SAAS,MAAM,MAAM,KAAK;CAChE,IAAI,cAA6B;AACjC,KAAI;AACF,gBAAc,MAAM,SAAS,SAAS,QAAQ;SACxC;AACN,gBAAc;;AAGhB,QAAO,kBAAkB;EACvB,YAAY;EACZ,gBAAgB,gBAAgB;EAChC,UAAU,gBAAgB,OAAO,YAAY,YAAY,GAAG;EAC5D,UAAU,MAAM;EAChB,WAAW,gBAAgB,OAAO,SAAY,YAAY,aAAa;EACvE,gBAAgB,MAAM;EACtB,gBAAgB,gBAAgB,OAAO,sBAAsB,YAAY,GAAG;EAC7E,CAAC;;;AAIJ,eAAsB,aACpB,SACA,SACA,OACwB;AACxB,KAAI;AACF,SAAO,MAAM,SAAS,aAAa,SAAS,SAAS,MAAM,MAAM,MAAM,KAAK,EAAE,QAAQ;SAChF;AACN,SAAO;;;;AAKX,eAAsB,aAAa,SAAiB,OAA0C;AAC5F,QAAO,gBAAgB,SAAS,MAAM,MAAM,MAAM,KAAK;;;;;;;;AAiBzD,eAAsB,mBACpB,SACA,SACA,UAC0B;CAC1B,MAAM,SAA0B,EAAE;AAClC,MAAK,MAAM,QAAQ,OAAO,KAAK,SAAS,EAAgB;EACtD,IAAI;AACJ,MAAI;AACF,aAAU,MAAM,QAAQ,KAAK,SAAS,SAAS,SAAS,MAAM,CAAC;UACzD;AACN;;AAEF,OAAK,MAAM,SAAS,SAAS;AAC3B,OAAI,CAAC,MAAM,SAAS,MAAM,CAAE;GAC5B,MAAM,OAAO,MAAM,MAAM,GAAG,GAAG;AAC/B,OAAI,CAAC,SAAS,UAAU,MAAM,KAAK,CACjC,QAAO,KAAK;IAAE;IAAM;IAAM,SAAS,YAAY,SAAS,MAAM,KAAK;IAAE,CAAC;;;AAI5E,QAAO,MAAM,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,KAAK,cAAc,EAAE,KAAK,CAAC;AACnF,QAAO;;;AAIT,MAAM,mBAA6C;CACjD,WAAW;CACX,UAAU;CACV,UAAU;CACX;;;;;;AAoBD,eAAsB,wBACpB,SACA,SACA,UAC2B;CAC3B,MAAM,UAA4B;EAChC,OAAO,SAAS,MAAM;EACtB,YAAY;EACZ,OAAO;EACP,YAAY;EACZ,SAAS;EACT,OAAO;EACR;AACD,SAAQ,SAAS,MAAM,mBAAmB,SAAS,SAAS,SAAS,EAAE;AACvE,KAAI,SAAS,MAAM,WAAW,EAC5B,QAAO;CAGT,MAAM,yBAAS,IAAI,KAAuB;AAC1C,MAAK,MAAM,SAAS,SAAS,OAAO;EAClC,IAAI,QAAQ,OAAO,IAAI,MAAM,KAAK;AAClC,MAAI,CAAC,OAAO;AACV,WAAQ,IAAI,SAAS,iBAAiB,MAAM,SAAS,EAAE,EAAE,QAAQ;AACjE,SAAM,MAAM,KAAK,EAAE,OAAO,MAAM,CAAC;AACjC,UAAO,IAAI,MAAM,MAAM,MAAM;;EAE/B,MAAM,SAAS,MAAM,cACnB,SACA,SACA,OACA,MAAM,IAAI,MAAM,KAAK,EAAE,IAAI,WAAW,KACvC;AACD,MAAI,OAAO,WAAY,SAAQ;AAC/B,MAAI,OAAO,MAAO,SAAQ;AAC1B,MAAI,OAAO,WAAY,SAAQ;AAC/B,MAAI,OAAO,UAAU,UAAW,SAAQ;;AAE1C,QAAO;;;;;;;;;AAUT,SAAS,kBAAkB,MAAsB;AAE/C,SADkB,KAAK,MAAM,QAAQ,CAAC,MAAM,IAEzC,QAAQ,cAAc,GAAG,CACzB,QAAQ,QAAQ,IAAI,CACpB,MAAM,CACN,MAAM,GAAG,IAAI;;;AAIlB,SAAS,eAAe,SAAyB;AAC/C,QAAO,QACJ,MAAM,IAAI,CACV,KAAK,QAAQ,mBAAmB,IAAI,CAAC,CACrC,KAAK,IAAI;;;AAId,eAAe,SAAS,SAA8C;AACpE,KAAI;EACF,MAAM,OAAO,OAAO,MAAM,SAAS,SAAS,QAAQ,CAAC,CAAC;EACtD,MAAM,cAAc,OAAO,KAAK,gBAAgB,WAAW,KAAK,cAAc;EAC9E,MAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;EAC5D,MAAM,QAAQ,eAAe;AAC7B,SAAO,QAAQ,kBAAkB,MAAM,GAAG;SACpC;AACN;;;;;;;;;AAUJ,eAAsB,wBACpB,SACA,SACA,UACe;CACf,MAAM,aAAa,KAAK,SAAS,SAAS,YAAY;CACtD,MAAM,SAAS,MAAM,mBAAmB,SAAS,SAAS,SAAS;AAEnE,KAAI,SAAS,MAAM,WAAW,KAAK,OAAO,WAAW,GAAG;AACtD,QAAM,GAAG,YAAY,EAAE,OAAO,MAAM,CAAC;AACrC,OAAK,MAAM,WAAW,OAAO,OAAO,SAAS,CAC3C,OAAM,MAAM,KAAK,SAAS,SAAS,QAAQ,CAAC,CAAC,YAAY,OAAU;AAErE,QAAM,MAAM,KAAK,SAAS,QAAQ,CAAC,CAAC,YAAY,OAAU;AAC1D;;CASF,MAAM,OAAmB,CACvB,GAAG,SAAS,MAAM,KAAK,OAAO;EAC5B,MAAM,EAAE;EACR,MAAM,EAAE;EACR,SAAS,YAAY,SAAS,EAAE,MAAM,EAAE,KAAK;EAC7C,QAAQ;EACT,EAAE,EACH,GAAG,OAAO,KAAK,OAAO;EAAE,GAAG;EAAG,QAAQ;EAAoC,EAAE,CAC7E,CAAC,MAAM,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,KAAK,IAAI,EAAE,KAAK,cAAc,EAAE,KAAK,CAAC;CAE9E,MAAM,QAAkB;EACtB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD;CACD,IAAI,cAAc;AAClB,MAAK,MAAM,OAAO,MAAM;AACtB,MAAI,IAAI,SAAS,aAAa;AAC5B,iBAAc,IAAI;AAClB,SAAM,KAAK,MAAM,SAAS,IAAI,SAAqB,IAAI,QAAQ,GAAG;;EAEpE,MAAM,QAAQ,MAAM,SAAS,KAAK,SAAS,IAAI,QAAQ,CAAC;EACxD,MAAM,WAAW,IAAI,QAAQ,MAAM,IAAI,CAAC,MAAM,GAAG,CAAC,KAAK,IAAI;EAG3D,MAAM,QAAQ,kBAAkB,IAAI,KAAK,IAAI,IAAI,KAAK,QAAQ,cAAc,GAAG;AAC/E,QAAM,KACJ,QAAQ,MAAM,QAAQ,eAAe,SAAS,CAAC,GAAG,QAAQ,KAAK,UAAU,KAAK,IAAI,SACnF;;AAEH,OAAM,KAAK,GAAG;AACd,OAAM,MAAM,KAAK,SAAS,QAAQ,EAAE,EAAE,WAAW,MAAM,CAAC;AACxD,OAAM,UAAU,YAAY,MAAM,KAAK,KAAK,CAAC"}