get-tbd 0.1.29 → 0.2.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 (93) hide show
  1. package/README.md +5 -1
  2. package/dist/bin.mjs +3241 -2326
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +1503 -791
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-B38rbI9u.mjs → config-BJz1m9eN.mjs} +183 -39
  7. package/dist/config-BJz1m9eN.mjs.map +1 -0
  8. package/dist/{config-C0ITTrtc.mjs → config-DlCUMyCG.mjs} +1 -1
  9. package/dist/docs/README.md +5 -1
  10. package/dist/docs/SKILL.md +0 -1
  11. package/dist/docs/guidelines/backward-compatibility-rules.md +4 -0
  12. package/dist/docs/guidelines/bun-monorepo-patterns.md +20 -4
  13. package/dist/docs/guidelines/cli-agent-skill-patterns.md +354 -37
  14. package/dist/docs/guidelines/commit-conventions.md +4 -0
  15. package/dist/docs/guidelines/common-doc-guidelines.md +234 -0
  16. package/dist/docs/guidelines/convex-limits-best-practices.md +4 -0
  17. package/dist/docs/guidelines/convex-rules.md +4 -0
  18. package/dist/docs/guidelines/electron-app-development-patterns.md +4 -0
  19. package/dist/docs/guidelines/error-handling-rules.md +4 -0
  20. package/dist/docs/guidelines/general-coding-rules.md +4 -0
  21. package/dist/docs/guidelines/general-comment-rules.md +4 -0
  22. package/dist/docs/guidelines/general-eng-assistant-rules.md +4 -0
  23. package/dist/docs/guidelines/general-tdd-guidelines.md +4 -0
  24. package/dist/docs/guidelines/general-testing-rules.md +4 -0
  25. package/dist/docs/guidelines/golden-testing-guidelines.md +4 -0
  26. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +27 -6
  27. package/dist/docs/guidelines/python-cli-patterns.md +4 -0
  28. package/dist/docs/guidelines/python-modern-guidelines.md +30 -0
  29. package/dist/docs/guidelines/python-rules.md +4 -0
  30. package/dist/docs/guidelines/release-notes-guidelines.md +4 -0
  31. package/dist/docs/guidelines/supply-chain-hardening.md +11 -7
  32. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +10 -4
  33. package/dist/docs/guidelines/typescript-cli-tool-rules.md +27 -24
  34. package/dist/docs/guidelines/typescript-code-coverage.md +11 -7
  35. package/dist/docs/guidelines/typescript-rules.md +10 -6
  36. package/dist/docs/guidelines/typescript-sorting-patterns.md +4 -0
  37. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +7 -3
  38. package/dist/docs/install/ensure-gh-cli.sh +59 -24
  39. package/dist/docs/shortcuts/standard/agent-handoff.md +4 -0
  40. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
  41. package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
  42. package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
  43. package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
  44. package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
  45. package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
  46. package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
  47. package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
  48. package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
  49. package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
  50. package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
  51. package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
  52. package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
  53. package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
  54. package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
  55. package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
  56. package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
  57. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
  58. package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
  59. package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
  60. package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
  61. package/dist/docs/shortcuts/standard/review-code.md +4 -0
  62. package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
  63. package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
  64. package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
  65. package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
  66. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
  67. package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
  68. package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
  69. package/dist/docs/tbd-closing.md +4 -0
  70. package/dist/docs/tbd-design.md +109 -68
  71. package/dist/docs/tbd-docs.md +20 -13
  72. package/dist/docs/tbd-prime.md +4 -0
  73. package/dist/docs/templates/architecture-doc.md +4 -0
  74. package/dist/docs/templates/plan-spec.md +4 -0
  75. package/dist/docs/templates/qa-playbook.md +4 -0
  76. package/dist/docs/templates/research-brief.md +4 -0
  77. package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-CFoPVinz.mjs} +1 -1
  78. package/dist/{id-mapping-CqrrLgeX.mjs → id-mapping-CtfTfGIh.mjs} +146 -122
  79. package/dist/id-mapping-CtfTfGIh.mjs.map +1 -0
  80. package/dist/index.d.mts +53 -1
  81. package/dist/index.mjs +3 -3
  82. package/dist/{schemas-C8mOQykE.mjs → schemas-f0EcuAVu.mjs} +40 -3
  83. package/dist/schemas-f0EcuAVu.mjs.map +1 -0
  84. package/dist/{src-CJyVkC3V.mjs → src-rIE4xSVs.mjs} +3 -3
  85. package/dist/src-rIE4xSVs.mjs.map +1 -0
  86. package/dist/tbd +3241 -2326
  87. package/package.json +1 -1
  88. package/dist/config-B38rbI9u.mjs.map +0 -1
  89. package/dist/docs/guidelines/general-style-rules.md +0 -38
  90. package/dist/docs/guidelines/writing-style-guidelines.md +0 -42
  91. package/dist/id-mapping-CqrrLgeX.mjs.map +0 -1
  92. package/dist/schemas-C8mOQykE.mjs.map +0 -1
  93. package/dist/src-CJyVkC3V.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-tbd",
3
- "version": "0.1.29",
3
+ "version": "0.2.0",
4
4
  "description": "Git-native issue tracking for AI agents and humans",
5
5
  "license": "MIT",
6
6
  "author": "Joshua Levy <joshua@cal.berkeley.edu> (https://github.com/jlevy)",
@@ -1 +0,0 @@
1
- {"version":3,"file":"config-B38rbI9u.mjs","names":["parseYaml","parsePath"],"sources":["../src/lib/paths.ts","../src/lib/tbd-format.ts","../src/file/config.ts"],"sourcesContent":["/**\n * Centralized path constants for tbd.\n *\n * Directory structure (per spec):\n *\n * On main/dev branches:\n * .tbd/\n * Committed to the repo:\n * config.yml - Project configuration\n * .gitignore - Controls what's gitignored below\n * workspaces/ - Persistent state (outbox, named workspaces)\n * Gitignored (local only):\n * state.yml - Local state\n * docs/ - Installed documentation (regenerated on setup)\n * data-sync-worktree/ - Hidden worktree checkout of tbd-sync branch\n * .tbd/data-sync/ - issues/, mappings/, attic/, meta.yml\n *\n * On tbd-sync branch:\n * .tbd/\n * data-sync/\n * issues/\n * mappings/\n * attic/\n * meta.yml\n */\n\nimport { join } from 'node:path';\n\n/** The tbd configuration directory on main branch */\nexport const TBD_DIR = '.tbd';\n\n/** The config file path */\nexport const CONFIG_FILE = join(TBD_DIR, 'config.yml');\n\n/** The local state file (gitignored) */\nexport const STATE_FILE = join(TBD_DIR, 'state.yml');\n\n/** The worktree directory name */\nexport const WORKTREE_DIR_NAME = 'data-sync-worktree';\n\n/** The worktree path (gitignored) */\nexport const WORKTREE_DIR = join(TBD_DIR, WORKTREE_DIR_NAME);\n\n/** The data directory name on the sync branch */\nexport const DATA_SYNC_DIR_NAME = 'data-sync';\n\n/**\n * The base directory for synced data.\n *\n * NOTE: This is currently pointing directly to .tbd/data-sync/ which is WRONG\n * per the spec. The correct path should be via the worktree:\n * .tbd/data-sync-worktree/.tbd/data-sync/\n *\n * TODO(tbd-208): Update this to use the worktree path once worktree\n * management is implemented.\n */\nexport const DATA_SYNC_DIR = join(TBD_DIR, DATA_SYNC_DIR_NAME);\n\n/**\n * The correct path for synced data via worktree (per spec).\n * Use this once worktree management is implemented.\n */\nexport const DATA_SYNC_DIR_VIA_WORKTREE = join(WORKTREE_DIR, TBD_DIR, DATA_SYNC_DIR_NAME);\n\n/** Issues directory */\nexport const ISSUES_DIR = join(DATA_SYNC_DIR, 'issues');\n\n/** Mappings directory */\nexport const MAPPINGS_DIR = join(DATA_SYNC_DIR, 'mappings');\n\n/** Attic directory for conflict resolution */\nexport const ATTIC_DIR = join(DATA_SYNC_DIR, 'attic');\n\n/** Meta file for schema version */\nexport const META_FILE = join(DATA_SYNC_DIR, 'meta.yml');\n\n/** The sync branch name */\nexport const SYNC_BRANCH = 'tbd-sync';\n\n// =============================================================================\n// Workspace Paths (for sync failure recovery, backups, bulk editing)\n// =============================================================================\n\n/** The workspaces directory name within .tbd/ */\nexport const WORKSPACES_DIR_NAME = 'workspaces';\n\n/** Full path to workspaces directory: .tbd/workspaces/ */\nexport const WORKSPACES_DIR = join(TBD_DIR, WORKSPACES_DIR_NAME);\n\n/**\n * Get the path to a named workspace directory.\n *\n * Workspaces are stored at: .tbd/workspaces/{name}/\n *\n * @param workspaceName - The name of the workspace (e.g., 'outbox', 'my-feature')\n * @returns Path to the workspace directory\n */\nexport function getWorkspaceDir(workspaceName: string): string {\n return join(WORKSPACES_DIR, workspaceName);\n}\n\n/**\n * Get the path to a workspace's issues directory.\n *\n * @param workspaceName - The name of the workspace\n * @returns Path to the workspace's issues directory\n */\nexport function getWorkspaceIssuesDir(workspaceName: string): string {\n return join(getWorkspaceDir(workspaceName), 'issues');\n}\n\n/**\n * Get the path to a workspace's mappings directory.\n *\n * @param workspaceName - The name of the workspace\n * @returns Path to the workspace's mappings directory\n */\nexport function getWorkspaceMappingsDir(workspaceName: string): string {\n return join(getWorkspaceDir(workspaceName), 'mappings');\n}\n\n/**\n * Get the path to a workspace's attic directory.\n *\n * The attic stores conflict backups during workspace save operations.\n *\n * @param workspaceName - The name of the workspace\n * @returns Path to the workspace's attic directory\n */\nexport function getWorkspaceAtticDir(workspaceName: string): string {\n return join(getWorkspaceDir(workspaceName), 'attic');\n}\n\n/**\n * Validate a workspace name.\n *\n * Valid workspace names:\n * - Lowercase alphanumeric characters\n * - Hyphens and underscores allowed\n * - Must not be empty\n * - Must not contain path separators or dots at start\n *\n * @param name - The workspace name to validate\n * @returns true if the name is valid\n */\nexport function isValidWorkspaceName(name: string): boolean {\n if (!name || name.length === 0) {\n return false;\n }\n\n // Must not start with dot (hidden files)\n if (name.startsWith('.')) {\n return false;\n }\n\n // Only allow lowercase alphanumeric, hyphens, and underscores\n // No spaces, path separators, or special characters\n const validPattern = /^[a-z0-9][a-z0-9_-]*$/;\n return validPattern.test(name);\n}\n\n// =============================================================================\n// Documentation/Shortcuts Paths\n// =============================================================================\n\n/** Docs directory name within .tbd/ */\nexport const DOCS_DIR = 'docs';\n\n/** Shortcuts directory name within docs/ */\nexport const SHORTCUTS_DIR = 'shortcuts';\n\n/** System shortcuts directory name (core docs like skill-baseline.md) */\nexport const SYSTEM_DIR = 'system';\n\n/** Standard shortcuts directory name (workflow shortcuts) */\nexport const STANDARD_DIR = 'standard';\n\n/** Guidelines directory name (coding rules and best practices) */\nexport const GUIDELINES_DIR = 'guidelines';\n\n/** Templates directory name (document templates) */\nexport const TEMPLATES_DIR = 'templates';\n\n/** Full path to docs directory: .tbd/docs/ */\nexport const TBD_DOCS_DIR = join(TBD_DIR, DOCS_DIR);\n\n/** Full path to shortcuts directory: .tbd/docs/shortcuts/ */\nexport const TBD_SHORTCUTS_DIR = join(TBD_DOCS_DIR, SHORTCUTS_DIR);\n\n/** Full path to system shortcuts: .tbd/docs/shortcuts/system/ */\nexport const TBD_SHORTCUTS_SYSTEM = join(TBD_SHORTCUTS_DIR, SYSTEM_DIR);\n\n/** Full path to standard shortcuts: .tbd/docs/shortcuts/standard/ */\nexport const TBD_SHORTCUTS_STANDARD = join(TBD_SHORTCUTS_DIR, STANDARD_DIR);\n\n/** Full path to guidelines: .tbd/docs/guidelines/ (top-level, not under shortcuts) */\nexport const TBD_GUIDELINES_DIR = join(TBD_DOCS_DIR, GUIDELINES_DIR);\n\n/** Full path to templates: .tbd/docs/templates/ (top-level, not under shortcuts) */\nexport const TBD_TEMPLATES_DIR = join(TBD_DOCS_DIR, TEMPLATES_DIR);\n\n/** Built-in docs source paths (relative to package docs/) */\nexport const BUILTIN_SHORTCUTS_SYSTEM = join(SHORTCUTS_DIR, SYSTEM_DIR);\nexport const BUILTIN_SHORTCUTS_STANDARD = join(SHORTCUTS_DIR, STANDARD_DIR);\n\n/** Built-in guidelines source path (relative to package docs/) */\nexport const BUILTIN_GUIDELINES_DIR = GUIDELINES_DIR;\n\n/** Built-in templates source path (relative to package docs/) */\nexport const BUILTIN_TEMPLATES_DIR = TEMPLATES_DIR;\n\n/** Install directory name (header files for tool-specific installation) */\nexport const INSTALL_DIR = 'install';\n\n/** Built-in install source path (relative to package docs/) */\nexport const BUILTIN_INSTALL_DIR = INSTALL_DIR;\n\n/**\n * Default shortcut lookup paths (searched in order, relative to tbd root).\n * Earlier paths take precedence over later paths.\n * Note: Guidelines and templates are now separate top-level directories.\n */\nexport const DEFAULT_SHORTCUT_PATHS = [\n TBD_SHORTCUTS_SYSTEM, // .tbd/docs/shortcuts/system/\n TBD_SHORTCUTS_STANDARD, // .tbd/docs/shortcuts/standard/\n];\n\n/**\n * Default guidelines lookup paths (relative to tbd root).\n */\nexport const DEFAULT_GUIDELINES_PATHS = [\n TBD_GUIDELINES_DIR, // .tbd/docs/guidelines/\n];\n\n/**\n * Default template lookup paths (relative to tbd root).\n */\nexport const DEFAULT_TEMPLATE_PATHS = [\n TBD_TEMPLATES_DIR, // .tbd/docs/templates/\n];\n\n/**\n * Get the full path to an issue file.\n */\nexport function getIssuePath(issueId: string): string {\n return join(ISSUES_DIR, `${issueId}.md`);\n}\n\n/**\n * Get the full path to a mapping file.\n */\nexport function getMappingPath(name: string): string {\n return join(MAPPINGS_DIR, `${name}.yml`);\n}\n\n/**\n * Get the full path to an attic entry.\n */\nexport function getAtticPath(issueId: string, filename: string): string {\n return join(ATTIC_DIR, 'conflicts', issueId, filename);\n}\n\n// =============================================================================\n// Dynamic Path Resolution\n// =============================================================================\n\nimport { access } from 'node:fs/promises';\n\n/**\n * Options for resolveDataSyncDir.\n */\nexport interface ResolveDataSyncDirOptions {\n /**\n * Allow fallback to direct path when worktree is missing.\n * Set to true for test environments or diagnostic tools.\n * Default: true. When false and worktree is missing, throws WorktreeMissingError.\n */\n allowFallback?: boolean;\n}\n\n/**\n * Error thrown when worktree is missing and fallback is not allowed.\n * Defined inline to avoid circular dependency with errors.ts.\n */\nexport class WorktreeMissingError extends Error {\n constructor(\n message = \"Worktree not found at .tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.\",\n ) {\n super(message);\n this.name = 'WorktreeMissingError';\n }\n}\n\n/**\n * Cache for resolved data sync directory.\n * Reset when baseDir changes.\n */\nlet _resolvedDataSyncDir: string | null = null;\nlet _resolvedBaseDir: string | null = null;\nlet _resolvedAllowFallback: boolean | null = null;\n\n/**\n * Resolve the actual data sync directory path.\n *\n * This function detects whether we're running with a git worktree\n * (production) or in a test environment without worktree.\n *\n * Order of preference:\n * 1. Worktree path if worktree exists: .tbd/data-sync-worktree/.tbd/data-sync/\n * 2. Direct path as fallback (only if allowFallback: true)\n *\n * @param baseDir - The tbd root directory (from requireInit or findTbdRoot)\n * @param options - Options for path resolution\n * @returns Resolved data sync directory path\n * @throws WorktreeMissingError if worktree missing and allowFallback is false\n *\n * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md\n */\nexport async function resolveDataSyncDir(\n baseDir: string,\n options?: ResolveDataSyncDirOptions,\n): Promise<string> {\n const allowFallback = options?.allowFallback ?? true;\n\n // Return cached result if baseDir and options haven't changed\n if (\n _resolvedDataSyncDir &&\n _resolvedBaseDir === baseDir &&\n _resolvedAllowFallback === allowFallback\n ) {\n return _resolvedDataSyncDir;\n }\n\n const worktreePath = join(baseDir, DATA_SYNC_DIR_VIA_WORKTREE);\n const directPath = join(baseDir, DATA_SYNC_DIR);\n\n // Check if worktree path exists\n try {\n await access(worktreePath);\n _resolvedDataSyncDir = worktreePath;\n _resolvedBaseDir = baseDir;\n _resolvedAllowFallback = allowFallback;\n return worktreePath;\n } catch {\n // Worktree doesn't exist\n if (!allowFallback) {\n throw new WorktreeMissingError();\n }\n\n // Fallback to direct path (test mode or diagnostic tools)\n // Note: In production, sync.ts checks worktree health before calling this\n // Debug warning to help detect unintended fallback usage\n if (process.env.DEBUG || process.env.TBD_DEBUG) {\n console.warn(\n '[tbd:paths] resolveDataSyncDir: worktree not found, falling back to direct path',\n );\n }\n _resolvedDataSyncDir = directPath;\n _resolvedBaseDir = baseDir;\n _resolvedAllowFallback = allowFallback;\n return directPath;\n }\n}\n\n/**\n * Resolve issues directory path.\n */\nexport async function resolveIssuesDir(\n baseDir: string,\n options?: ResolveDataSyncDirOptions,\n): Promise<string> {\n const dataSyncDir = await resolveDataSyncDir(baseDir, options);\n return join(dataSyncDir, 'issues');\n}\n\n/**\n * Resolve mappings directory path.\n */\nexport async function resolveMappingsDir(\n baseDir: string,\n options?: ResolveDataSyncDirOptions,\n): Promise<string> {\n const dataSyncDir = await resolveDataSyncDir(baseDir, options);\n return join(dataSyncDir, 'mappings');\n}\n\n/**\n * Resolve attic directory path.\n */\nexport async function resolveAtticDir(\n baseDir: string,\n options?: ResolveDataSyncDirOptions,\n): Promise<string> {\n const dataSyncDir = await resolveDataSyncDir(baseDir, options);\n return join(dataSyncDir, 'attic');\n}\n\n/**\n * Clear the resolved path cache.\n * Call this when the repository state changes (e.g., after init).\n */\nexport function clearPathCache(): void {\n _resolvedDataSyncDir = null;\n _resolvedBaseDir = null;\n _resolvedAllowFallback = null;\n}\n\n// =============================================================================\n// Doc Path Resolution\n// =============================================================================\n\nimport { isAbsolute } from 'node:path';\nimport { homedir } from 'node:os';\n\n/**\n * Resolve a doc path for consistent handling across the codebase.\n *\n * Path resolution rules:\n * - Absolute paths (starting with /): used as-is\n * - Home directory paths (starting with ~/): expanded to user home directory\n * - Relative paths: resolved from tbd root (baseDir)\n *\n * @param docPath - The path to resolve\n * @param baseDir - The tbd root directory (parent of .tbd/)\n * @returns Resolved absolute path\n *\n * @example\n * // Absolute path - returned as-is\n * resolveDocPath('/usr/local/docs/file.md') // => '/usr/local/docs/file.md'\n *\n * // Home path - expanded\n * resolveDocPath('~/docs/file.md') // => '/Users/username/docs/file.md'\n *\n * // Relative path - resolved from baseDir\n * resolveDocPath('docs/file.md', '/project') // => '/project/docs/file.md'\n */\nexport function resolveDocPath(docPath: string, baseDir: string): string {\n // Handle home directory expansion\n if (docPath.startsWith('~/')) {\n return join(homedir(), docPath.slice(2));\n }\n\n // Absolute paths used as-is\n if (isAbsolute(docPath)) {\n return docPath;\n }\n\n // Relative paths resolved from baseDir (tbd root)\n return join(baseDir, docPath);\n}\n\n// =============================================================================\n// Token Estimation Settings\n// =============================================================================\n\n/**\n * Characters per token ratio for estimating token counts.\n *\n * Based on research of OpenAI (tiktoken) and Claude tokenizers:\n * - Pure English prose: ~4-5 chars/token\n * - Code and symbols: ~3 chars/token\n * - Mixed markdown/code docs: ~3.5 chars/token\n *\n * We use 3.5 as our docs are markdown with code examples.\n * This provides ~15-20% accuracy, sufficient for cost estimation.\n */\nexport const CHARS_PER_TOKEN = 3.5;\n","/**\n * tbd Directory Format Versioning\n * ================================\n *\n * This file is the SINGLE SOURCE OF TRUTH for .tbd/ directory format versions.\n *\n * WHEN TO BUMP THE FORMAT VERSION:\n * - Bump when changes REQUIRE migration (deleting files, changing formats, moving files)\n * - **Bump when changing config schema** (adding, removing, or modifying fields)\n * - Do NOT bump for additive changes that don't affect config.yml (new directories, etc.)\n *\n * HOW TO ADD A NEW FORMAT VERSION:\n * 1. Add entry to FORMAT_HISTORY with detailed description\n * 2. Implement migrate_fXX_to_fYY() function\n * 3. Add case to migrateToLatest()\n * 4. Update CURRENT_FORMAT\n * 5. Add tests for the migration path\n *\n * FORWARD COMPATIBILITY POLICY:\n * ConfigSchema uses Zod's strip() mode, which discards unknown fields. To prevent\n * data loss when users mix tbd versions:\n *\n * 1. When changing config schema, bump the format version (e.g., f03 → f04)\n * 2. config.ts checks format compatibility via isCompatibleFormat()\n * 3. Older tbd versions will error with \"format 'fXX' is from a newer tbd version\"\n * 4. The error tells users to upgrade: npm install -g get-tbd@latest\n *\n * This ensures older versions fail fast rather than silently corrupting config.\n * See ConfigSchema in schemas.ts and checkFormatCompatibility() in config.ts.\n */\n\n// =============================================================================\n// Format Constants\n// =============================================================================\n\n/**\n * Current format version.\n * Bump this ONLY for breaking changes that require migration.\n */\nexport const CURRENT_FORMAT = 'f03';\n\n/**\n * Initial format version for configs that don't have tbd_format field.\n */\nexport const INITIAL_FORMAT = 'f01';\n\n// =============================================================================\n// Format History\n// =============================================================================\n\n/**\n * Complete history of format versions with their changes.\n * This serves as documentation and enables version detection.\n */\nexport const FORMAT_HISTORY = {\n f01: {\n introduced: '0.1.0',\n description: 'Initial format',\n structure: {\n 'config.yml': 'Project configuration',\n 'state.yml': 'Local state (gitignored)',\n 'docs/': 'Documentation cache (gitignored)',\n 'issues/': 'Issue YAML files',\n },\n },\n f02: {\n introduced: '0.1.5',\n description: 'Adds configurable doc_cache',\n changes: [\n 'Added doc_cache: key to config.yml for configurable doc sources',\n 'Added settings.doc_auto_sync_hours for automatic doc refresh',\n 'Added last_doc_sync_at to state.yml for tracking sync time',\n ],\n migration: 'Populates default doc_cache config from bundled docs',\n },\n f03: {\n introduced: '0.1.6',\n description: 'Consolidates docs_cache config structure',\n changes: [\n 'Consolidated doc_cache: and docs: into single docs_cache: key',\n 'Moved doc_cache: -> docs_cache.files:',\n 'Moved docs.paths: -> docs_cache.lookup_path:',\n 'Removed separate docs: key',\n ],\n migration: 'Migrates old config keys to new docs_cache structure',\n },\n} as const;\n\nexport type FormatVersion = keyof typeof FORMAT_HISTORY;\n\n// =============================================================================\n// Migration Types\n// =============================================================================\n\n/**\n * Raw config data before parsing/validation.\n * Used during migration when we need to work with potentially old formats.\n */\nexport interface RawConfig {\n tbd_format?: string;\n tbd_version?: string;\n sync?: {\n branch?: string;\n remote?: string;\n };\n display?: {\n id_prefix?: string;\n };\n settings?: {\n auto_sync?: boolean;\n doc_auto_sync_hours?: number;\n };\n // Old format (f02 and earlier)\n docs?: {\n paths?: string[];\n };\n doc_cache?: Record<string, string>;\n // New format (f03+)\n docs_cache?: {\n files?: Record<string, string>;\n lookup_path?: string[];\n };\n}\n\n/**\n * Result of a migration operation.\n */\nexport interface MigrationResult {\n /** The migrated config */\n config: RawConfig;\n /** Format version before migration */\n fromFormat: FormatVersion;\n /** Format version after migration */\n toFormat: FormatVersion;\n /** Whether any changes were made */\n changed: boolean;\n /** Description of changes made */\n changes: string[];\n}\n\n// =============================================================================\n// Migration Functions\n// =============================================================================\n\n/**\n * Migrate from f01 to f02.\n * - Adds tbd_format field\n * - Adds doc_auto_sync_hours setting (default: 24)\n * - doc_cache will be populated separately during setup (requires file system access)\n */\nfunction migrate_f01_to_f02(config: RawConfig): MigrationResult {\n const changes: string[] = [];\n const migrated = { ...config };\n\n // Add format version\n migrated.tbd_format = 'f02';\n changes.push('Added tbd_format: f02');\n\n // Ensure settings exists and add doc_auto_sync_hours\n migrated.settings ??= {};\n if (migrated.settings.doc_auto_sync_hours === undefined) {\n migrated.settings.doc_auto_sync_hours = 24;\n changes.push('Added settings.doc_auto_sync_hours: 24');\n }\n\n // Note: doc_cache is intentionally NOT added here.\n // It will be populated during setup when we have access to the file system\n // and can enumerate the bundled docs.\n\n return {\n config: migrated,\n fromFormat: 'f01',\n toFormat: 'f02',\n changed: changes.length > 0,\n changes,\n };\n}\n\n/**\n * Migrate from f02 to f03.\n * - Consolidates doc_cache: and docs: into docs_cache:\n * - Moves doc_cache: -> docs_cache.files:\n * - Moves docs.paths: -> docs_cache.lookup_path:\n * - Removes separate docs: and doc_cache: keys\n */\nfunction migrate_f02_to_f03(config: RawConfig): MigrationResult {\n const changes: string[] = [];\n const migrated = { ...config };\n\n // Update format version\n migrated.tbd_format = 'f03';\n changes.push('Updated tbd_format: f03');\n\n // Initialize docs_cache if it doesn't exist\n migrated.docs_cache ??= {};\n\n // Migrate doc_cache -> docs_cache.files\n if (migrated.doc_cache && Object.keys(migrated.doc_cache).length > 0) {\n migrated.docs_cache.files = { ...migrated.doc_cache };\n changes.push('Moved doc_cache: -> docs_cache.files:');\n delete migrated.doc_cache;\n }\n\n // Migrate docs.paths -> docs_cache.lookup_path\n if (migrated.docs?.paths && migrated.docs.paths.length > 0) {\n migrated.docs_cache.lookup_path = [...migrated.docs.paths];\n changes.push('Moved docs.paths: -> docs_cache.lookup_path:');\n }\n\n // Remove old docs: key\n if (migrated.docs) {\n delete migrated.docs;\n changes.push('Removed docs: key');\n }\n\n return {\n config: migrated,\n fromFormat: 'f02',\n toFormat: 'f03',\n changed: changes.length > 0,\n changes,\n };\n}\n\n// =============================================================================\n// Public API\n// =============================================================================\n\n/**\n * Detect the format version of a config.\n * Returns INITIAL_FORMAT ('f01') if no tbd_format field is present.\n */\nexport function detectFormat(config: RawConfig): FormatVersion {\n const format = config.tbd_format;\n if (!format) {\n return INITIAL_FORMAT;\n }\n if (format in FORMAT_HISTORY) {\n return format as FormatVersion;\n }\n // Unknown format - treat as latest (will fail validation if incompatible)\n return CURRENT_FORMAT;\n}\n\n/**\n * Check if a config needs migration.\n */\nexport function needsMigration(config: RawConfig): boolean {\n const currentFormat = detectFormat(config);\n return currentFormat !== CURRENT_FORMAT;\n}\n\n/**\n * Migrate a config to the latest format version.\n *\n * This function applies all necessary migrations in sequence.\n * It does NOT populate doc_cache - that requires file system access\n * and should be done separately during setup.\n *\n * @param config - The raw config to migrate\n * @returns Migration result with the migrated config and change log\n */\nexport function migrateToLatest(config: RawConfig): MigrationResult {\n const fromFormat = detectFormat(config);\n\n if (fromFormat === CURRENT_FORMAT) {\n return {\n config,\n fromFormat,\n toFormat: CURRENT_FORMAT,\n changed: false,\n changes: [],\n };\n }\n\n let current = config;\n let currentFormat: FormatVersion = fromFormat;\n const allChanges: string[] = [];\n\n // Apply migrations in sequence\n if (currentFormat === 'f01') {\n const result = migrate_f01_to_f02(current);\n current = result.config;\n currentFormat = 'f02' as FormatVersion;\n allChanges.push(...result.changes);\n }\n\n if (currentFormat === 'f02') {\n const result = migrate_f02_to_f03(current);\n current = result.config;\n currentFormat = 'f03' as FormatVersion;\n allChanges.push(...result.changes);\n }\n\n // Add more migrations here as new format versions are added\n\n return {\n config: current,\n fromFormat,\n toFormat: currentFormat,\n changed: allChanges.length > 0,\n changes: allChanges,\n };\n}\n\n/**\n * Check if a format version is compatible with the current tbd version.\n * Future format versions are considered incompatible (would need tbd upgrade).\n */\nexport function isCompatibleFormat(format: string): boolean {\n const formatVersions = Object.keys(FORMAT_HISTORY);\n const currentIndex = formatVersions.indexOf(CURRENT_FORMAT);\n const checkIndex = formatVersions.indexOf(format);\n\n if (checkIndex === -1) {\n // Unknown format - might be from a newer tbd version\n return false;\n }\n\n // Compatible if same or older format (we can migrate up)\n return checkIndex <= currentIndex;\n}\n\n/**\n * Get a human-readable description of what migrations will be applied.\n */\nexport function describeMigration(fromFormat: FormatVersion): string[] {\n const descriptions: string[] = [];\n let current = fromFormat;\n\n if (current === 'f01') {\n descriptions.push('f01 → f02: Add doc_cache configuration support');\n current = 'f02';\n }\n\n if (current === 'f02') {\n descriptions.push('f02 → f03: Consolidate doc_cache and docs into docs_cache');\n current = 'f03';\n }\n\n // Add more migration descriptions here\n\n return descriptions;\n}\n","/**\n * Config file operations.\n *\n * Config is stored at .tbd/config.yml and contains project-level settings.\n *\n * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.\n *\n * See: tbd-design.md §2.2.2 Config File\n */\n\nimport { readFile, mkdir, access } from 'node:fs/promises';\nimport { join, dirname, parse as parsePath } from 'node:path';\nimport { writeFile } from 'atomically';\nimport { parse as parseYaml } from 'yaml';\n\nimport { sortKeys, stringifyYaml } from '../utils/yaml-utils.js';\nimport type { Config, LocalState } from '../lib/types.js';\nimport {\n ConfigSchema,\n LocalStateSchema,\n CONFIG_FIELD_ORDER,\n LOCAL_STATE_FIELD_ORDER,\n} from '../lib/schemas.js';\nimport { CONFIG_FILE, STATE_FILE, SYNC_BRANCH } from '../lib/paths.js';\nimport {\n CURRENT_FORMAT,\n needsMigration,\n migrateToLatest,\n isCompatibleFormat,\n type RawConfig,\n} from '../lib/tbd-format.js';\n\n/**\n * Error thrown when the config format version is from a newer tbd version.\n * This prevents older tbd versions from silently stripping new config fields.\n */\nexport class IncompatibleFormatError extends Error {\n constructor(\n public readonly foundFormat: string,\n public readonly supportedFormat: string,\n ) {\n super(\n `Config format '${foundFormat}' is from a newer tbd version.\\n` +\n `This tbd version supports up to format '${supportedFormat}'.\\n` +\n `Please upgrade tbd: npm install -g get-tbd@latest`,\n );\n this.name = 'IncompatibleFormatError';\n }\n}\n\n/**\n * Check if config format is compatible, throw if not.\n * This prevents older tbd versions from silently stripping fields added by newer versions.\n */\nfunction checkFormatCompatibility(data: RawConfig): void {\n const format = data.tbd_format;\n if (format && !isCompatibleFormat(format)) {\n throw new IncompatibleFormatError(format, CURRENT_FORMAT);\n }\n}\n\n/**\n * Create default config for a new project.\n * @param prefix - Required: the project prefix for display IDs (e.g., \"proj\", \"myapp\")\n */\nfunction createDefaultConfig(version: string, prefix: string): Config {\n return ConfigSchema.parse({\n tbd_format: CURRENT_FORMAT,\n tbd_version: version,\n sync: {\n branch: SYNC_BRANCH,\n remote: 'origin',\n },\n display: {\n id_prefix: prefix,\n },\n settings: {\n auto_sync: false,\n doc_auto_sync_hours: 24,\n },\n });\n}\n\n/**\n * Initialize a new config file with default settings.\n * Creates .tbd directory if it doesn't exist.\n * @param prefix - Required: the project prefix for display IDs (e.g., \"proj\", \"myapp\")\n */\nexport async function initConfig(\n baseDir: string,\n version: string,\n prefix: string,\n): Promise<Config> {\n const tbdDir = join(baseDir, '.tbd');\n await mkdir(tbdDir, { recursive: true });\n\n const config = createDefaultConfig(version, prefix);\n await writeConfig(baseDir, config);\n\n return config;\n}\n\n/**\n * Read config from file with automatic migration if needed.\n *\n * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.\n *\n * @throws {IncompatibleFormatError} If config is from a newer tbd version.\n * @throws If config file doesn't exist or is invalid.\n */\nexport async function readConfig(baseDir: string): Promise<Config> {\n const configPath = join(baseDir, CONFIG_FILE);\n const content = await readFile(configPath, 'utf-8');\n const data = parseYaml(content) as RawConfig;\n\n // Check for incompatible (future) format versions first\n checkFormatCompatibility(data);\n\n // Check if migration is needed (for older formats)\n if (needsMigration(data)) {\n const result = migrateToLatest(data);\n // Note: We don't automatically write the migrated config here.\n // Migration writes should be explicit via writeConfig() after setup.\n return ConfigSchema.parse(result.config);\n }\n\n return ConfigSchema.parse(data);\n}\n\n/**\n * Read config from file, returning migration info if a migration was applied.\n * Use this when you need to know if the config was migrated.\n *\n * @throws {IncompatibleFormatError} If config is from a newer tbd version.\n */\nexport async function readConfigWithMigration(\n baseDir: string,\n): Promise<{ config: Config; migrated: boolean; changes: string[] }> {\n const configPath = join(baseDir, CONFIG_FILE);\n const content = await readFile(configPath, 'utf-8');\n const data = parseYaml(content) as RawConfig;\n\n // Check for incompatible (future) format versions first\n checkFormatCompatibility(data);\n\n if (needsMigration(data)) {\n const result = migrateToLatest(data);\n return {\n config: ConfigSchema.parse(result.config),\n migrated: result.changed,\n changes: result.changes,\n };\n }\n\n return {\n config: ConfigSchema.parse(data),\n migrated: false,\n changes: [],\n };\n}\n\n/**\n * Write config to file with explanatory comments.\n */\nexport async function writeConfig(baseDir: string, config: Config): Promise<void> {\n const configPath = join(baseDir, CONFIG_FILE);\n\n // Sort keys using canonical field order, then serialize with compact output.\n // sortMapEntries: false preserves our manual ordering.\n const sorted = sortKeys(config as unknown as Record<string, unknown>, CONFIG_FIELD_ORDER);\n const yaml = stringifyYaml(sorted, { lineWidth: 0, sortMapEntries: false });\n\n // Add explanatory comments for docs_cache section\n let content = yaml;\n if (config.docs_cache && Object.keys(config.docs_cache).length > 0) {\n const docsCacheComment = `# Documentation cache configuration.\n# files: Maps destination paths (relative to .tbd/docs/) to source locations.\n# Sources can be:\n# - internal: prefix for bundled docs (e.g., \"internal:shortcuts/standard/code-review-and-commit.md\")\n# - Full URL for external docs (e.g., \"https://raw.githubusercontent.com/org/repo/main/file.md\")\n# lookup_path: Search paths for doc lookup (like shell $PATH). Earlier paths take precedence.\n#\n# To sync docs: tbd sync --docs\n# To check status: tbd sync --status\n#\n# Auto-sync: Docs are automatically synced when stale (default: every 24 hours).\n# Configure with settings.doc_auto_sync_hours (0 = disabled).\n`;\n content = content.replace('docs_cache:', docsCacheComment + 'docs_cache:');\n }\n\n await writeFile(configPath, content);\n}\n\n/**\n * Check if tbd is properly initialized in the given directory.\n * Returns true only if .tbd/config.yml exists (not just a .tbd/ directory).\n *\n * This prevents spurious .tbd/ directories (e.g., containing only state.yml\n * created by a bug) from being mistaken for tbd roots. A valid tbd root\n * always has config.yml created during `tbd init`.\n */\nasync function hasTbdDir(dir: string): Promise<boolean> {\n const configPath = join(dir, CONFIG_FILE);\n try {\n await access(configPath);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Find the tbd repository root by walking up the directory tree.\n * Similar to how git finds .git/ directories.\n *\n * @param startDir - Directory to start searching from\n * @returns The tbd root directory path, or null if not found\n */\nexport async function findTbdRoot(startDir: string): Promise<string | null> {\n let currentDir = startDir;\n const { root } = parsePath(startDir);\n\n while (currentDir !== root) {\n if (await hasTbdDir(currentDir)) {\n return currentDir;\n }\n currentDir = dirname(currentDir);\n }\n\n // Check root directory as well\n if (await hasTbdDir(root)) {\n return root;\n }\n\n return null;\n}\n\n/**\n * Check if tbd is initialized in the given directory or any parent directory.\n * Walks up the directory tree looking for .tbd/.\n */\nexport async function isInitialized(baseDir: string): Promise<boolean> {\n const root = await findTbdRoot(baseDir);\n return root !== null;\n}\n\n// =============================================================================\n// Local State Operations\n// =============================================================================\n\n/**\n * Read local state from .tbd/state.yml\n * Returns empty state if file doesn't exist.\n */\nexport async function readLocalState(baseDir: string): Promise<LocalState> {\n const statePath = join(baseDir, STATE_FILE);\n try {\n const content = await readFile(statePath, 'utf-8');\n const data: unknown = parseYaml(content);\n return LocalStateSchema.parse(data ?? {});\n } catch {\n // File doesn't exist or is invalid - return empty state\n return {};\n }\n}\n\n/**\n * Write local state to .tbd/state.yml\n *\n * Uses `atomically` for safe writes (atomic rename, auto parent-dir creation).\n * However, we intentionally guard against .tbd/ not existing: `atomically`\n * would auto-create it, which is wrong if baseDir is a subdirectory rather\n * than the true tbd root. Only `tbd init` (via initConfig) should create .tbd/.\n */\nexport async function writeLocalState(baseDir: string, state: LocalState): Promise<void> {\n // Guard: refuse to write if .tbd/ directory doesn't exist.\n // Without this, `atomically` would auto-create .tbd/ in subdirectories,\n // producing spurious directories that confuse findTbdRoot().\n const tbdDir = join(baseDir, '.tbd');\n try {\n await access(tbdDir);\n } catch {\n throw new Error(\n `Cannot write state: .tbd/ directory does not exist at ${baseDir}. ` +\n `Run 'tbd init' first or ensure the correct tbd root is being used.`,\n );\n }\n\n const statePath = join(baseDir, STATE_FILE);\n\n // Sort keys using canonical field order, then serialize with compact output.\n // sortMapEntries: false preserves our manual ordering.\n const sorted = sortKeys(state as unknown as Record<string, unknown>, LOCAL_STATE_FIELD_ORDER);\n const yaml = stringifyYaml(sorted, { lineWidth: 0, sortMapEntries: false });\n\n await writeFile(statePath, yaml);\n}\n\n/**\n * Update specific fields in local state (merge with existing).\n */\nexport async function updateLocalState(\n baseDir: string,\n updates: Partial<LocalState>,\n): Promise<LocalState> {\n const current = await readLocalState(baseDir);\n const updated = { ...current, ...updates };\n await writeLocalState(baseDir, updated);\n return updated;\n}\n\n// =============================================================================\n// Welcome State Operations\n// =============================================================================\n\n/**\n * Check if the user has seen the welcome message.\n */\nexport async function hasSeenWelcome(baseDir: string): Promise<boolean> {\n const state = await readLocalState(baseDir);\n return state.welcome_seen === true;\n}\n\n/**\n * Mark the welcome message as seen.\n */\nexport async function markWelcomeSeen(baseDir: string): Promise<void> {\n await updateLocalState(baseDir, { welcome_seen: true });\n}\n"],"mappings":"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"}
@@ -1,38 +0,0 @@
1
- ---
2
- title: General Style Rules
3
- description: Style guidelines for auto-formatting, emoji usage, and output formatting
4
- author: Joshua Levy (github.com/jlevy) with LLM assistance
5
- ---
6
- # General Style Rules
7
-
8
- ## Always Auto-Format
9
-
10
- Always use auto-formatting on every file type possible.
11
- Defer to rules on auto-formatting for exact coding style in each language.
12
-
13
- ## Use of Emojis
14
-
15
- These rules apply to output and other messages, comments, log messages, user messages,
16
- and UI messages.
17
-
18
- - **Use of emojis:**
19
-
20
- - **Do not use emojis gratuitously:** Use emojis in output only if it enhances the
21
- clarity and can be done with a consistent semantic vocabulary.
22
-
23
- - DO use ✅ and ❌ (or if the codebase already uses them, ✔︎ and ✘) to indicate success
24
- and failure and ⚠️ and ‼️ (or if the codebase already uses them, ∆ and ‼︎)
25
- user-facing warnings and errors.
26
- Whatever you use, just be sure to do it consistently across the codebase.
27
-
28
- - You MAY use the following emojis if you use them consistently:
29
-
30
- - 📈 for reports and quantitative summaries
31
-
32
- - ⏰ for timings and scheduling
33
-
34
- - 🧪 for tests and experiments
35
-
36
- - DO NOT use emojis in comments or output just because they are fun or cute.
37
- Unless the user says otherwise, avoid emojis and Unicode in comments as it adds
38
- distraction without the benefit of systematic meaning.
@@ -1,42 +0,0 @@
1
- ---
2
- title: Writing Style Guidelines
3
- description: Guidelines for clear, concise, and reader-friendly writing in documentation and code
4
- author: Joshua Levy (github.com/jlevy) with LLM assistance
5
- ---
6
- # Writing Style Guidelines
7
-
8
- All documents on a project should adhere to these principles.
9
- Make edits as needed when editing documents to keep them in alignment.
10
-
11
- ## 1. Use Clear and Concise Language
12
-
13
- - Eliminate unnecessary or extraneous words.
14
- - Be casual, not overly stiff or formal.
15
-
16
- ## 2. Use Clear Structure
17
-
18
- - Use headings, subheadings, lists, and other structural devices effectively.
19
- - Keep structure logical and consistent.
20
- - Make headings specific—cleave to the true contours of the subject matter.
21
-
22
- ## 3. Be Detailed and Specific
23
-
24
- - Avoid vagueness or generalities.
25
- - Use concrete examples and quality citations whenever possible.
26
-
27
- ## 4. Be Efficient
28
-
29
- - Avoid obvious statements.
30
- - Remove duplication where a document says the same thing in different places.
31
-
32
- ## 5. Be Respectful of the Reader’s Intelligence
33
-
34
- - Write for a reader that is highly intelligent even if they are highly ignorant of the
35
- subject.
36
- - Explain concepts (or cite good sources) clearly and concisely from first principles.
37
- - Never dumb things down, be vague, or skip important technicalities or details.
38
-
39
- ## 6. Be Warm
40
-
41
- - Be friendly in tone.
42
- - Be gracious in acknowledging previous work, even if correcting it.
@@ -1 +0,0 @@
1
- {"version":3,"file":"id-mapping-CqrrLgeX.mjs","names":[],"sources":["../src/lib/ids.ts","../src/utils/lockfile.ts","../src/lib/sort.ts","../src/file/id-mapping.ts"],"sourcesContent":["/**\n * ID generation and validation utilities.\n *\n * The system uses dual IDs for usability:\n * - Internal ID: is-{ulid} - ULID-based (26 lowercase chars), stored in files\n * - External ID: {prefix}-{short} - 4-5 base36 chars for CLI display/input\n *\n * For Beads compatibility, bd- prefix is accepted on input for external IDs.\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { monotonicFactory } from 'ulid';\nimport { randomBytes } from 'node:crypto';\n\n// Monotonic factory ensures ULIDs are strictly increasing even within the same\n// millisecond. This guarantees that lexicographic sort = creation order, which\n// is critical for deterministic list output (the tiebreaker sort is by ULID).\nconst ulid = monotonicFactory();\n\n// =============================================================================\n// Branded Types for Type-Safe ID Handling\n// =============================================================================\n\n/**\n * Branded type for internal issue IDs (is-{ulid} format).\n *\n * Internal IDs are stored in files and used as the canonical identifier.\n * Format: is-{26 lowercase alphanumeric chars}\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * Use this type when:\n * - Reading/writing issue files\n * - Storing parent_id, dependencies, child_order_hints\n * - Passing IDs between internal functions\n */\ndeclare const InternalIssueIdBrand: unique symbol;\nexport type InternalIssueId = string & { [InternalIssueIdBrand]: never };\n\n/**\n * Branded type for display issue IDs ({prefix}-{short} format).\n *\n * Display IDs are shown to users and accepted as CLI input.\n * Format: {prefix}-{short} where short is typically 4 base36 chars\n * Example: tbd-a7k2, bd-100\n *\n * Use this type when:\n * - Formatting output for users\n * - Accepting user input (before resolution)\n * - Building tree views for display\n */\ndeclare const DisplayIssueIdBrand: unique symbol;\nexport type DisplayIssueId = string & { [DisplayIssueIdBrand]: never };\n\n/**\n * Cast a string to InternalIssueId after validation.\n * Use this when you've validated that a string is a valid internal ID.\n */\nexport function asInternalId(id: string): InternalIssueId {\n return id as InternalIssueId;\n}\n\n/**\n * Cast a string to DisplayIssueId.\n * Use this when formatting an ID for display.\n */\nexport function asDisplayId(id: string): DisplayIssueId {\n return id as DisplayIssueId;\n}\n\n/**\n * Prefix for internal IDs (ULID-based).\n * All internal IDs are formatted as: {INTERNAL_ID_PREFIX}-{ulid}\n */\nexport const INTERNAL_ID_PREFIX = 'is';\n\n/**\n * Length of internal ID prefix including the hyphen (e.g., \"is-\" = 3).\n */\nexport const INTERNAL_ID_PREFIX_LENGTH = INTERNAL_ID_PREFIX.length + 1;\n\n/**\n * Construct an internal ID from a ULID.\n *\n * @param ulidValue - The ULID (26 chars)\n * @returns Internal ID in format {prefix}-{ulid}\n */\nexport function makeInternalId(ulidValue: string): InternalIssueId {\n return `${INTERNAL_ID_PREFIX}-${ulidValue.toLowerCase()}` as InternalIssueId;\n}\n\n/**\n * Generate a unique internal ID using ULID.\n * Format: is-{ulid} (26 lowercase alphanumeric chars)\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * ULID provides:\n * - Time-ordered sorting (48-bit timestamp)\n * - 80-bit randomness (no collisions)\n * - Lexicographic sort = chronological order\n */\nexport function generateInternalId(): InternalIssueId {\n return makeInternalId(ulid());\n}\n\n/**\n * Generate a short ID for external display.\n * Format: base36 characters (a-z, 0-9)\n * Example: a7k2\n *\n * @param length - Number of characters (default 4)\n */\nexport function generateShortId(length = 4): string {\n const chars = '0123456789abcdefghijklmnopqrstuvwxyz';\n let result = '';\n const bytes = randomBytes(length);\n for (let i = 0; i < length; i++) {\n result += chars[bytes[i]! % 36];\n }\n return result;\n}\n\n// Regex pattern for validating internal IDs - built from prefix constant\nconst INTERNAL_ID_PATTERN = new RegExp(`^${INTERNAL_ID_PREFIX}-[0-9a-z]{26}$`);\n\n// Expected length of a full internal ID (prefix + hyphen + 26-char ULID)\nconst INTERNAL_ID_LENGTH = INTERNAL_ID_PREFIX_LENGTH + 26;\n\n/**\n * Validate an internal issue ID matches the ULID format.\n * Format: {prefix}-{26 lowercase alphanumeric chars}\n */\nexport function validateIssueId(id: string): boolean {\n return INTERNAL_ID_PATTERN.test(id);\n}\n\n/**\n * Validate a short/external ID format.\n * Format: 1+ base36 characters (typically 4 for new IDs, but imports may preserve longer IDs).\n */\nexport function validateShortId(id: string): boolean {\n return /^[0-9a-z]+$/.test(id);\n}\n\n/**\n * Check if an input looks like an internal ID (ULID-based).\n */\nexport function isInternalId(input: string): boolean {\n const lower = input.toLowerCase();\n // Check if it starts with the internal prefix and has correct length\n const prefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n if (lower.startsWith(prefixWithHyphen) && lower.length === INTERNAL_ID_LENGTH) {\n return INTERNAL_ID_PATTERN.test(lower);\n }\n return false;\n}\n\n/**\n * Check if an input looks like a short/external ID.\n * Returns true for IDs like \"a7k2\", \"bd-a7k2\", \"100\", \"tbd-100\".\n * Short IDs are 16 characters or less (ULIDs are 26 characters).\n */\nexport function isShortId(input: string): boolean {\n const lower = input.toLowerCase();\n // Strip prefix if present\n const stripped = lower.replace(/^[a-z]+-/, '');\n // Must be 1-16 alphanumeric chars (short IDs, not ULIDs which are 26 chars)\n return /^[0-9a-z]+$/.test(stripped) && stripped.length >= 1 && stripped.length <= 16;\n}\n\n/**\n * Extract the short ID portion from an external ID.\n * Examples:\n * \"tbd-100\" -> \"100\"\n * \"bd-a7k2\" -> \"a7k2\"\n * \"a7k2\" -> \"a7k2\"\n * \"100\" -> \"100\"\n */\nexport function extractShortId(externalId: string): string {\n return externalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/**\n * Extract the prefix portion from an external ID.\n * Returns the prefix (letters before the hyphen) or null if no prefix found.\n * Examples:\n * \"tbd-100\" -> \"tbd\"\n * \"bd-a7k2\" -> \"bd\"\n * \"TBD-100\" -> \"tbd\" (normalized to lowercase)\n * \"a7k2\" -> null (no prefix)\n * \"100\" -> null (no prefix)\n */\nexport function extractPrefix(externalId: string): string | null {\n const match = /^([a-zA-Z]+)-/.exec(externalId);\n return match?.[1]?.toLowerCase() ?? null;\n}\n\n/**\n * Extract the ULID portion from an internal ID.\n *\n * Internal IDs have the format: {prefix}-{ulid}\n * This function strips any prefix to return just the ULID.\n *\n * Examples:\n * \"is-01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\"\n * \"01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\" (no prefix)\n *\n * @param internalId - The internal ID (with or without prefix)\n * @returns The ULID portion without any prefix\n */\nexport function extractUlidFromInternalId(internalId: string): string {\n // Strip any prefix in format {letters}- (e.g., \"is-\", \"bd-\")\n return internalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/** Prefix used in Beads for compatibility */\nconst BEADS_COMPAT_PREFIX = 'bd';\n\n/**\n * Normalize an internal issue ID.\n *\n * This function expects a full internal ID ({prefix}-{ulid}).\n * If given a short ID, it won't be able to resolve it without\n * access to the ID mapping.\n *\n * Handles:\n * - Uppercase (converts to lowercase)\n * - Ensures internal ID prefix\n * - Beads compatibility (bd- prefix)\n */\nexport function normalizeIssueId(input: string): string {\n const lower = input.toLowerCase();\n const internalPrefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n const beadsPrefixWithHyphen = `${BEADS_COMPAT_PREFIX}-`;\n\n // If already a valid internal ID, return as-is\n if (validateIssueId(lower)) {\n return lower;\n }\n\n // If it starts with internal prefix but wrong length, might be corrupted\n if (lower.startsWith(internalPrefixWithHyphen)) {\n return lower; // Return as-is, let validation fail later\n }\n\n // If it starts with bd- (Beads compat), convert prefix\n if (lower.startsWith(beadsPrefixWithHyphen)) {\n const rest = lower.slice(beadsPrefixWithHyphen.length);\n if (rest.length === 26) {\n return makeInternalId(rest);\n }\n // Short ID - can't resolve without mapping\n return lower;\n }\n\n // Bare ID without prefix\n if (lower.length === 26 && /^[0-9a-z]{26}$/.test(lower)) {\n return makeInternalId(lower);\n }\n\n // Can't normalize - return as-is\n return lower;\n}\n\nimport type { IdMapping } from '../file/id-mapping.js';\n\n/**\n * Format an internal ID for display with the configured prefix.\n *\n * Uses the short ID (4 chars) from the mapping.\n * Throws an error if the mapping is missing or doesn't contain the ID.\n *\n * IMPORTANT: All user-facing output MUST use short IDs, never internal ULIDs.\n * If you see a ULID in user output, it's a bug.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup (required)\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n * @throws Error if mapping is missing or ID not found in mapping\n */\nexport function formatDisplayId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): DisplayIssueId {\n // Extract the ULID portion\n const ulidPart = extractUlidFromInternalId(internalId);\n\n // Get short ID from mapping\n const shortId = mapping.ulidToShort.get(ulidPart);\n if (!shortId) {\n throw new Error(\n `No short ID mapping found for internal ID: ${internalId}. ` +\n `This is a bug - all issues must have a short ID mapping.`,\n );\n }\n\n return `${prefix}-${shortId}` as DisplayIssueId;\n}\n\n/**\n * Format an ID for debug output, showing both public and internal IDs.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n */\nexport function formatDebugId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): string {\n const displayId = formatDisplayId(internalId, mapping, prefix);\n return `${displayId} (${internalId})`;\n}\n","/**\n * Directory-based mutual exclusion for concurrent file access.\n *\n * Note: Despite the name \"lockfile\", this is NOT a POSIX file lock (flock/fcntl).\n * It uses mkdir to create a lock *directory* as a coordination convention — no\n * OS-level file locking syscalls are involved. This makes it portable across all\n * filesystems, including NFS and other network mounts where flock/fcntl locks\n * are unreliable or unsupported.\n *\n * This is the same strategy used by:\n *\n * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)\n * See: https://git-scm.com/docs/gitrepository-layout (\"lockfile protocol\")\n * - **npm** for package-lock.json concurrent access\n *\n * ## Why mkdir?\n *\n * `mkdir(2)` is atomic on all common filesystems (local and network): it either\n * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,\n * a directory lock is trivially distinguishable from normal files.\n *\n * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving\n * the atomicity guarantee:\n * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback\n *\n * ## Lock lifecycle\n *\n * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process\n * 2. **Hold**: Execute the critical section\n * 3. **Release**: `rmdir(lockDir)` — in a finally block\n * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder\n * crashed and break the lock. This is a heuristic — safe when the critical\n * section is short-lived (sub-second for file I/O).\n *\n * ## Failure on timeout\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError is\n * thrown. This prevents the dangerous \"degraded mode\" where the critical section\n * runs without mutual exclusion, which can cause data loss (e.g., lost ID\n * mappings during concurrent `tbd create`).\n *\n * IMPORTANT: `timeoutMs` must be greater than `staleMs` so stale locks from\n * crashed processes are always detected and broken before the timeout expires.\n */\n\nimport { mkdir, rmdir, stat } from 'node:fs/promises';\n\n/** Options for `withLockfile`. */\nexport interface LockfileOptions {\n /** Maximum time (ms) to wait for the lock. Default: 10000 */\n timeoutMs?: number;\n /** Polling interval (ms) between acquisition attempts. Default: 50 */\n pollMs?: number;\n /** Age (ms) after which a lock is considered stale. Default: 5000 */\n staleMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_POLL_MS = 50;\nconst DEFAULT_STALE_MS = 5_000;\n\n/**\n * Error thrown when the lock cannot be acquired within the timeout.\n */\nexport class LockAcquisitionError extends Error {\n constructor(lockPath: string, timeoutMs: number) {\n super(\n `Failed to acquire lock at ${lockPath} within ${timeoutMs}ms. ` +\n `Another process may be holding the lock. If this persists, ` +\n `delete the lock directory manually and retry.`,\n );\n this.name = 'LockAcquisitionError';\n }\n}\n\n/**\n * Execute `fn` while holding a lockfile.\n *\n * The lock is a directory at `lockPath` (typically `<target-file>.lock`).\n * Concurrent callers will wait up to `timeoutMs` for the lock, polling\n * every `pollMs`. Stale locks older than `staleMs` are broken automatically.\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError\n * is thrown. This ensures mutual exclusion is never silently bypassed, which\n * prevents data loss from concurrent writes.\n *\n * @param lockPath - Path to use as the lock directory (e.g., \"/path/to/ids.yml.lock\")\n * @param fn - Critical section to execute under the lock\n * @param options - Timing parameters for lock acquisition\n * @returns The return value of `fn`\n * @throws LockAcquisitionError if the lock cannot be acquired within the timeout\n *\n * @example\n * ```ts\n * await withLockfile('/path/to/ids.yml.lock', async () => {\n * const data = await readFile('/path/to/ids.yml', 'utf-8');\n * const updated = mergeEntries(data, newEntries);\n * await writeFile('/path/to/ids.yml', updated);\n * });\n * ```\n */\nexport async function withLockfile<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options?: LockfileOptions,\n): Promise<T> {\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;\n const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;\n\n const deadline = Date.now() + timeoutMs;\n let acquired = false;\n\n while (Date.now() < deadline) {\n try {\n // mkdir is atomic per POSIX.1-2017 — fails with EEXIST if already held\n await mkdir(lockPath);\n acquired = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n // Unexpected error (permissions, disk full, missing parent, etc.) —\n // preserve the original failure instead of misreporting lock contention.\n throw error;\n }\n\n // Lock exists — check if it's stale (holder likely crashed)\n try {\n const lockStat = await stat(lockPath);\n if (Date.now() - lockStat.mtimeMs > staleMs) {\n try {\n await rmdir(lockPath);\n } catch {\n // Another process may have already broken/released it\n }\n continue; // Retry immediately after breaking stale lock\n }\n } catch {\n // Lock was released between our mkdir and stat — retry immediately\n continue;\n }\n\n // Lock is fresh — wait and retry\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n }\n }\n\n if (!acquired) {\n throw new LockAcquisitionError(lockPath, timeoutMs);\n }\n\n try {\n return await fn();\n } finally {\n try {\n await rmdir(lockPath);\n } catch {\n // Best-effort cleanup; stale lock detection handles the rest\n }\n }\n}\n","/**\n * Natural sort utilities.\n *\n * Provides alphanumeric sorting where numeric portions are sorted numerically.\n * Similar to `sort -V` (version sort) or `sort -n` for numbers.\n *\n * Examples:\n * [\"1\", \"2\", \"9\", \"10\", \"11\"] instead of [\"1\", \"10\", \"11\", \"2\", \"9\"]\n * [\"a1\", \"a2\", \"a10\"] instead of [\"a1\", \"a10\", \"a2\"]\n * [\"file1.txt\", \"file2.txt\", \"file10.txt\"] instead of [\"file1.txt\", \"file10.txt\", \"file2.txt\"]\n */\n\n/**\n * Split a string into alternating runs of digits and non-digits.\n * @param str - The string to split\n * @returns Array of [isNumeric, value] tuples\n */\nfunction splitIntoChunks(str: string): [boolean, string][] {\n const chunks: [boolean, string][] = [];\n let current = '';\n let currentIsNumeric: boolean | null = null;\n\n for (const char of str) {\n const isDigit = char >= '0' && char <= '9';\n\n if (currentIsNumeric === null) {\n // First character\n currentIsNumeric = isDigit;\n current = char;\n } else if (isDigit === currentIsNumeric) {\n // Same type, continue accumulating\n current += char;\n } else {\n // Type changed, push current chunk and start new one\n chunks.push([currentIsNumeric, current]);\n currentIsNumeric = isDigit;\n current = char;\n }\n }\n\n // Push final chunk\n if (current) {\n chunks.push([currentIsNumeric!, current]);\n }\n\n return chunks;\n}\n\n/**\n * Compare two strings using natural (alphanumeric) ordering.\n *\n * Numeric portions are compared numerically, non-numeric portions are\n * compared lexicographically (case-insensitive). Numbers sort before letters\n * when they appear at the same position in mixed comparisons, matching\n * the behavior of `sort -V` (version sort).\n *\n * @param a - First string\n * @param b - Second string\n * @returns Negative if a < b, positive if a > b, zero if equal\n */\nexport function naturalCompare(a: string, b: string): number {\n // Handle empty strings\n if (!a && !b) return 0;\n if (!a) return -1;\n if (!b) return 1;\n\n const chunksA = splitIntoChunks(a);\n const chunksB = splitIntoChunks(b);\n\n const minLen = Math.min(chunksA.length, chunksB.length);\n\n for (let i = 0; i < minLen; i++) {\n const [isNumericA, valueA] = chunksA[i]!;\n const [isNumericB, valueB] = chunksB[i]!;\n\n if (isNumericA && isNumericB) {\n // Both are numeric - compare as numbers\n const numA = parseInt(valueA, 10);\n const numB = parseInt(valueB, 10);\n if (numA !== numB) {\n return numA - numB;\n }\n // If numerically equal but different strings (e.g., \"01\" vs \"1\"),\n // prefer shorter (fewer leading zeros)\n if (valueA.length !== valueB.length) {\n return valueA.length - valueB.length;\n }\n } else if (!isNumericA && !isNumericB) {\n // Both are non-numeric - compare lexicographically (case-insensitive)\n const lowerA = valueA.toLowerCase();\n const lowerB = valueB.toLowerCase();\n if (lowerA !== lowerB) {\n return lowerA.localeCompare(lowerB);\n }\n // Same when lowercased - they're equal for sorting purposes\n } else {\n // Mixed: numeric comes before non-numeric\n // (so \"1\" comes before \"a\" at the same position)\n // This matches `sort -V` behavior\n return isNumericA ? -1 : 1;\n }\n }\n\n // All compared chunks are equal, shorter string comes first\n return chunksA.length - chunksB.length;\n}\n\n/**\n * Sort an array of strings using natural (alphanumeric) ordering.\n *\n * @param arr - Array to sort\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSort(arr: readonly string[]): string[] {\n return [...arr].sort(naturalCompare);\n}\n\n/**\n * Sort an array of objects by a string key using natural ordering.\n *\n * @param arr - Array to sort\n * @param keyFn - Function to extract the sort key from each element\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSortBy<T>(arr: readonly T[], keyFn: (item: T) => string): T[] {\n return [...arr].sort((a, b) => naturalCompare(keyFn(a), keyFn(b)));\n}\n","/**\n * ID mapping management for short public IDs.\n *\n * Maps 4-char base36 short IDs to 26-char ULIDs.\n * Stored in .tbd/data-sync/mappings/ids.yml\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { readFile, mkdir } from 'node:fs/promises';\nimport { join, dirname } from 'node:path';\nimport { writeFile } from 'atomically';\n\nimport {\n parseYamlToleratingDuplicateKeys,\n stringifyYaml,\n hasMergeConflictMarkers,\n} from '../utils/yaml-utils.js';\nimport { withLockfile } from '../utils/lockfile.js';\n\nimport {\n generateShortId,\n extractUlidFromInternalId,\n makeInternalId,\n isInternalId,\n extractShortId,\n asInternalId,\n type InternalIssueId,\n} from '../lib/ids.js';\nimport { naturalSort } from '../lib/sort.js';\nimport { IdMappingYamlSchema } from '../lib/schemas.js';\n\n/**\n * ID mapping from short ID to ULID.\n * Format in ids.yml:\n * a7k2: 01hx5zzkbkactav9wevgemmvrz\n * b3m9: 01hx5zzkbkbctav9wevgemmvrz\n */\nexport interface IdMapping {\n shortToUlid: Map<string, string>;\n ulidToShort: Map<string, string>;\n}\n\n/**\n * Get the path to the ids.yml mapping file.\n */\nfunction getMappingPath(baseDir: string): string {\n return join(baseDir, 'mappings', 'ids.yml');\n}\n\n/**\n * Load the ID mapping from disk.\n * Returns empty mapping if file doesn't exist.\n */\nexport async function loadIdMapping(baseDir: string): Promise<IdMapping> {\n const filePath = getMappingPath(baseDir);\n\n let content: string;\n try {\n content = await readFile(filePath, 'utf-8');\n } catch {\n // File doesn't exist - return empty mapping\n return {\n shortToUlid: new Map(),\n ulidToShort: new Map(),\n };\n }\n\n // Parse tolerating duplicate keys — this handles the case where a git merge\n // conflict resolution kept entries from both sides, creating duplicate YAML keys.\n // Without this, the yaml parser throws \"Map keys must be unique\".\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(\n content,\n filePath,\n );\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ${filePath} contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `This usually happens after a git merge conflict resolution. ` +\n `The file will be auto-fixed on next save.`,\n );\n }\n\n // Validate with Zod schema - ensures all keys are valid short IDs and values are ULIDs\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Save the ID mapping to disk with mutual exclusion.\n *\n * Uses a lockfile to serialize concurrent writers, then performs read-merge-write\n * inside the lock. This prevents the lost-update problem when multiple `tbd create`\n * commands run in parallel.\n *\n * The merge is safe because ID mappings are append-only — entries are never\n * intentionally removed. If the lock cannot be acquired within the timeout,\n * a LockAcquisitionError is thrown rather than proceeding without protection.\n */\nexport async function saveIdMapping(baseDir: string, mapping: IdMapping): Promise<void> {\n const filePath = getMappingPath(baseDir);\n\n // Ensure directory exists\n await mkdir(dirname(filePath), { recursive: true });\n\n await withLockfile(filePath + '.lock', async () => {\n // Inside the lock: read current on-disk state, merge with our in-memory\n // mapping, and write the result. Our entries take precedence for short ID\n // conflicts (extremely unlikely with random 4-char base36 IDs).\n let merged = mapping;\n let onDiskSize = 0;\n try {\n const onDisk = await loadIdMappingRaw(filePath);\n onDiskSize = onDisk.shortToUlid.size;\n if (onDiskSize > 0) {\n merged = mergeIdMappings(mapping, onDisk);\n }\n } catch {\n // File doesn't exist or is unreadable — proceed with our mapping only\n }\n\n // Safety check: ID mappings are append-only. If the merged result has fewer\n // entries than what's on disk, something went wrong. Refuse to write so the\n // caller can investigate rather than silently destroying entries.\n if (merged.shortToUlid.size < onDiskSize) {\n throw new Error(\n `Refusing to save ID mapping: would lose ${onDiskSize - merged.shortToUlid.size} entries ` +\n `(on-disk: ${onDiskSize}, proposed: ${merged.shortToUlid.size}). ` +\n `ID mappings are append-only — this indicates a bug.`,\n );\n }\n\n const data: Record<string, string> = {};\n const sortedKeys = naturalSort(Array.from(merged.shortToUlid.keys()));\n for (const key of sortedKeys) {\n data[key] = merged.shortToUlid.get(key)!;\n }\n\n const content = stringifyYaml(data);\n await writeFile(filePath, content);\n });\n}\n\n/**\n * Load an ID mapping directly from a file path (internal helper for save merging).\n * Separated from loadIdMapping to avoid coupling the save path to baseDir resolution.\n */\nasync function loadIdMappingRaw(filePath: string): Promise<IdMapping> {\n const content = await readFile(filePath, 'utf-8');\n\n const { data: rawData } = parseYamlToleratingDuplicateKeys<unknown>(content, filePath);\n const data = rawData ?? {};\n\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Calculate the optimal short ID length based on existing ID count.\n *\n * At 50K issues, switches from 4-char to 5-char IDs to keep\n * collision probability low (~3% per attempt with 4 chars at 50K).\n *\n * With 10 retries per length, actual failure probability is astronomically low.\n */\nexport function calculateOptimalLength(existingCount: number): number {\n return existingCount < 50_000 ? 4 : 5;\n}\n\n/**\n * Generate a unique short ID that doesn't collide with existing ones.\n *\n * Calculates optimal length (4 or 5 chars) based on existing ID count,\n * then retries with the next length if collisions occur.\n *\n * @returns The new short ID\n * @throws If unable to generate a unique ID after max attempts\n */\nexport function generateUniqueShortId(mapping: IdMapping): string {\n const ATTEMPTS_PER_LENGTH = 10;\n const existingCount = mapping.shortToUlid.size;\n const optimalLength = calculateOptimalLength(existingCount);\n\n // Try optimal length first, then fall back to longer if needed\n for (const length of [optimalLength, optimalLength + 1]) {\n for (let attempt = 0; attempt < ATTEMPTS_PER_LENGTH; attempt++) {\n const shortId = generateShortId(length);\n if (!mapping.shortToUlid.has(shortId)) {\n return shortId;\n }\n }\n }\n\n throw new Error(\n `Failed to generate unique short ID after 20 attempts with ${existingCount} existing IDs. ` +\n `This should be extremely rare - please report if you see this error.`,\n );\n}\n\n/**\n * Register a new ID mapping.\n * @param ulid - The ULID (without is- prefix)\n * @param shortId - The short ID (4 chars)\n */\nexport function addIdMapping(mapping: IdMapping, ulid: string, shortId: string): void {\n mapping.shortToUlid.set(shortId, ulid);\n mapping.ulidToShort.set(ulid, shortId);\n}\n\n/**\n * Get the short ID for a ULID.\n * @param ulid - The ULID (without is- prefix)\n * @returns The short ID, or undefined if not found\n */\nexport function getShortId(mapping: IdMapping, ulid: string): string | undefined {\n return mapping.ulidToShort.get(ulid);\n}\n\n/**\n * Get the ULID for a short ID.\n * @param shortId - The short ID\n * @returns The ULID (without is- prefix), or undefined if not found\n */\nexport function getUlid(mapping: IdMapping, shortId: string): string | undefined {\n return mapping.shortToUlid.get(shortId);\n}\n\n/**\n * Check if a short ID exists in the mapping.\n */\nexport function hasShortId(mapping: IdMapping, shortId: string): boolean {\n return mapping.shortToUlid.has(shortId);\n}\n\n/**\n * Create a short ID mapping for a new internal ID.\n * Generates a unique short ID and registers it in the mapping.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - The ID mapping to update\n * @returns The generated short ID\n */\nexport function createShortIdMapping(internalId: string, mapping: IdMapping): string {\n // Extract ULID from internal ID (remove prefix)\n const ulid = extractUlidFromInternalId(internalId);\n\n // Check if already mapped\n const existing = mapping.ulidToShort.get(ulid);\n if (existing) {\n return existing;\n }\n\n // Generate unique short ID\n const shortId = generateUniqueShortId(mapping);\n\n // Register mapping\n addIdMapping(mapping, ulid, shortId);\n\n return shortId;\n}\n\n/**\n * Resolve any ID input to an internal ID ({prefix}-{ulid}).\n *\n * Handles:\n * - Internal IDs: {prefix}-{ulid} -> {prefix}-{ulid}\n * - Short IDs: a7k2 -> {prefix}-{ulid from mapping}\n * - Prefixed short IDs: bd-a7k2 -> {prefix}-{ulid from mapping}\n *\n * @param input - The ID input (short ID, prefixed short ID, or internal ID)\n * @param mapping - The ID mapping for short ID resolution\n * @returns The internal ID ({prefix}-{ulid})\n * @throws If the short ID is not found in the mapping\n */\nexport function resolveToInternalId(input: string, mapping: IdMapping): InternalIssueId {\n const lower = input.toLowerCase();\n\n // If it's already an internal ID, return it\n if (isInternalId(lower)) {\n return asInternalId(lower);\n }\n\n // Extract the short ID portion (strips any prefix like \"bd-\" or \"is-\")\n const shortId = extractShortId(lower);\n\n // If it's a full ULID (26 chars), it might be a bare internal ID\n if (shortId.length === 26 && /^[0-9a-z]{26}$/.test(shortId)) {\n return makeInternalId(shortId);\n }\n\n // Must be a short ID - look it up in the mapping\n const ulid = mapping.shortToUlid.get(shortId);\n if (!ulid) {\n throw new Error(`Unknown issue ID: ${input}. ` + `Short ID \"${shortId}\" not found in mapping.`);\n }\n\n return makeInternalId(ulid);\n}\n\n/**\n * Parse an ID mapping from raw YAML content.\n * Used for loading mappings from git show output during conflict resolution.\n *\n * @throws MergeConflictError if content contains merge conflict markers\n */\nexport function parseIdMappingFromYaml(content: string): IdMapping {\n // Parse tolerating duplicate keys — handles post-merge-conflict duplicates\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(content);\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ID mapping YAML contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `Duplicates will be auto-resolved.`,\n );\n }\n\n // Validate with Zod schema\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Ensure all given internal IDs have short ID mappings.\n * Creates missing mappings for any IDs without entries.\n *\n * This repairs state after git merges that may add issue files\n * without corresponding mapping entries (e.g., when outbox issues\n * are merged from a feature branch but ids.yml doesn't include them).\n *\n * When a `historicalMapping` is provided, the function will try to recover\n * the original short ID from that mapping before generating a new random one.\n * This preserves ID stability so that existing references (in docs, PRs,\n * conversations) remain valid.\n *\n * @param internalIds - Array of internal IDs (is-{ulid}) to reconcile\n * @param mapping - The ID mapping to update (mutated in-place)\n * @param historicalMapping - Optional mapping from prior state (e.g., git history) to recover original short IDs\n * @returns Object with `created` (IDs that got new random short IDs) and `recovered` (IDs restored from history)\n */\nexport function reconcileMappings(\n internalIds: string[],\n mapping: IdMapping,\n historicalMapping?: IdMapping,\n): { created: string[]; recovered: string[] } {\n const created: string[] = [];\n const recovered: string[] = [];\n\n for (const id of internalIds) {\n const ulid = extractUlidFromInternalId(id);\n if (mapping.ulidToShort.has(ulid)) {\n continue; // Already has a mapping\n }\n\n // Try to recover original short ID from historical mapping\n const historicalShortId = historicalMapping?.ulidToShort.get(ulid);\n if (historicalShortId && !mapping.shortToUlid.has(historicalShortId)) {\n // Recovered: restore the original short ID\n addIdMapping(mapping, ulid, historicalShortId);\n recovered.push(id);\n } else {\n // No history available or short ID conflicts — generate new random one\n createShortIdMapping(id, mapping);\n created.push(id);\n }\n }\n\n return { created, recovered };\n}\n\n/**\n * Merge two ID mappings by combining all entries from both.\n * ID mappings are always additive (new IDs are only added, never removed),\n * so merging simply unions all key-value pairs.\n *\n * If the same short ID maps to different ULIDs in each mapping (a conflict),\n * the local mapping takes precedence (caller should log a warning).\n *\n * @param local - The local ID mapping\n * @param remote - The remote ID mapping\n * @returns Merged mapping with all entries from both\n */\nexport function mergeIdMappings(local: IdMapping, remote: IdMapping): IdMapping {\n const merged: IdMapping = {\n shortToUlid: new Map(local.shortToUlid),\n ulidToShort: new Map(local.ulidToShort),\n };\n\n // Add all remote entries that don't conflict\n for (const [shortId, ulid] of remote.shortToUlid) {\n if (!merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n // If shortId already exists with different ulid, keep local (conflict resolution)\n }\n\n // Also check for ULIDs that exist in remote but not in local\n // (different short ID for same ULID - shouldn't happen but handle gracefully)\n for (const [ulid, shortId] of remote.ulidToShort) {\n if (!merged.ulidToShort.has(ulid) && !merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n }\n\n return merged;\n}\n\n/**\n * Resolve merge conflicts in ids.yml content by extracting both sides and merging.\n *\n * ids.yml is a sorted key-value YAML map where entries are append-only.\n * The most common merge conflict is both sides adding non-overlapping keys,\n * which is trivially auto-resolvable by keeping all entries from both sides.\n *\n * @param content - Raw file content that may contain git merge conflict markers\n * @returns Merged IdMapping with entries from both sides\n */\nexport function resolveIdMappingConflicts(content: string): IdMapping {\n if (!hasMergeConflictMarkers(content)) {\n return parseIdMappingFromYaml(content);\n }\n\n const lines = content.split('\\n');\n const oursLines: string[] = [];\n const theirsLines: string[] = [];\n let inConflict: 'none' | 'ours' | 'theirs' = 'none';\n\n for (const line of lines) {\n if (line.startsWith('<<<<<<< ')) {\n inConflict = 'ours';\n continue;\n }\n if (line === '=======' && inConflict === 'ours') {\n inConflict = 'theirs';\n continue;\n }\n if (line.startsWith('>>>>>>> ') && inConflict === 'theirs') {\n inConflict = 'none';\n continue;\n }\n\n if (inConflict === 'none') {\n oursLines.push(line);\n theirsLines.push(line);\n } else if (inConflict === 'ours') {\n oursLines.push(line);\n } else {\n theirsLines.push(line);\n }\n }\n\n const oursMapping = parseIdMappingFromYaml(oursLines.join('\\n'));\n const theirsMapping = parseIdMappingFromYaml(theirsLines.join('\\n'));\n\n return mergeIdMappings(oursMapping, theirsMapping);\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"schemas-C8mOQykE.mjs","names":[],"sources":["../src/lib/schemas.ts"],"sourcesContent":["/**\n * Zod schemas for tbd entities.\n *\n * These schemas are the normative specification for the file format.\n * See: tbd-design.md §2.6 Schemas\n */\n\nimport { z } from 'zod';\n\n// =============================================================================\n// Common Types (§2.6.1)\n// =============================================================================\n\n/**\n * ISO8601 timestamp with Z suffix (UTC).\n */\nexport const Timestamp = z.string().datetime();\n\n/**\n * Issue ID: prefix + 26 lowercase alphanumeric characters (ULID format).\n * Format: is-{ulid} where ulid is 26 chars (a-z, 0-9).\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n */\nexport const IssueId = z.string().regex(/^is-[0-9a-z]{26}$/);\n\n/**\n * Short ID: 1+ base36 characters used for external/display IDs.\n * Typically 4 chars for new IDs (e.g., a7k2, b3m9).\n * Imports may preserve longer numeric IDs (e.g., \"100\" from \"tbd-100\").\n * Legacy imports may include dots (e.g., \"208.1\" from hierarchical numbering).\n * Imports may include dashes/underscores (e.g., \"stat-in_progress\" from JSONL).\n */\nexport const ShortId = z.string().regex(/^[0-9a-z._-]+$/);\n\n/**\n * ULID: 26 lowercase alphanumeric characters.\n * Used in internal IDs and ID mappings.\n * Example: 01hx5zzkbkactav9wevgemmvrz\n */\nexport const Ulid = z.string().regex(/^[0-9a-z]{26}$/);\n\n/**\n * External Issue ID input: accepts {prefix}-{short} or just {short}.\n * Examples: bd-a7k2, a7k2, bd-100, 100\n */\nexport const ExternalIssueIdInput = z.string().regex(/^([a-z]+-)?[0-9a-z]+$/);\n\n/**\n * Edit counter - incremented on every local change.\n * NOTE: Version is NOT used for conflict detection (Git push rejection is used).\n * Content hash is used as tiebreaker during merge resolution.\n * Version is informational only - set to max(local, remote) + 1 after merges.\n */\nexport const Version = z.number().int().nonnegative();\n\n/**\n * Entity type discriminator.\n */\nexport const EntityType = z.literal('is');\n\n// =============================================================================\n// BaseEntity (§2.6.2)\n// =============================================================================\n\n/**\n * All entities share common fields.\n */\nexport const BaseEntity = z.object({\n type: EntityType,\n id: IssueId,\n version: Version,\n created_at: Timestamp,\n updated_at: Timestamp,\n\n // Extensibility namespace for third-party data\n extensions: z.record(z.string(), z.unknown()).optional(),\n});\n\n// =============================================================================\n// Issue Schema (§2.6.3)\n// =============================================================================\n\n/**\n * Issue status values matching Beads.\n */\nexport const IssueStatus = z.enum(['open', 'in_progress', 'blocked', 'deferred', 'closed']);\n\n/**\n * Issue kind/type values matching Beads.\n * Note: CLI uses --type flag, which maps to this `kind` field.\n */\nexport const IssueKind = z.enum(['bug', 'feature', 'task', 'epic', 'chore']);\n\n/**\n * Maximum issue title length before detail belongs in the description body.\n */\nexport const ISSUE_TITLE_MAX_LENGTH = 500;\n\n/**\n * Maximum issue body section length to keep issue files practical to review and sync.\n */\nexport const ISSUE_BODY_MAX_LENGTH = 50_000;\n\n/**\n * Issue title text as persisted in issue files.\n */\nexport const IssueTitle = z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH);\n\n/**\n * Priority: 0 (highest/critical) to 4 (lowest).\n */\nexport const Priority = z.number().int().min(0).max(4);\n\n/**\n * Dependency types - only \"blocks\" supported initially.\n */\nexport const DependencyRelationType = z.enum(['blocks']);\n\n/**\n * A dependency relationship.\n */\nexport const Dependency = z.object({\n type: DependencyRelationType,\n target: IssueId,\n});\n\n/**\n * Full issue schema.\n *\n * Field order is canonical and mirrored by ISSUE_FIELD_ORDER below:\n * type, id, title, kind, status, priority, version (the \"header seven\"),\n * then linkages, assignment, hierarchy, scheduling, provenance,\n * timestamps, lifecycle, and extensions.\n *\n * Note: Fields use .nullable() in addition to .optional() because\n * YAML parses `field: null` as JavaScript null, not undefined.\n *\n * Design note: We could add the short ID to this schema. We didn't originally\n * because it's one more field to maintain consistency around across files.\n * Having it here might make recovery of lost ID mappings far easier, but for\n * now we have more reliable management of the mappings file (ids.yml) and\n * consider it authoritative. See IdMappingYamlSchema (§2.6.8).\n */\nexport const IssueSchema = BaseEntity.extend({\n // Header seven: the fields you always want to see at a glance\n type: z.literal('is'),\n // id, version inherited from BaseEntity\n title: IssueTitle,\n kind: IssueKind.default('task'),\n status: IssueStatus.default('open'),\n priority: Priority.default(2),\n\n // Body content (serialized outside frontmatter)\n description: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),\n notes: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),\n\n // Linkages\n spec_path: z.string().nullable().optional(),\n\n // Assignment and categorization\n assignee: z.string().nullable().optional(),\n labels: z.array(z.string()).default([]),\n dependencies: z.array(Dependency).default([]),\n\n // Hierarchical issues\n parent_id: IssueId.nullable().optional(),\n\n // Child ordering hints - soft ordering for children under this parent.\n // Array of internal IssueIds in preferred display order.\n // May contain stale IDs; display logic filters for actual children.\n child_order_hints: z.array(IssueId).nullable().optional(),\n\n // Scheduling\n due_date: Timestamp.nullable().optional(),\n deferred_until: Timestamp.nullable().optional(),\n\n // Provenance and lifecycle\n created_by: z.string().nullable().optional(),\n closed_at: Timestamp.nullable().optional(),\n close_reason: z.string().nullable().optional(),\n});\n\n// =============================================================================\n// Config Schema (§2.6.4)\n// =============================================================================\n\n/**\n * Git branch name - restricted to safe characters.\n * Allows: alphanumeric, hyphens, underscores, forward slashes, and dots.\n * Prevents shell injection in git commands.\n */\nexport const GitBranchName = z\n .string()\n .min(1)\n .max(255)\n .regex(\n /^[a-zA-Z0-9._/-]+$/,\n 'Invalid branch name: only alphanumeric, dots, underscores, hyphens, and slashes allowed',\n );\n\n/**\n * Git remote name - restricted to safe characters.\n * Allows: alphanumeric, hyphens, underscores, and dots.\n * Prevents shell injection in git commands.\n */\nexport const GitRemoteName = z\n .string()\n .min(1)\n .max(255)\n .regex(\n /^[a-zA-Z0-9._-]+$/,\n 'Invalid remote name: only alphanumeric, dots, underscores, and hyphens allowed',\n );\n\n/**\n * Doc cache configuration - maps destination paths to source locations.\n *\n * Keys are destination paths relative to .tbd/docs/ (e.g., \"shortcuts/standard/code-review-and-commit.md\")\n * Values are source locations:\n * - internal: prefix for bundled docs (e.g., \"internal:shortcuts/standard/code-review-and-commit.md\")\n * - Full URL for external docs (e.g., \"https://raw.githubusercontent.com/org/repo/main/file.md\")\n *\n * Example:\n * ```yaml\n * doc_cache:\n * shortcuts/standard/code-review-and-commit.md: internal:shortcuts/standard/code-review-and-commit.md\n * shortcuts/custom/my-shortcut.md: https://raw.githubusercontent.com/org/repo/main/shortcuts/my-shortcut.md\n * ```\n */\nexport const DocCacheConfigSchema = z.record(z.string(), z.string());\n\n/**\n * Documentation cache configuration (consolidated structure).\n *\n * Combines file sync mappings and lookup paths into a single config block.\n * See: docs/project/specs/active/plan-2026-01-26-docs-cache-config-restructure.md\n */\nexport const DocsCacheSchema = z.object({\n /**\n * Files to sync: maps destination paths to source locations.\n * Keys are destination paths relative to .tbd/docs/\n * Values are source locations:\n * - internal: prefix for bundled docs (e.g., \"internal:shortcuts/standard/code-review-and-commit.md\")\n * - Full URL for external docs (e.g., \"https://raw.githubusercontent.com/org/repo/main/file.md\")\n */\n files: z.record(z.string(), z.string()).optional(),\n /**\n * Search paths for doc lookup (like shell $PATH).\n * Earlier paths take precedence when names conflict.\n */\n lookup_path: z\n .array(z.string())\n .default(['.tbd/docs/shortcuts/system', '.tbd/docs/shortcuts/standard']),\n});\n\n/**\n * Project configuration stored in .tbd/config.yml\n *\n * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.\n * The tbd_format field tracks breaking changes to this schema.\n *\n * ⚠️ FORWARD COMPATIBILITY POLICY:\n * This schema uses Zod's default strip() mode, which discards unknown fields.\n * To prevent data loss when users mix tbd versions:\n *\n * 1. **When changing config schema (adding, removing, or modifying fields), ALWAYS\n * bump the format version** (e.g., f03 → f04)\n * 2. Older tbd versions will error when they see an unknown format version\n * 3. The error message tells users to upgrade tbd\n *\n * This ensures older versions fail fast rather than silently corrupting config.\n * The format version check happens in config.ts via isCompatibleFormat().\n *\n * See tbd-format.ts for format version history and migration rules.\n */\nexport const ConfigSchema = z.object({\n /**\n * Format version for the .tbd/ directory structure.\n * See tbd-format.ts for version history and migration rules.\n * Only bumped for breaking changes that require migration.\n */\n tbd_format: z.string().default('f01'),\n\n tbd_version: z.string(),\n sync: z\n .object({\n branch: GitBranchName.default('tbd-sync'),\n remote: GitRemoteName.default('origin'),\n })\n .default({}),\n display: z.object({\n id_prefix: z.string().min(1).max(20), // Required: set during init --prefix or import\n }),\n settings: z\n .object({\n auto_sync: z.boolean().default(false),\n /**\n * How often to automatically sync documentation cache (in hours).\n * - Default: 24 (sync once per day when actively using tbd)\n * - Set to 0 to disable auto-sync\n * - Only triggers when accessing docs (shortcut, guidelines, template commands)\n */\n doc_auto_sync_hours: z.number().default(24),\n /**\n * Whether to install the ensure-gh-cli.sh hook script during setup.\n * When true (default), `tbd setup` installs a SessionStart hook that\n * ensures the GitHub CLI is available in agent sessions.\n * Set to false or use `tbd setup --no-gh-cli` to disable.\n */\n use_gh_cli: z.boolean().default(true),\n })\n .default({}),\n /**\n * Documentation cache configuration (consolidated).\n * Contains files to sync and lookup paths.\n * See DocsCacheSchema for structure details.\n */\n docs_cache: DocsCacheSchema.optional(),\n});\n\n// =============================================================================\n// Meta Schema (§2.6.5)\n// =============================================================================\n\n/**\n * Shared metadata stored in .tbd/data-sync/meta.yml\n */\nexport const MetaSchema = z.object({\n schema_version: z.number().int(),\n created_at: Timestamp,\n});\n\n// =============================================================================\n// Local State Schema (§2.6.6)\n// =============================================================================\n\n/**\n * Per-node state stored in .tbd/state.yml (gitignored).\n * Tracks local timing information that shouldn't be shared across nodes.\n */\nexport const LocalStateSchema = z.object({\n /** When this node last synced issues successfully */\n last_sync_at: Timestamp.optional(),\n /** When this node last synced the doc cache successfully */\n last_doc_sync_at: Timestamp.optional(),\n /** Whether the user has seen the welcome message */\n welcome_seen: z.boolean().optional(),\n});\n\n// =============================================================================\n// Attic Entry Schema (§2.6.7)\n// =============================================================================\n\n/**\n * Preserved conflict losers.\n */\nexport const AtticEntrySchema = z.object({\n entity_id: IssueId,\n timestamp: Timestamp,\n field: z.string(),\n lost_value: z.string(),\n winner_source: z.enum(['local', 'remote']),\n loser_source: z.enum(['local', 'remote']),\n context: z.object({\n local_version: Version,\n remote_version: Version,\n local_updated_at: Timestamp,\n remote_updated_at: Timestamp,\n }),\n});\n\n// =============================================================================\n// ID Mapping Schema (§2.6.8)\n// =============================================================================\n\n/**\n * ID mapping YAML file schema for ids.yml.\n * Maps short IDs to ULIDs.\n * Format: { \"a7k2\": \"01hx5zzkbkactav9wevgemmvrz\", ... }\n */\nexport const IdMappingYamlSchema = z.record(ShortId, Ulid);\n\n// =============================================================================\n// Field Order Constants for YAML Serialization\n// =============================================================================\n//\n// Each array defines the canonical field order for YAML output.\n// Order mirrors the Zod schema definition above: identity first,\n// human-relevant fields next, bookkeeping last.\n//\n// Used with sortKeys() from yaml-utils.ts and ordering.manual()\n// from comparison-chain.ts. Fields not listed sort to the end.\n\n/**\n * Canonical field order for issue YAML frontmatter.\n * (description and notes are body content, not frontmatter)\n */\nexport const ISSUE_FIELD_ORDER = [\n // Header seven: the fields you always want to see at a glance\n 'type',\n 'id',\n 'title',\n 'kind',\n 'status',\n 'priority',\n 'version',\n\n // Linkages\n 'spec_path',\n\n // Assignment and categorization\n 'assignee',\n 'labels',\n 'dependencies',\n\n // Hierarchy\n 'parent_id',\n 'child_order_hints',\n\n // Scheduling\n 'due_date',\n 'deferred_until',\n\n // Provenance\n 'created_by',\n\n // Timestamps\n 'created_at',\n 'updated_at',\n\n // Lifecycle (closure)\n 'closed_at',\n 'close_reason',\n\n // Extensibility\n 'extensions',\n] as const;\n\n/**\n * Canonical field order for config YAML.\n */\nexport const CONFIG_FIELD_ORDER = [\n 'tbd_format',\n 'tbd_version',\n 'display',\n 'sync',\n 'settings',\n 'docs_cache',\n] as const;\n\n/**\n * Canonical field order for attic entry YAML.\n */\nexport const ATTIC_ENTRY_FIELD_ORDER = [\n 'entity_id',\n 'timestamp',\n 'field',\n 'lost_value',\n 'winner_source',\n 'loser_source',\n 'context',\n] as const;\n\n/**\n * Canonical field order for meta YAML.\n */\nexport const META_FIELD_ORDER = ['schema_version', 'created_at'] as const;\n\n/**\n * Canonical field order for local state YAML.\n */\nexport const LOCAL_STATE_FIELD_ORDER = [\n 'last_sync_at',\n 'last_doc_sync_at',\n 'welcome_seen',\n] as const;\n"],"mappings":";;;;;;;;;;;;AAgBA,MAAa,YAAY,EAAE,QAAQ,CAAC,UAAU;;;;;;AAO9C,MAAa,UAAU,EAAE,QAAQ,CAAC,MAAM,oBAAoB;;;;;;;;AAS5D,MAAa,UAAU,EAAE,QAAQ,CAAC,MAAM,iBAAiB;;;;;;AAOzD,MAAa,OAAO,EAAE,QAAQ,CAAC,MAAM,iBAAiB;;;;;AAMtD,MAAa,uBAAuB,EAAE,QAAQ,CAAC,MAAM,wBAAwB;;;;;;;AAQ7E,MAAa,UAAU,EAAE,QAAQ,CAAC,KAAK,CAAC,aAAa;;;;AAKrD,MAAa,aAAa,EAAE,QAAQ,KAAK;;;;AASzC,MAAa,aAAa,EAAE,OAAO;CACjC,MAAM;CACN,IAAI;CACJ,SAAS;CACT,YAAY;CACZ,YAAY;CAGZ,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,SAAS,CAAC,CAAC,UAAU;CACzD,CAAC;;;;AASF,MAAa,cAAc,EAAE,KAAK;CAAC;CAAQ;CAAe;CAAW;CAAY;CAAS,CAAC;;;;;AAM3F,MAAa,YAAY,EAAE,KAAK;CAAC;CAAO;CAAW;CAAQ;CAAQ;CAAQ,CAAC;;;;AAK5E,MAAa,yBAAyB;;;;AAKtC,MAAa,wBAAwB;;;;AAKrC,MAAa,aAAa,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,uBAAuB;;;;AAKvE,MAAa,WAAW,EAAE,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE;;;;AAKtD,MAAa,yBAAyB,EAAE,KAAK,CAAC,SAAS,CAAC;;;;AAKxD,MAAa,aAAa,EAAE,OAAO;CACjC,MAAM;CACN,QAAQ;CACT,CAAC;;;;;;;;;;;;;;;;;;AAmBF,MAAa,cAAc,WAAW,OAAO;CAE3C,MAAM,EAAE,QAAQ,KAAK;CAErB,OAAO;CACP,MAAM,UAAU,QAAQ,OAAO;CAC/B,QAAQ,YAAY,QAAQ,OAAO;CACnC,UAAU,SAAS,QAAQ,EAAE;CAG7B,aAAa,EAAE,QAAQ,CAAC,IAAI,sBAAsB,CAAC,UAAU,CAAC,UAAU;CACxE,OAAO,EAAE,QAAQ,CAAC,IAAI,sBAAsB,CAAC,UAAU,CAAC,UAAU;CAGlE,WAAW,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;CAG3C,UAAU,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;CAC1C,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAC;CACvC,cAAc,EAAE,MAAM,WAAW,CAAC,QAAQ,EAAE,CAAC;CAG7C,WAAW,QAAQ,UAAU,CAAC,UAAU;CAKxC,mBAAmB,EAAE,MAAM,QAAQ,CAAC,UAAU,CAAC,UAAU;CAGzD,UAAU,UAAU,UAAU,CAAC,UAAU;CACzC,gBAAgB,UAAU,UAAU,CAAC,UAAU;CAG/C,YAAY,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;CAC5C,WAAW,UAAU,UAAU,CAAC,UAAU;CAC1C,cAAc,EAAE,QAAQ,CAAC,UAAU,CAAC,UAAU;CAC/C,CAAC;;;;;;AAWF,MAAa,gBAAgB,EAC1B,QAAQ,CACR,IAAI,EAAE,CACN,IAAI,IAAI,CACR,MACC,sBACA,0FACD;;;;;;AAOH,MAAa,gBAAgB,EAC1B,QAAQ,CACR,IAAI,EAAE,CACN,IAAI,IAAI,CACR,MACC,qBACA,iFACD;;;;;;;;;;;;;;;;AAiBH,MAAa,uBAAuB,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,QAAQ,CAAC;;;;;;;AAQpE,MAAa,kBAAkB,EAAE,OAAO;CAQtC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,QAAQ,CAAC,CAAC,UAAU;CAKlD,aAAa,EACV,MAAM,EAAE,QAAQ,CAAC,CACjB,QAAQ,CAAC,8BAA8B,+BAA+B,CAAC;CAC3E,CAAC;;;;;;;;;;;;;;;;;;;;;AAsBF,MAAa,eAAe,EAAE,OAAO;CAMnC,YAAY,EAAE,QAAQ,CAAC,QAAQ,MAAM;CAErC,aAAa,EAAE,QAAQ;CACvB,MAAM,EACH,OAAO;EACN,QAAQ,cAAc,QAAQ,WAAW;EACzC,QAAQ,cAAc,QAAQ,SAAS;EACxC,CAAC,CACD,QAAQ,EAAE,CAAC;CACd,SAAS,EAAE,OAAO,EAChB,WAAW,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,GAAG,EACrC,CAAC;CACF,UAAU,EACP,OAAO;EACN,WAAW,EAAE,SAAS,CAAC,QAAQ,MAAM;EAOrC,qBAAqB,EAAE,QAAQ,CAAC,QAAQ,GAAG;EAO3C,YAAY,EAAE,SAAS,CAAC,QAAQ,KAAK;EACtC,CAAC,CACD,QAAQ,EAAE,CAAC;CAMd,YAAY,gBAAgB,UAAU;CACvC,CAAC;;;;AASF,MAAa,aAAa,EAAE,OAAO;CACjC,gBAAgB,EAAE,QAAQ,CAAC,KAAK;CAChC,YAAY;CACb,CAAC;;;;;AAUF,MAAa,mBAAmB,EAAE,OAAO;CAEvC,cAAc,UAAU,UAAU;CAElC,kBAAkB,UAAU,UAAU;CAEtC,cAAc,EAAE,SAAS,CAAC,UAAU;CACrC,CAAC;;;;AASF,MAAa,mBAAmB,EAAE,OAAO;CACvC,WAAW;CACX,WAAW;CACX,OAAO,EAAE,QAAQ;CACjB,YAAY,EAAE,QAAQ;CACtB,eAAe,EAAE,KAAK,CAAC,SAAS,SAAS,CAAC;CAC1C,cAAc,EAAE,KAAK,CAAC,SAAS,SAAS,CAAC;CACzC,SAAS,EAAE,OAAO;EAChB,eAAe;EACf,gBAAgB;EAChB,kBAAkB;EAClB,mBAAmB;EACpB,CAAC;CACH,CAAC;;;;;;AAWF,MAAa,sBAAsB,EAAE,OAAO,SAAS,KAAK;;;;;AAiB1D,MAAa,oBAAoB;CAE/B;CACA;CACA;CACA;CACA;CACA;CACA;CAGA;CAGA;CACA;CACA;CAGA;CACA;CAGA;CACA;CAGA;CAGA;CACA;CAGA;CACA;CAGA;CACD;;;;AAKD,MAAa,qBAAqB;CAChC;CACA;CACA;CACA;CACA;CACA;CACD;;;;AAKD,MAAa,0BAA0B;CACrC;CACA;CACA;CACA;CACA;CACA;CACA;CACD;;;;AAKD,MAAa,mBAAmB,CAAC,kBAAkB,aAAa;;;;AAKhE,MAAa,0BAA0B;CACrC;CACA;CACA;CACD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"src-CJyVkC3V.mjs","names":["parseYaml"],"sources":["../src/lib/types.ts","../src/utils/markdown-utils.ts","../src/file/parser.ts","../src/index.ts"],"sourcesContent":["/**\n * TypeScript types derived from Zod schemas.\n *\n * These types are the canonical TypeScript interface for tbd entities.\n */\n\nimport type { z } from 'zod';\n\nimport type {\n IssueSchema,\n IssueStatus,\n IssueKind,\n Priority,\n Dependency,\n ConfigSchema,\n MetaSchema,\n LocalStateSchema,\n AtticEntrySchema,\n} from './schemas.js';\n\n// =============================================================================\n// Entity Types\n// =============================================================================\n\n/**\n * A tbd issue entity.\n */\nexport type Issue = z.infer<typeof IssueSchema>;\n\n/**\n * Issue status enum values.\n */\nexport type IssueStatusType = z.infer<typeof IssueStatus>;\n\n/**\n * Issue kind enum values.\n */\nexport type IssueKindType = z.infer<typeof IssueKind>;\n\n/**\n * Priority level (0-4).\n */\nexport type PriorityType = z.infer<typeof Priority>;\n\n/**\n * A dependency relationship.\n */\nexport type DependencyType = z.infer<typeof Dependency>;\n\n// =============================================================================\n// Configuration Types\n// =============================================================================\n\n/**\n * Project configuration.\n */\nexport type Config = z.infer<typeof ConfigSchema>;\n\n/**\n * Shared metadata.\n */\nexport type Meta = z.infer<typeof MetaSchema>;\n\n/**\n * Per-node local state.\n */\nexport type LocalState = z.infer<typeof LocalStateSchema>;\n\n/**\n * Attic entry for conflict losers.\n */\nexport type AtticEntry = z.infer<typeof AtticEntrySchema>;\n\n// =============================================================================\n// Input Types for Commands\n// =============================================================================\n\n/**\n * Options for creating an issue.\n */\nexport interface CreateIssueOptions {\n title: string;\n description?: string;\n kind?: IssueKindType;\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent_id?: string;\n due_date?: string;\n deferred_until?: string;\n}\n\n/**\n * Options for updating an issue.\n */\nexport interface UpdateIssueOptions {\n title?: string;\n description?: string;\n notes?: string;\n kind?: IssueKindType;\n status?: IssueStatusType;\n priority?: PriorityType;\n assignee?: string | null;\n addLabels?: string[];\n removeLabels?: string[];\n parent_id?: string | null;\n due_date?: string | null;\n deferred_until?: string | null;\n}\n\n/**\n * Options for listing issues.\n */\nexport interface ListIssuesOptions {\n status?: IssueStatusType | IssueStatusType[];\n kind?: IssueKindType | IssueKindType[];\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent?: string;\n all?: boolean;\n sort?: 'priority' | 'created' | 'updated';\n limit?: number;\n}\n\n/**\n * Options for searching issues.\n */\nexport interface SearchIssuesOptions {\n query: string;\n status?: IssueStatusType | IssueStatusType[];\n limit?: number;\n}\n\n// =============================================================================\n// CLI Utility Types\n// =============================================================================\n\n/**\n * A documentation section with title and slug.\n * Used by docs and design commands.\n */\nexport interface DocSection {\n title: string;\n slug: string;\n}\n\n/**\n * Logger interface for long-running operations in non-CLI layers.\n *\n * Allows core logic (file/, lib/) to report progress without depending on\n * the CLI output layer. CLI commands create an OperationLogger via\n * `OutputManager.logger(spinner)` and pass it to core functions.\n *\n * All methods are required. Use `noopLogger` when no logging is needed.\n */\nexport interface OperationLogger {\n /** Key milestones — drives the spinner in CLI context */\n progress: (message: string) => void;\n /** Operational detail (shown with --verbose or --debug) */\n info: (message: string) => void;\n /** Non-fatal warnings */\n warn: (message: string) => void;\n /** Internal state for troubleshooting (shown with --debug only) */\n debug: (message: string) => void;\n}\n\n/**\n * No-op logger for when no logging is needed.\n * Analogous to noopSpinner in the CLI layer.\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst noop = () => {};\nexport const noopLogger: OperationLogger = {\n progress: noop,\n info: noop,\n warn: noop,\n debug: noop,\n};\n","/**\n * Markdown utilities for processing markdown content.\n *\n * Uses gray-matter for parsing and centralized yaml-utils for stringify to ensure\n * proper handling of special YAML characters (colons, quotes, etc.).\n */\n\nimport matter from 'gray-matter';\n\nimport { stringifyYamlCompact } from './yaml-utils.js';\n\nexport interface ParsedMarkdown {\n /** Raw frontmatter string (without --- delimiters), or null if no frontmatter */\n frontmatter: string | null;\n /** Body content after frontmatter, with leading newlines trimmed */\n body: string;\n}\n\n/**\n * Normalize line endings to LF.\n */\nexport function normalizeLineEndings(content: string): string {\n return content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n}\n\n/**\n * Parse markdown content into frontmatter and body.\n * Handles both LF and CRLF line endings.\n *\n * @returns Object with frontmatter (null if none) and body\n */\nexport function parseMarkdown(content: string): ParsedMarkdown {\n const normalized = normalizeLineEndings(content);\n\n if (!matter.test(normalized)) {\n return { frontmatter: null, body: content };\n }\n\n try {\n const parsed = matter(normalized);\n\n // Extract frontmatter from parsed.data by stringifying back to YAML\n // The matter property is unreliable, so we reconstruct from data\n const data = parsed.data;\n let frontmatter: string | null = null;\n\n if (data && Object.keys(data).length > 0) {\n // Use centralized yaml-utils for proper handling of special characters\n // (colons, quotes, multiline strings, etc.)\n frontmatter = stringifyYamlCompact(data).trimEnd();\n } else {\n // Empty frontmatter (just --- followed by ---)\n frontmatter = '';\n }\n\n // Body with leading newlines trimmed\n const body = parsed.content.replace(/^\\n+/, '');\n\n return { frontmatter, body };\n } catch {\n // Invalid/unclosed frontmatter - treat as no frontmatter\n return { frontmatter: null, body: content };\n }\n}\n\n/**\n * Parse YAML frontmatter from markdown content.\n * Returns the frontmatter content (without delimiters) or null if no valid frontmatter.\n * Handles both LF and CRLF line endings.\n */\nexport function parseFrontmatter(content: string): string | null {\n return parseMarkdown(content).frontmatter;\n}\n\n/**\n * Strip YAML frontmatter from markdown content.\n * Returns the body content without frontmatter, with leading newlines trimmed.\n * Handles both LF and CRLF line endings.\n */\nexport function stripFrontmatter(content: string): string {\n return parseMarkdown(content).body;\n}\n\n/**\n * Insert content after YAML frontmatter.\n * If no frontmatter exists, prepends the content.\n * Content is inserted directly after ---. Include leading newlines in toInsert if needed.\n */\nexport function insertAfterFrontmatter(content: string, toInsert: string): string {\n const { frontmatter, body } = parseMarkdown(content);\n\n if (frontmatter === null) {\n return toInsert + content;\n }\n\n const frontmatterBlock = frontmatter ? `---\\n${frontmatter}\\n---` : '---\\n---';\n return `${frontmatterBlock}\\n${toInsert}\\n\\n${body}`;\n}\n","/**\n * YAML front matter parser and serializer for issue files.\n *\n * Issues are stored as Markdown files with YAML front matter:\n * ---\n * type: is\n * id: is-a1b2c3\n * ...\n * ---\n *\n * Description body here.\n *\n * ## Notes\n *\n * Working notes here.\n *\n * See: tbd-design.md §2.1 Markdown + YAML Front Matter Format\n */\n\nimport matter from 'gray-matter';\nimport { parse as parseYaml } from 'yaml';\n\nimport { normalizeLineEndings } from '../utils/markdown-utils.js';\nimport { sortKeys, stringifyYaml } from '../utils/yaml-utils.js';\nimport type { Issue } from '../lib/types.js';\nimport { IssueSchema, ISSUE_FIELD_ORDER } from '../lib/schemas.js';\n\n/**\n * gray-matter options using the 'yaml' package as engine.\n * This preserves date strings instead of converting them to Date objects.\n */\nexport const matterOptions = {\n engines: {\n yaml: {\n parse: (str: string): object => parseYaml(str) as object,\n stringify: (obj: object): string => stringifyYaml(obj),\n },\n },\n};\n\n/**\n * Parsed issue file content.\n */\nexport interface ParsedIssueFile {\n frontmatter: Record<string, unknown>;\n description: string;\n notes: string;\n}\n\n/**\n * Parse a Markdown file with YAML front matter.\n * Uses gray-matter for consistent frontmatter parsing.\n * Handles both LF and CRLF line endings.\n */\nexport function parseMarkdownWithFrontmatter(content: string): ParsedIssueFile {\n // Normalize CRLF to LF before parsing\n const normalizedContent = normalizeLineEndings(content);\n\n // Check for valid frontmatter\n if (!matter.test(normalizedContent)) {\n throw new Error('Invalid format: missing front matter opening delimiter');\n }\n\n const parsed = matter(normalizedContent, matterOptions);\n\n // gray-matter returns empty object if no closing delimiter found\n // but the raw matter string will be empty if parsing failed\n if (parsed.matter === '' && !normalizedContent.includes('---\\n---')) {\n // Check if there's actually a closing delimiter\n const lines = normalizedContent.split('\\n');\n let hasClosing = false;\n for (let i = 1; i < lines.length; i++) {\n if (lines[i]?.trim() === '---') {\n hasClosing = true;\n break;\n }\n }\n if (!hasClosing) {\n throw new Error('Invalid format: missing front matter closing delimiter');\n }\n }\n\n const frontmatter = parsed.data as Record<string, unknown>;\n\n // Parse body - split into description and notes\n const body = parsed.content.trim();\n\n // Find notes section\n const notesMatch = /\\n## Notes\\n/i.exec(body);\n let description = body;\n let notes = '';\n\n if (notesMatch?.index !== undefined) {\n description = body.slice(0, notesMatch.index).trim();\n notes = body.slice(notesMatch.index + notesMatch[0].length).trim();\n }\n\n return { frontmatter, description, notes };\n}\n\n/**\n * Parse an issue from Markdown file content.\n */\nexport function parseIssue(content: string): Issue {\n const { frontmatter, description, notes } = parseMarkdownWithFrontmatter(content);\n\n // Merge body content into frontmatter\n const data = {\n ...frontmatter,\n description: description || undefined,\n notes: notes || undefined,\n };\n\n // Validate and parse with Zod\n return IssueSchema.parse(data);\n}\n\n/**\n * Serialize an issue to Markdown file content.\n * Uses canonical serialization for deterministic output.\n */\nexport function serializeIssue(issue: Issue): string {\n // Extract body fields\n const { description, notes, ...metadata } = issue;\n\n // Sort keys using canonical field order (not alphabetical)\n const sortedMetadata = sortKeys(metadata, ISSUE_FIELD_ORDER);\n\n // Serialize YAML with compact output for frontmatter.\n // sortMapEntries: false preserves our manual ordering.\n const yaml = stringifyYaml(sortedMetadata, {\n lineWidth: 0,\n nullStr: 'null',\n sortMapEntries: false,\n });\n\n // Build the file content\n // Note: No blank line between closing --- and body content\n const parts = ['---', yaml.trim(), '---'];\n\n if (description) {\n parts.push(description.trim());\n }\n\n if (notes) {\n parts.push('');\n parts.push('## Notes');\n parts.push('');\n parts.push(notes.trim());\n }\n\n // Single newline at end\n return parts.join('\\n') + '\\n';\n}\n","/**\n * tbd: Git-native issue tracking for AI agents and humans\n *\n * This is the library entry point. All exports here should be node-free\n * to support browser/edge runtime usage. CLI-specific code is in ./cli/.\n */\n\n// Version injected at build time\ndeclare const __TBD_VERSION__: string;\n\n/**\n * Package version, derived from git at build time.\n * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.\n */\nexport const VERSION: string =\n typeof __TBD_VERSION__ !== 'undefined' ? __TBD_VERSION__ : 'development';\n\n// Re-export schemas for library consumers\nexport * from './lib/schemas.js';\nexport * from './lib/types.js';\n\n// Re-export core operations (these should be node-free)\nexport { parseIssue, serializeIssue } from './file/parser.js';\n"],"mappings":"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"}