get-tbd 0.1.21 → 0.1.23

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.
package/dist/cli.mjs CHANGED
@@ -1,6 +1,7 @@
1
- import { A as LOCAL_STATE_FIELD_ORDER, D as IssueKind, a as stringifyYaml, c as ordering, d as ATTIC_ENTRY_FIELD_ORDER, f as AtticEntrySchema, h as ConfigSchema, i as sortKeys, j as LocalStateSchema, k as IssueStatus, l as PAGINATION_LINE_THRESHOLD, m as CONFIG_FIELD_ORDER, r as parseYamlWithConflictDetection, s as comparisonChain, t as detectDuplicateYamlKeys, u as PARENT_CONTEXT_MAX_LINES } from "./yaml-utils-x_kr2IId.mjs";
2
- import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-BjMRpmMh.mjs";
3
- import { _ as normalizeIssueId, a as loadIdMapping, c as resolveToInternalId, d as extractShortId, f as extractUlidFromInternalId, g as makeInternalId, h as generateInternalId, i as hasShortId, l as saveIdMapping, m as formatDisplayId, o as mergeIdMappings, p as formatDebugId, r as generateUniqueShortId, s as parseIdMappingFromYaml, t as addIdMapping, u as extractPrefix, v as validateIssueId } from "./id-mapping-CD5c_ZVA.mjs";
1
+ import { D as IssueKind, a as stringifyYaml, c as ordering, d as ATTIC_ENTRY_FIELD_ORDER, f as AtticEntrySchema, i as sortKeys, k as IssueStatus, l as PAGINATION_LINE_THRESHOLD, r as parseYamlWithConflictDetection, s as comparisonChain, t as detectDuplicateYamlKeys, u as PARENT_CONTEXT_MAX_LINES } from "./yaml-utils-U7l9hhkh.mjs";
2
+ import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-7qUDeWJf.mjs";
3
+ import { A as isValidWorkspaceName, C as TBD_SHORTCUTS_STANDARD, D as WORKTREE_DIR, E as WORKSPACES_DIR, M as resolveDataSyncDir, O as WORKTREE_DIR_NAME, S as TBD_GUIDELINES_DIR, T as TBD_TEMPLATES_DIR, _ as DEFAULT_SHORTCUT_PATHS, a as isInitialized, b as TBD_DIR, c as readConfigWithMigration, d as writeConfig, g as DEFAULT_GUIDELINES_PATHS, h as DATA_SYNC_DIR_NAME, i as initConfig, j as resolveAtticDir, k as getWorkspaceDir, l as readLocalState, m as DATA_SYNC_DIR, n as findTbdRoot, o as markWelcomeSeen, p as CHARS_PER_TOKEN, r as hasSeenWelcome, s as readConfig, u as updateLocalState, v as DEFAULT_TEMPLATE_PATHS, w as TBD_SHORTCUTS_SYSTEM, x as TBD_DOCS_DIR, y as SYNC_BRANCH } from "./config-CmEAGaxz.mjs";
4
+ import { _ as generateInternalId, a as hasShortId, b as validateIssueId, c as parseIdMappingFromYaml, d as saveIdMapping, f as extractPrefix, g as formatDisplayId, h as formatDebugId, i as generateUniqueShortId, l as reconcileMappings, m as extractUlidFromInternalId, o as loadIdMapping, p as extractShortId, s as mergeIdMappings, t as addIdMapping, u as resolveToInternalId, v as makeInternalId, y as normalizeIssueId } from "./id-mapping-JGow6Jk4.mjs";
4
5
  import { createRequire } from "node:module";
5
6
  import matter from "gray-matter";
6
7
  import { parse } from "yaml";
@@ -10,7 +11,7 @@ import { marked } from "marked";
10
11
  import { markedTerminal } from "marked-terminal";
11
12
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
12
13
  import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
13
- import { basename, dirname, isAbsolute, join, normalize, parse as parse$1, relative, resolve, sep } from "node:path";
14
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
14
15
  import { writeFile } from "atomically";
15
16
  import { homedir } from "node:os";
16
17
  import { promisify } from "node:util";
@@ -52,15 +53,12 @@ const VERSION = getVersion();
52
53
  */
53
54
  function getCommandContext(command) {
54
55
  const opts = command.optsWithGlobals();
55
- const isCI = Boolean(process.env.CI);
56
56
  return {
57
57
  dryRun: opts.dryRun ?? false,
58
58
  verbose: opts.verbose ?? false,
59
59
  quiet: opts.quiet ?? false,
60
60
  json: opts.json ?? false,
61
61
  color: opts.color ?? "auto",
62
- nonInteractive: opts.nonInteractive ?? (!process.stdin.isTTY || isCI),
63
- yes: opts.yes ?? false,
64
62
  sync: opts.sync !== false,
65
63
  debug: opts.debug ?? false
66
64
  };
@@ -550,634 +548,6 @@ var OutputManager = class {
550
548
  }
551
549
  };
552
550
 
553
- //#endregion
554
- //#region src/lib/paths.ts
555
- /**
556
- * Centralized path constants for tbd.
557
- *
558
- * Directory structure (per spec):
559
- *
560
- * On main/dev branches:
561
- * .tbd/
562
- * Committed to the repo:
563
- * config.yml - Project configuration
564
- * .gitignore - Controls what's gitignored below
565
- * workspaces/ - Persistent state (outbox, named workspaces)
566
- * Gitignored (local only):
567
- * state.yml - Local state
568
- * docs/ - Installed documentation (regenerated on setup)
569
- * data-sync-worktree/ - Hidden worktree checkout of tbd-sync branch
570
- * .tbd/data-sync/ - issues/, mappings/, attic/, meta.yml
571
- *
572
- * On tbd-sync branch:
573
- * .tbd/
574
- * data-sync/
575
- * issues/
576
- * mappings/
577
- * attic/
578
- * meta.yml
579
- */
580
- /** The tbd configuration directory on main branch */
581
- const TBD_DIR = ".tbd";
582
- /** The config file path */
583
- const CONFIG_FILE = join(TBD_DIR, "config.yml");
584
- /** The local state file (gitignored) */
585
- const STATE_FILE = join(TBD_DIR, "state.yml");
586
- /** The worktree directory name */
587
- const WORKTREE_DIR_NAME = "data-sync-worktree";
588
- /** The worktree path (gitignored) */
589
- const WORKTREE_DIR = join(TBD_DIR, WORKTREE_DIR_NAME);
590
- /** The data directory name on the sync branch */
591
- const DATA_SYNC_DIR_NAME = "data-sync";
592
- /**
593
- * The base directory for synced data.
594
- *
595
- * NOTE: This is currently pointing directly to .tbd/data-sync/ which is WRONG
596
- * per the spec. The correct path should be via the worktree:
597
- * .tbd/data-sync-worktree/.tbd/data-sync/
598
- *
599
- * TODO(tbd-208): Update this to use the worktree path once worktree
600
- * management is implemented.
601
- */
602
- const DATA_SYNC_DIR = join(TBD_DIR, DATA_SYNC_DIR_NAME);
603
- /**
604
- * The correct path for synced data via worktree (per spec).
605
- * Use this once worktree management is implemented.
606
- */
607
- const DATA_SYNC_DIR_VIA_WORKTREE = join(WORKTREE_DIR, TBD_DIR, DATA_SYNC_DIR_NAME);
608
- /** Issues directory */
609
- const ISSUES_DIR = join(DATA_SYNC_DIR, "issues");
610
- /** Mappings directory */
611
- const MAPPINGS_DIR = join(DATA_SYNC_DIR, "mappings");
612
- /** Attic directory for conflict resolution */
613
- const ATTIC_DIR = join(DATA_SYNC_DIR, "attic");
614
- /** Meta file for schema version */
615
- const META_FILE = join(DATA_SYNC_DIR, "meta.yml");
616
- /** The sync branch name */
617
- const SYNC_BRANCH = "tbd-sync";
618
- /** The workspaces directory name within .tbd/ */
619
- const WORKSPACES_DIR_NAME = "workspaces";
620
- /** Full path to workspaces directory: .tbd/workspaces/ */
621
- const WORKSPACES_DIR = join(TBD_DIR, WORKSPACES_DIR_NAME);
622
- /**
623
- * Get the path to a named workspace directory.
624
- *
625
- * Workspaces are stored at: .tbd/workspaces/{name}/
626
- *
627
- * @param workspaceName - The name of the workspace (e.g., 'outbox', 'my-feature')
628
- * @returns Path to the workspace directory
629
- */
630
- function getWorkspaceDir(workspaceName) {
631
- return join(WORKSPACES_DIR, workspaceName);
632
- }
633
- /**
634
- * Validate a workspace name.
635
- *
636
- * Valid workspace names:
637
- * - Lowercase alphanumeric characters
638
- * - Hyphens and underscores allowed
639
- * - Must not be empty
640
- * - Must not contain path separators or dots at start
641
- *
642
- * @param name - The workspace name to validate
643
- * @returns true if the name is valid
644
- */
645
- function isValidWorkspaceName(name) {
646
- if (!name || name.length === 0) return false;
647
- if (name.startsWith(".")) return false;
648
- return /^[a-z0-9][a-z0-9_-]*$/.test(name);
649
- }
650
- /** Docs directory name within .tbd/ */
651
- const DOCS_DIR = "docs";
652
- /** Shortcuts directory name within docs/ */
653
- const SHORTCUTS_DIR = "shortcuts";
654
- /** System shortcuts directory name (core docs like skill-baseline.md) */
655
- const SYSTEM_DIR = "system";
656
- /** Standard shortcuts directory name (workflow shortcuts) */
657
- const STANDARD_DIR = "standard";
658
- /** Guidelines directory name (coding rules and best practices) */
659
- const GUIDELINES_DIR = "guidelines";
660
- /** Templates directory name (document templates) */
661
- const TEMPLATES_DIR = "templates";
662
- /** Full path to docs directory: .tbd/docs/ */
663
- const TBD_DOCS_DIR = join(TBD_DIR, DOCS_DIR);
664
- /** Full path to shortcuts directory: .tbd/docs/shortcuts/ */
665
- const TBD_SHORTCUTS_DIR = join(TBD_DOCS_DIR, SHORTCUTS_DIR);
666
- /** Full path to system shortcuts: .tbd/docs/shortcuts/system/ */
667
- const TBD_SHORTCUTS_SYSTEM = join(TBD_SHORTCUTS_DIR, SYSTEM_DIR);
668
- /** Full path to standard shortcuts: .tbd/docs/shortcuts/standard/ */
669
- const TBD_SHORTCUTS_STANDARD = join(TBD_SHORTCUTS_DIR, STANDARD_DIR);
670
- /** Full path to guidelines: .tbd/docs/guidelines/ (top-level, not under shortcuts) */
671
- const TBD_GUIDELINES_DIR = join(TBD_DOCS_DIR, GUIDELINES_DIR);
672
- /** Full path to templates: .tbd/docs/templates/ (top-level, not under shortcuts) */
673
- const TBD_TEMPLATES_DIR = join(TBD_DOCS_DIR, TEMPLATES_DIR);
674
- /** Built-in docs source paths (relative to package docs/) */
675
- const BUILTIN_SHORTCUTS_SYSTEM = join(SHORTCUTS_DIR, SYSTEM_DIR);
676
- const BUILTIN_SHORTCUTS_STANDARD = join(SHORTCUTS_DIR, STANDARD_DIR);
677
- /**
678
- * Default shortcut lookup paths (searched in order, relative to tbd root).
679
- * Earlier paths take precedence over later paths.
680
- * Note: Guidelines and templates are now separate top-level directories.
681
- */
682
- const DEFAULT_SHORTCUT_PATHS = [TBD_SHORTCUTS_SYSTEM, TBD_SHORTCUTS_STANDARD];
683
- /**
684
- * Default guidelines lookup paths (relative to tbd root).
685
- */
686
- const DEFAULT_GUIDELINES_PATHS = [TBD_GUIDELINES_DIR];
687
- /**
688
- * Default template lookup paths (relative to tbd root).
689
- */
690
- const DEFAULT_TEMPLATE_PATHS = [TBD_TEMPLATES_DIR];
691
- /**
692
- * Error thrown when worktree is missing and fallback is not allowed.
693
- * Defined inline to avoid circular dependency with errors.ts.
694
- */
695
- var WorktreeMissingError$1 = class extends Error {
696
- constructor(message = "Worktree not found at .tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.") {
697
- super(message);
698
- this.name = "WorktreeMissingError";
699
- }
700
- };
701
- /**
702
- * Cache for resolved data sync directory.
703
- * Reset when baseDir changes.
704
- */
705
- let _resolvedDataSyncDir = null;
706
- let _resolvedBaseDir = null;
707
- let _resolvedAllowFallback = null;
708
- /**
709
- * Resolve the actual data sync directory path.
710
- *
711
- * This function detects whether we're running with a git worktree
712
- * (production) or in a test environment without worktree.
713
- *
714
- * Order of preference:
715
- * 1. Worktree path if worktree exists: .tbd/data-sync-worktree/.tbd/data-sync/
716
- * 2. Direct path as fallback (only if allowFallback: true)
717
- *
718
- * @param baseDir - The tbd root directory (from requireInit or findTbdRoot)
719
- * @param options - Options for path resolution
720
- * @returns Resolved data sync directory path
721
- * @throws WorktreeMissingError if worktree missing and allowFallback is false
722
- *
723
- * See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
724
- */
725
- async function resolveDataSyncDir(baseDir, options) {
726
- const allowFallback = options?.allowFallback ?? true;
727
- if (_resolvedDataSyncDir && _resolvedBaseDir === baseDir && _resolvedAllowFallback === allowFallback) return _resolvedDataSyncDir;
728
- const worktreePath = join(baseDir, DATA_SYNC_DIR_VIA_WORKTREE);
729
- const directPath = join(baseDir, DATA_SYNC_DIR);
730
- try {
731
- await access(worktreePath);
732
- _resolvedDataSyncDir = worktreePath;
733
- _resolvedBaseDir = baseDir;
734
- _resolvedAllowFallback = allowFallback;
735
- return worktreePath;
736
- } catch {
737
- if (!allowFallback) throw new WorktreeMissingError$1();
738
- if (process.env.DEBUG || process.env.TBD_DEBUG) console.warn("[tbd:paths] resolveDataSyncDir: worktree not found, falling back to direct path");
739
- _resolvedDataSyncDir = directPath;
740
- _resolvedBaseDir = baseDir;
741
- _resolvedAllowFallback = allowFallback;
742
- return directPath;
743
- }
744
- }
745
- /**
746
- * Resolve attic directory path.
747
- */
748
- async function resolveAtticDir(baseDir, options) {
749
- return join(await resolveDataSyncDir(baseDir, options), "attic");
750
- }
751
- /**
752
- * Characters per token ratio for estimating token counts.
753
- *
754
- * Based on research of OpenAI (tiktoken) and Claude tokenizers:
755
- * - Pure English prose: ~4-5 chars/token
756
- * - Code and symbols: ~3 chars/token
757
- * - Mixed markdown/code docs: ~3.5 chars/token
758
- *
759
- * We use 3.5 as our docs are markdown with code examples.
760
- * This provides ~15-20% accuracy, sufficient for cost estimation.
761
- */
762
- const CHARS_PER_TOKEN = 3.5;
763
-
764
- //#endregion
765
- //#region src/lib/tbd-format.ts
766
- /**
767
- * tbd Directory Format Versioning
768
- * ================================
769
- *
770
- * This file is the SINGLE SOURCE OF TRUTH for .tbd/ directory format versions.
771
- *
772
- * WHEN TO BUMP THE FORMAT VERSION:
773
- * - Bump when changes REQUIRE migration (deleting files, changing formats, moving files)
774
- * - **Bump when changing config schema** (adding, removing, or modifying fields)
775
- * - Do NOT bump for additive changes that don't affect config.yml (new directories, etc.)
776
- *
777
- * HOW TO ADD A NEW FORMAT VERSION:
778
- * 1. Add entry to FORMAT_HISTORY with detailed description
779
- * 2. Implement migrate_fXX_to_fYY() function
780
- * 3. Add case to migrateToLatest()
781
- * 4. Update CURRENT_FORMAT
782
- * 5. Add tests for the migration path
783
- *
784
- * FORWARD COMPATIBILITY POLICY:
785
- * ConfigSchema uses Zod's strip() mode, which discards unknown fields. To prevent
786
- * data loss when users mix tbd versions:
787
- *
788
- * 1. When changing config schema, bump the format version (e.g., f03 → f04)
789
- * 2. config.ts checks format compatibility via isCompatibleFormat()
790
- * 3. Older tbd versions will error with "format 'fXX' is from a newer tbd version"
791
- * 4. The error tells users to upgrade: npm install -g get-tbd@latest
792
- *
793
- * This ensures older versions fail fast rather than silently corrupting config.
794
- * See ConfigSchema in schemas.ts and checkFormatCompatibility() in config.ts.
795
- */
796
- /**
797
- * Current format version.
798
- * Bump this ONLY for breaking changes that require migration.
799
- */
800
- const CURRENT_FORMAT = "f03";
801
- /**
802
- * Initial format version for configs that don't have tbd_format field.
803
- */
804
- const INITIAL_FORMAT = "f01";
805
- /**
806
- * Complete history of format versions with their changes.
807
- * This serves as documentation and enables version detection.
808
- */
809
- const FORMAT_HISTORY = {
810
- f01: {
811
- introduced: "0.1.0",
812
- description: "Initial format",
813
- structure: {
814
- "config.yml": "Project configuration",
815
- "state.yml": "Local state (gitignored)",
816
- "docs/": "Documentation cache (gitignored)",
817
- "issues/": "Issue YAML files"
818
- }
819
- },
820
- f02: {
821
- introduced: "0.1.5",
822
- description: "Adds configurable doc_cache",
823
- changes: [
824
- "Added doc_cache: key to config.yml for configurable doc sources",
825
- "Added settings.doc_auto_sync_hours for automatic doc refresh",
826
- "Added last_doc_sync_at to state.yml for tracking sync time"
827
- ],
828
- migration: "Populates default doc_cache config from bundled docs"
829
- },
830
- f03: {
831
- introduced: "0.1.6",
832
- description: "Consolidates docs_cache config structure",
833
- changes: [
834
- "Consolidated doc_cache: and docs: into single docs_cache: key",
835
- "Moved doc_cache: -> docs_cache.files:",
836
- "Moved docs.paths: -> docs_cache.lookup_path:",
837
- "Removed separate docs: key"
838
- ],
839
- migration: "Migrates old config keys to new docs_cache structure"
840
- }
841
- };
842
- /**
843
- * Migrate from f01 to f02.
844
- * - Adds tbd_format field
845
- * - Adds doc_auto_sync_hours setting (default: 24)
846
- * - doc_cache will be populated separately during setup (requires file system access)
847
- */
848
- function migrate_f01_to_f02(config) {
849
- const changes = [];
850
- const migrated = { ...config };
851
- migrated.tbd_format = "f02";
852
- changes.push("Added tbd_format: f02");
853
- migrated.settings ??= {};
854
- if (migrated.settings.doc_auto_sync_hours === void 0) {
855
- migrated.settings.doc_auto_sync_hours = 24;
856
- changes.push("Added settings.doc_auto_sync_hours: 24");
857
- }
858
- return {
859
- config: migrated,
860
- fromFormat: "f01",
861
- toFormat: "f02",
862
- changed: changes.length > 0,
863
- changes
864
- };
865
- }
866
- /**
867
- * Migrate from f02 to f03.
868
- * - Consolidates doc_cache: and docs: into docs_cache:
869
- * - Moves doc_cache: -> docs_cache.files:
870
- * - Moves docs.paths: -> docs_cache.lookup_path:
871
- * - Removes separate docs: and doc_cache: keys
872
- */
873
- function migrate_f02_to_f03(config) {
874
- const changes = [];
875
- const migrated = { ...config };
876
- migrated.tbd_format = "f03";
877
- changes.push("Updated tbd_format: f03");
878
- migrated.docs_cache ??= {};
879
- if (migrated.doc_cache && Object.keys(migrated.doc_cache).length > 0) {
880
- migrated.docs_cache.files = { ...migrated.doc_cache };
881
- changes.push("Moved doc_cache: -> docs_cache.files:");
882
- delete migrated.doc_cache;
883
- }
884
- if (migrated.docs?.paths && migrated.docs.paths.length > 0) {
885
- migrated.docs_cache.lookup_path = [...migrated.docs.paths];
886
- changes.push("Moved docs.paths: -> docs_cache.lookup_path:");
887
- }
888
- if (migrated.docs) {
889
- delete migrated.docs;
890
- changes.push("Removed docs: key");
891
- }
892
- return {
893
- config: migrated,
894
- fromFormat: "f02",
895
- toFormat: "f03",
896
- changed: changes.length > 0,
897
- changes
898
- };
899
- }
900
- /**
901
- * Detect the format version of a config.
902
- * Returns INITIAL_FORMAT ('f01') if no tbd_format field is present.
903
- */
904
- function detectFormat(config) {
905
- const format = config.tbd_format;
906
- if (!format) return INITIAL_FORMAT;
907
- if (format in FORMAT_HISTORY) return format;
908
- return CURRENT_FORMAT;
909
- }
910
- /**
911
- * Check if a config needs migration.
912
- */
913
- function needsMigration(config) {
914
- return detectFormat(config) !== CURRENT_FORMAT;
915
- }
916
- /**
917
- * Migrate a config to the latest format version.
918
- *
919
- * This function applies all necessary migrations in sequence.
920
- * It does NOT populate doc_cache - that requires file system access
921
- * and should be done separately during setup.
922
- *
923
- * @param config - The raw config to migrate
924
- * @returns Migration result with the migrated config and change log
925
- */
926
- function migrateToLatest(config) {
927
- const fromFormat = detectFormat(config);
928
- if (fromFormat === CURRENT_FORMAT) return {
929
- config,
930
- fromFormat,
931
- toFormat: CURRENT_FORMAT,
932
- changed: false,
933
- changes: []
934
- };
935
- let current = config;
936
- let currentFormat = fromFormat;
937
- const allChanges = [];
938
- if (currentFormat === "f01") {
939
- const result = migrate_f01_to_f02(current);
940
- current = result.config;
941
- currentFormat = "f02";
942
- allChanges.push(...result.changes);
943
- }
944
- if (currentFormat === "f02") {
945
- const result = migrate_f02_to_f03(current);
946
- current = result.config;
947
- currentFormat = "f03";
948
- allChanges.push(...result.changes);
949
- }
950
- return {
951
- config: current,
952
- fromFormat,
953
- toFormat: currentFormat,
954
- changed: allChanges.length > 0,
955
- changes: allChanges
956
- };
957
- }
958
- /**
959
- * Check if a format version is compatible with the current tbd version.
960
- * Future format versions are considered incompatible (would need tbd upgrade).
961
- */
962
- function isCompatibleFormat(format) {
963
- const formatVersions = Object.keys(FORMAT_HISTORY);
964
- const currentIndex = formatVersions.indexOf(CURRENT_FORMAT);
965
- const checkIndex = formatVersions.indexOf(format);
966
- if (checkIndex === -1) return false;
967
- return checkIndex <= currentIndex;
968
- }
969
-
970
- //#endregion
971
- //#region src/file/config.ts
972
- /**
973
- * Config file operations.
974
- *
975
- * Config is stored at .tbd/config.yml and contains project-level settings.
976
- *
977
- * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.
978
- *
979
- * See: tbd-design.md §2.2.2 Config File
980
- */
981
- /**
982
- * Error thrown when the config format version is from a newer tbd version.
983
- * This prevents older tbd versions from silently stripping new config fields.
984
- */
985
- var IncompatibleFormatError = class extends Error {
986
- constructor(foundFormat, supportedFormat) {
987
- super(`Config format '${foundFormat}' is from a newer tbd version.\nThis tbd version supports up to format '${supportedFormat}'.\nPlease upgrade tbd: npm install -g get-tbd@latest`);
988
- this.foundFormat = foundFormat;
989
- this.supportedFormat = supportedFormat;
990
- this.name = "IncompatibleFormatError";
991
- }
992
- };
993
- /**
994
- * Check if config format is compatible, throw if not.
995
- * This prevents older tbd versions from silently stripping fields added by newer versions.
996
- */
997
- function checkFormatCompatibility(data) {
998
- const format = data.tbd_format;
999
- if (format && !isCompatibleFormat(format)) throw new IncompatibleFormatError(format, CURRENT_FORMAT);
1000
- }
1001
- /**
1002
- * Create default config for a new project.
1003
- * @param prefix - Required: the project prefix for display IDs (e.g., "proj", "myapp")
1004
- */
1005
- function createDefaultConfig(version, prefix) {
1006
- return ConfigSchema.parse({
1007
- tbd_format: CURRENT_FORMAT,
1008
- tbd_version: version,
1009
- sync: {
1010
- branch: SYNC_BRANCH,
1011
- remote: "origin"
1012
- },
1013
- display: { id_prefix: prefix },
1014
- settings: {
1015
- auto_sync: false,
1016
- doc_auto_sync_hours: 24
1017
- }
1018
- });
1019
- }
1020
- /**
1021
- * Initialize a new config file with default settings.
1022
- * Creates .tbd directory if it doesn't exist.
1023
- * @param prefix - Required: the project prefix for display IDs (e.g., "proj", "myapp")
1024
- */
1025
- async function initConfig(baseDir, version, prefix) {
1026
- await mkdir(join(baseDir, ".tbd"), { recursive: true });
1027
- const config = createDefaultConfig(version, prefix);
1028
- await writeConfig(baseDir, config);
1029
- return config;
1030
- }
1031
- /**
1032
- * Read config from file with automatic migration if needed.
1033
- *
1034
- * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.
1035
- *
1036
- * @throws {IncompatibleFormatError} If config is from a newer tbd version.
1037
- * @throws If config file doesn't exist or is invalid.
1038
- */
1039
- async function readConfig(baseDir) {
1040
- const data = parse(await readFile(join(baseDir, CONFIG_FILE), "utf-8"));
1041
- checkFormatCompatibility(data);
1042
- if (needsMigration(data)) {
1043
- const result = migrateToLatest(data);
1044
- return ConfigSchema.parse(result.config);
1045
- }
1046
- return ConfigSchema.parse(data);
1047
- }
1048
- /**
1049
- * Read config from file, returning migration info if a migration was applied.
1050
- * Use this when you need to know if the config was migrated.
1051
- *
1052
- * @throws {IncompatibleFormatError} If config is from a newer tbd version.
1053
- */
1054
- async function readConfigWithMigration(baseDir) {
1055
- const data = parse(await readFile(join(baseDir, CONFIG_FILE), "utf-8"));
1056
- checkFormatCompatibility(data);
1057
- if (needsMigration(data)) {
1058
- const result = migrateToLatest(data);
1059
- return {
1060
- config: ConfigSchema.parse(result.config),
1061
- migrated: result.changed,
1062
- changes: result.changes
1063
- };
1064
- }
1065
- return {
1066
- config: ConfigSchema.parse(data),
1067
- migrated: false,
1068
- changes: []
1069
- };
1070
- }
1071
- /**
1072
- * Write config to file with explanatory comments.
1073
- */
1074
- async function writeConfig(baseDir, config) {
1075
- const configPath = join(baseDir, CONFIG_FILE);
1076
- let content = stringifyYaml(sortKeys(config, CONFIG_FIELD_ORDER), {
1077
- lineWidth: 0,
1078
- sortMapEntries: false
1079
- });
1080
- if (config.docs_cache && Object.keys(config.docs_cache).length > 0) content = content.replace("docs_cache:", "# 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).\ndocs_cache:");
1081
- await writeFile(configPath, content);
1082
- }
1083
- /**
1084
- * Check if tbd is properly initialized in the given directory.
1085
- * Returns true only if .tbd/config.yml exists (not just a .tbd/ directory).
1086
- *
1087
- * This prevents spurious .tbd/ directories (e.g., containing only state.yml
1088
- * created by a bug) from being mistaken for tbd roots. A valid tbd root
1089
- * always has config.yml created during `tbd init`.
1090
- */
1091
- async function hasTbdDir(dir) {
1092
- const configPath = join(dir, CONFIG_FILE);
1093
- try {
1094
- await access(configPath);
1095
- return true;
1096
- } catch {
1097
- return false;
1098
- }
1099
- }
1100
- /**
1101
- * Find the tbd repository root by walking up the directory tree.
1102
- * Similar to how git finds .git/ directories.
1103
- *
1104
- * @param startDir - Directory to start searching from
1105
- * @returns The tbd root directory path, or null if not found
1106
- */
1107
- async function findTbdRoot(startDir) {
1108
- let currentDir = startDir;
1109
- const { root } = parse$1(startDir);
1110
- while (currentDir !== root) {
1111
- if (await hasTbdDir(currentDir)) return currentDir;
1112
- currentDir = dirname(currentDir);
1113
- }
1114
- if (await hasTbdDir(root)) return root;
1115
- return null;
1116
- }
1117
- /**
1118
- * Check if tbd is initialized in the given directory or any parent directory.
1119
- * Walks up the directory tree looking for .tbd/.
1120
- */
1121
- async function isInitialized(baseDir) {
1122
- return await findTbdRoot(baseDir) !== null;
1123
- }
1124
- /**
1125
- * Read local state from .tbd/state.yml
1126
- * Returns empty state if file doesn't exist.
1127
- */
1128
- async function readLocalState(baseDir) {
1129
- const statePath = join(baseDir, STATE_FILE);
1130
- try {
1131
- const data = parse(await readFile(statePath, "utf-8"));
1132
- return LocalStateSchema.parse(data ?? {});
1133
- } catch {
1134
- return {};
1135
- }
1136
- }
1137
- /**
1138
- * Write local state to .tbd/state.yml
1139
- *
1140
- * Uses `atomically` for safe writes (atomic rename, auto parent-dir creation).
1141
- * However, we intentionally guard against .tbd/ not existing: `atomically`
1142
- * would auto-create it, which is wrong if baseDir is a subdirectory rather
1143
- * than the true tbd root. Only `tbd init` (via initConfig) should create .tbd/.
1144
- */
1145
- async function writeLocalState(baseDir, state) {
1146
- const tbdDir = join(baseDir, ".tbd");
1147
- try {
1148
- await access(tbdDir);
1149
- } catch {
1150
- throw new Error(`Cannot write state: .tbd/ directory does not exist at ${baseDir}. Run 'tbd init' first or ensure the correct tbd root is being used.`);
1151
- }
1152
- await writeFile(join(baseDir, STATE_FILE), stringifyYaml(sortKeys(state, LOCAL_STATE_FIELD_ORDER), {
1153
- lineWidth: 0,
1154
- sortMapEntries: false
1155
- }));
1156
- }
1157
- /**
1158
- * Update specific fields in local state (merge with existing).
1159
- */
1160
- async function updateLocalState(baseDir, updates) {
1161
- const updated = {
1162
- ...await readLocalState(baseDir),
1163
- ...updates
1164
- };
1165
- await writeLocalState(baseDir, updated);
1166
- return updated;
1167
- }
1168
- /**
1169
- * Check if the user has seen the welcome message.
1170
- */
1171
- async function hasSeenWelcome(baseDir) {
1172
- return (await readLocalState(baseDir)).welcome_seen === true;
1173
- }
1174
- /**
1175
- * Mark the welcome message as seen.
1176
- */
1177
- async function markWelcomeSeen(baseDir) {
1178
- await updateLocalState(baseDir, { welcome_seen: true });
1179
- }
1180
-
1181
551
  //#endregion
1182
552
  //#region src/cli/lib/errors.ts
1183
553
  /**
@@ -5312,6 +4682,9 @@ async function importFromWorkspace(tbdRoot, dataSyncDir, options) {
5312
4682
  const sourceMapping = await loadIdMapping(sourceDir);
5313
4683
  const targetMapping = await loadIdMapping(dataSyncDir);
5314
4684
  for (const [shortId, ulid] of sourceMapping.shortToUlid) if (!targetMapping.shortToUlid.has(shortId)) addIdMapping(targetMapping, ulid, shortId);
4685
+ const reconcileResult = reconcileMappings(sourceIssues.map((i) => i.id), targetMapping, sourceMapping);
4686
+ if (reconcileResult.recovered.length > 0) log.info(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from workspace`);
4687
+ if (reconcileResult.created.length > 0) log.info(`Created ${reconcileResult.created.length} new ID mapping(s) for imported issues`);
5315
4688
  await saveIdMapping(dataSyncDir, targetMapping);
5316
4689
  let cleared = false;
5317
4690
  if (shouldClear && imported > 0) {
@@ -5466,8 +4839,8 @@ var SyncHandler = class extends BaseCommand {
5466
4839
  else if (options.push) await this.pushChanges(syncBranch, remote);
5467
4840
  else await this.fullSync(syncBranch, remote, {
5468
4841
  force: options.force,
5469
- noAutoSave: options.noAutoSave,
5470
- noOutbox: options.noOutbox
4842
+ autoSave: options.autoSave,
4843
+ outbox: options.outbox
5471
4844
  });
5472
4845
  }
5473
4846
  /**
@@ -5770,6 +5143,24 @@ var SyncHandler = class extends BaseCommand {
5770
5143
  await git("-C", worktreePath, "merge", `${remote}/${syncBranch}`, "-m", "tbd sync: merge remote changes");
5771
5144
  this.output.debug(`Merged ${behindCommits} commit(s) from remote`);
5772
5145
  if (headBeforeMerge) await this.showGitLogDebug("Commits received", `${headBeforeMerge}..${syncBranch}`);
5146
+ const postMergeIssues = await listIssues(this.dataSyncDir);
5147
+ const postMergeMapping = await loadIdMapping(this.dataSyncDir);
5148
+ let historicalMapping;
5149
+ try {
5150
+ const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
5151
+ if (remoteIdsContent) historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
5152
+ } catch {}
5153
+ const reconcileResult = reconcileMappings(postMergeIssues.map((i) => i.id), postMergeMapping, historicalMapping);
5154
+ const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
5155
+ if (totalReconciled > 0) {
5156
+ await saveIdMapping(this.dataSyncDir, postMergeMapping);
5157
+ await git("-C", worktreePath, "add", "-A");
5158
+ try {
5159
+ await git("-C", worktreePath, "commit", "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
5160
+ } catch {}
5161
+ if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
5162
+ if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
5163
+ }
5773
5164
  } catch {
5774
5165
  this.output.info(`Merge conflict, attempting file-level resolution`);
5775
5166
  const localIssues = await listIssues(this.dataSyncDir);
@@ -5782,18 +5173,29 @@ var SyncHandler = class extends BaseCommand {
5782
5173
  } catch {
5783
5174
  this.output.debug(`Issue ${localIssue.id} not on remote, keeping local`);
5784
5175
  }
5176
+ let conflictRemoteMapping;
5785
5177
  try {
5786
5178
  const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
5787
5179
  if (remoteIdsContent) {
5180
+ conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
5788
5181
  const localMapping = await loadIdMapping(this.dataSyncDir);
5789
- const remoteMapping = parseIdMappingFromYaml(remoteIdsContent);
5790
- const mergedMapping = mergeIdMappings(localMapping, remoteMapping);
5182
+ const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
5791
5183
  await saveIdMapping(this.dataSyncDir, mergedMapping);
5792
- this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${remoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
5184
+ this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
5793
5185
  }
5794
5186
  } catch (error) {
5795
5187
  this.output.debug(`Could not merge ids.yml: ${error.message}`);
5796
5188
  }
5189
+ {
5190
+ const allIssues = await listIssues(this.dataSyncDir);
5191
+ const currentMapping = await loadIdMapping(this.dataSyncDir);
5192
+ const reconcileResult = reconcileMappings(allIssues.map((i) => i.id), currentMapping, conflictRemoteMapping);
5193
+ if (reconcileResult.created.length + reconcileResult.recovered.length > 0) {
5194
+ await saveIdMapping(this.dataSyncDir, currentMapping);
5195
+ if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`);
5196
+ if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`);
5197
+ }
5198
+ }
5797
5199
  await git("-C", worktreePath, "add", "-A");
5798
5200
  const conflictCheck = await git("-C", worktreePath, "diff", "--cached", "-S<<<<<<< ", "--name-only");
5799
5201
  if (conflictCheck.trim()) {
@@ -5857,7 +5259,7 @@ var SyncHandler = class extends BaseCommand {
5857
5259
  this.output.error(`Push failed: ${displayError}`);
5858
5260
  console.log(` ${aheadCommits} commit(s) not pushed to remote.`);
5859
5261
  });
5860
- if (errorType === "permanent" && !options.noAutoSave) await this.handlePermanentFailure();
5262
+ if (errorType === "permanent" && options.autoSave !== false) await this.handlePermanentFailure();
5861
5263
  else if (!this.ctx.json) if (errorType === "transient") {
5862
5264
  console.log("");
5863
5265
  console.log(" This appears to be a temporary issue. Options:");
@@ -5872,7 +5274,7 @@ var SyncHandler = class extends BaseCommand {
5872
5274
  }
5873
5275
  return;
5874
5276
  }
5875
- if (!options.noOutbox) await this.maybeImportOutbox(syncBranch, remote);
5277
+ if (options.outbox !== false) await this.maybeImportOutbox(syncBranch, remote);
5876
5278
  this.output.data({
5877
5279
  summary,
5878
5280
  conflicts: conflicts.length
@@ -6895,6 +6297,7 @@ var DoctorHandler = class extends BaseCommand {
6895
6297
  healthChecks.push(await this.checkIdMappingDuplicates(options.fix));
6896
6298
  healthChecks.push(await this.checkTempFiles(options.fix));
6897
6299
  healthChecks.push(this.checkIssueValidity(this.issues));
6300
+ healthChecks.push(await this.checkMissingMappings(options.fix));
6898
6301
  healthChecks.push(await this.checkWorktree(options.fix));
6899
6302
  healthChecks.push(await this.checkDataLocation(options.fix));
6900
6303
  healthChecks.push(await this.checkLocalSyncBranch());
@@ -7119,7 +6522,7 @@ var DoctorHandler = class extends BaseCommand {
7119
6522
  status: "ok"
7120
6523
  };
7121
6524
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
7122
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-BqSnxlxk.mjs");
6525
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-0-R0X8zb.mjs");
7123
6526
  const mapping = await loadIdMapping(this.dataSyncDir);
7124
6527
  await saveIdMapping(this.dataSyncDir, mapping);
7125
6528
  return {
@@ -7238,6 +6641,63 @@ var DoctorHandler = class extends BaseCommand {
7238
6641
  suggestion: "Manually fix or delete invalid issue files"
7239
6642
  };
7240
6643
  }
6644
+ /**
6645
+ * Check for issues that have no short ID mapping in ids.yml.
6646
+ *
6647
+ * This can happen when a git merge brings in issue files (e.g., from
6648
+ * a feature branch with outbox issues) without the corresponding
6649
+ * ids.yml entries. Without a mapping, any command that tries to
6650
+ * display the issue ID will crash.
6651
+ *
6652
+ * With --fix, creates missing mappings automatically.
6653
+ */
6654
+ async checkMissingMappings(fix) {
6655
+ if (this.issues.length === 0) return {
6656
+ name: "ID mapping coverage",
6657
+ status: "ok"
6658
+ };
6659
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-0-R0X8zb.mjs");
6660
+ const mapping = await loadIdMapping(this.dataSyncDir);
6661
+ const missingIds = [];
6662
+ for (const issue of this.issues) {
6663
+ const ulid = extractUlidFromInternalId(issue.id);
6664
+ if (!mapping.ulidToShort.has(ulid)) missingIds.push(issue.id);
6665
+ }
6666
+ if (missingIds.length === 0) return {
6667
+ name: "ID mapping coverage",
6668
+ status: "ok"
6669
+ };
6670
+ if (fix && !this.checkDryRun("Create missing ID mappings")) {
6671
+ const { parseIdMappingFromYaml } = await import("./id-mapping-0-R0X8zb.mjs");
6672
+ let historicalMapping;
6673
+ try {
6674
+ const syncBranch = (await import("./config-CB1tcqTZ.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
6675
+ const priorContent = await git("log", "-1", "--format=%H", syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
6676
+ if (priorContent.trim()) {
6677
+ const idsContent = await git("show", `${priorContent.trim()}:${DATA_SYNC_DIR}/mappings/ids.yml`);
6678
+ if (idsContent) historicalMapping = parseIdMappingFromYaml(idsContent);
6679
+ }
6680
+ } catch {}
6681
+ const result = reconcileMappings(missingIds, mapping, historicalMapping);
6682
+ await saveIdMapping(this.dataSyncDir, mapping);
6683
+ const parts = [];
6684
+ if (result.recovered.length > 0) parts.push(`recovered ${result.recovered.length} from git history`);
6685
+ if (result.created.length > 0) parts.push(`created ${result.created.length} new`);
6686
+ return {
6687
+ name: "ID mapping coverage",
6688
+ status: "ok",
6689
+ message: parts.join(", ")
6690
+ };
6691
+ }
6692
+ return {
6693
+ name: "ID mapping coverage",
6694
+ status: "error",
6695
+ message: `${missingIds.length} issue(s) without short ID mapping`,
6696
+ details: missingIds.map((id) => `${id} (no short ID)`),
6697
+ fixable: true,
6698
+ suggestion: "Run: tbd doctor --fix to create missing mappings"
6699
+ };
6700
+ }
7241
6701
  async checkClaudeSkill() {
7242
6702
  const claudePaths = getClaudePaths(this.cwd);
7243
6703
  try {
@@ -10939,6 +10399,9 @@ var SetupDefaultHandler = class extends BaseCommand {
10939
10399
  ]);
10940
10400
  if (tbdGitignoreResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitignore`);
10941
10401
  else if (tbdGitignoreResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitignore with new patterns`);
10402
+ const gitattributesResult = await ensureGitignorePatterns(join(projectDir, TBD_DIR, ".gitattributes"), ["# Protect ID mappings from merge deletion (always keep all rows)", "**/mappings/ids.yml merge=union"]);
10403
+ if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
10404
+ else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
10942
10405
  console.log("Checking integrations...");
10943
10406
  await new SetupAutoHandler(this.cmd).run(projectDir);
10944
10407
  console.log("");
@@ -11069,6 +10532,9 @@ Example:
11069
10532
  ]);
11070
10533
  if (tbdGitignoreResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitignore`);
11071
10534
  else if (tbdGitignoreResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitignore`);
10535
+ const gitattributesResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitattributes"), ["# Protect ID mappings from merge deletion (always keep all rows)", "**/mappings/ids.yml merge=union"]);
10536
+ if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
10537
+ else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
11072
10538
  try {
11073
10539
  await initWorktree(cwd);
11074
10540
  const health = await checkWorktreeHealth(cwd);
@@ -11441,7 +10907,7 @@ const workspaceCommand = new Command("workspace").description("Manage workspaces
11441
10907
  function createProgram() {
11442
10908
  const program = new Command().name("tbd").description("Git-native issue tracking for AI agents and humans").version(VERSION, "--version", "Show version number").helpOption("--help", "Display help for command").showHelpAfterError("(add --help for additional information)");
11443
10909
  configureColoredHelp(program);
11444
- program.option("--dry-run", "Show what would be done without making changes").option("--verbose", "Enable verbose output").option("--quiet", "Suppress non-essential output").option("--json", "Output as JSON").option("--color <when>", "Colorize output: auto, always, never", "auto").option("--non-interactive", "Disable all prompts, fail if input required").option("--yes", "Assume yes to confirmation prompts").option("--no-sync", "Skip automatic sync after write operations").option("--debug", "Show internal IDs alongside public IDs for debugging");
10910
+ program.option("--dry-run", "Show what would be done without making changes").option("--verbose", "Enable verbose output").option("--quiet", "Suppress non-essential output").option("--json", "Output as JSON").option("--color <when>", "Colorize output: auto, always, never", "auto").option("--no-sync", "Skip automatic sync after write operations").option("--debug", "Show internal IDs alongside public IDs for debugging");
11445
10911
  program.commandsGroup("Documentation:");
11446
10912
  program.addCommand(readmeCommand);
11447
10913
  program.addCommand(primeCommand);