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/README.md +17 -19
- package/dist/bin.mjs +181 -12
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +110 -644
- package/dist/cli.mjs.map +1 -1
- package/dist/config-CB1tcqTZ.mjs +3 -0
- package/dist/config-CmEAGaxz.mjs +637 -0
- package/dist/config-CmEAGaxz.mjs.map +1 -0
- package/dist/docs/README.md +17 -19
- package/dist/docs/guidelines/bun-monorepo-patterns.md +816 -80
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +586 -16
- package/dist/docs/guidelines/python-cli-patterns.md +2 -2
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +27 -0
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +465 -196
- package/dist/docs/tbd-design.md +86 -46
- package/dist/docs/tbd-docs.md +0 -6
- package/dist/id-mapping-0-R0X8zb.mjs +3 -0
- package/dist/{id-mapping-CD5c_ZVA.mjs → id-mapping-JGow6Jk4.mjs} +57 -3
- package/dist/{id-mapping-CD5c_ZVA.mjs.map → id-mapping-JGow6Jk4.mjs.map} +1 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +2 -2
- package/dist/{src-BjMRpmMh.mjs → src-7qUDeWJf.mjs} +3 -3
- package/dist/{src-BjMRpmMh.mjs.map → src-7qUDeWJf.mjs.map} +1 -1
- package/dist/tbd +181 -12
- package/dist/{yaml-utils-x_kr2IId.mjs → yaml-utils-U7l9hhkh.mjs} +7 -1
- package/dist/yaml-utils-U7l9hhkh.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/id-mapping-BqSnxlxk.mjs +0 -3
- package/dist/yaml-utils-x_kr2IId.mjs.map +0 -1
package/dist/docs/tbd-design.md
CHANGED
|
@@ -77,6 +77,7 @@ agents.
|
|
|
77
77
|
- [3.2 Sync Branch Architecture](#32-sync-branch-architecture)
|
|
78
78
|
- [Files Tracked on Main Branch](#files-tracked-on-main-branch)
|
|
79
79
|
- [.tbd/.gitignore Contents](#tbdgitignore-contents)
|
|
80
|
+
- [.tbd/.gitattributes Contents](#tbdgitattributes-contents)
|
|
80
81
|
- [Files Tracked on tbd-sync Branch](#files-tracked-on-tbd-sync-branch)
|
|
81
82
|
- [3.3 Sync Operations](#33-sync-operations)
|
|
82
83
|
- [3.3.1 Reading from Sync Branch](#331-reading-from-sync-branch)
|
|
@@ -325,7 +326,7 @@ tbd and Beads serve different use cases:
|
|
|
325
326
|
| Complex workflow orchestration | Molecules, wisps, formulas, bonding |
|
|
326
327
|
| Need ephemeral work tracking | Wisps (never synced, squash to digest) |
|
|
327
328
|
| High-performance queries on 10K+ issues | SQLite with indexes is faster than file scan |
|
|
328
|
-
| Need automatic
|
|
329
|
+
| Need automatic “memory decay” | AI-powered compaction of old issues |
|
|
329
330
|
| Need interactive edit mode | `bd edit` opens in $EDITOR |
|
|
330
331
|
|
|
331
332
|
**Key Differences Summary:**
|
|
@@ -424,7 +425,7 @@ tbd addresses specific requirements:
|
|
|
424
425
|
| Git commit log noise | Issues stored on separate `tbd-sync` branch |
|
|
425
426
|
| Synchronized state across Git branches | Always sync from the `tbd-sync` branch |
|
|
426
427
|
| Git merging conflicts | One file per issue eliminates most merge conflicts |
|
|
427
|
-
| Agent-friendly | Self-documenting, skill-compatible,
|
|
428
|
+
| Agent-friendly | Self-documenting, skill-compatible, simple commands |
|
|
428
429
|
| Transparent formats | Issues internally are Markdown files with YAML frontmatter |
|
|
429
430
|
| Reliable | Clear specs, golden testing of end-to-end use scenarios |
|
|
430
431
|
|
|
@@ -639,6 +640,24 @@ serialization:
|
|
|
639
640
|
.tbd/data-sync/** text eol=lf
|
|
640
641
|
```
|
|
641
642
|
|
|
643
|
+
**Required `.tbd/.gitattributes`** (created by `tbd setup`):
|
|
644
|
+
|
|
645
|
+
```gitattributes
|
|
646
|
+
# Protect ID mappings from merge deletion (always keep all rows)
|
|
647
|
+
# See: https://github.com/jlevy/tbd/issues/99
|
|
648
|
+
**/mappings/ids.yml merge=union
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
> **Why `merge=union`?** Git 3-way merge can delete `ids.yml` when merging a feature
|
|
652
|
+
> branch back to main, because main has no outbox directory and the merge treats “no
|
|
653
|
+
> file” as the correct state.
|
|
654
|
+
> The `merge=union` strategy keeps all lines from both sides, which is safe for
|
|
655
|
+
> `ids.yml` since it’s an append-only mapping.
|
|
656
|
+
> Duplicate keys (if any) are tolerated by the YAML parser and auto-fixed on next save.
|
|
657
|
+
> This file is placed inside `.tbd/` so all tbd settings are self-contained in one
|
|
658
|
+
> directory. Git supports `.gitattributes` in subdirectories with paths relative to that
|
|
659
|
+
> directory.
|
|
660
|
+
|
|
642
661
|
> **Why canonical format?** Deterministic serialization ensures:
|
|
643
662
|
> 1. Git diffs show only actual content changes (no spurious whitespace/ordering noise)
|
|
644
663
|
> 2. Testing is reliable (same input produces same output)
|
|
@@ -712,6 +731,7 @@ tbd uses three directory locations:
|
|
|
712
731
|
│ Committed to the repo:
|
|
713
732
|
├── config.yml # Project configuration
|
|
714
733
|
├── .gitignore # Controls what's gitignored below
|
|
734
|
+
├── .gitattributes # Merge strategies (merge=union for ids.yml)
|
|
715
735
|
├── workspaces/ # Persistent state (outbox, named workspaces)
|
|
716
736
|
│ ├── outbox/ # Sync failure recovery workspace
|
|
717
737
|
│ │ ├── issues/
|
|
@@ -848,6 +868,27 @@ backups/
|
|
|
848
868
|
> **Note:** `workspaces/` must not be gitignored — it stores outbox data that must be
|
|
849
869
|
> committed to the working branch.
|
|
850
870
|
|
|
871
|
+
#### .tbd/.gitattributes Contents
|
|
872
|
+
|
|
873
|
+
The `.tbd/.gitattributes` file configures merge strategies for tbd files.
|
|
874
|
+
It is placed inside `.tbd/` (not at the repo root) so all tbd settings are
|
|
875
|
+
self-contained. Git supports `.gitattributes` in subdirectories with paths relative to
|
|
876
|
+
that directory.
|
|
877
|
+
|
|
878
|
+
```gitattributes
|
|
879
|
+
# Protect ID mappings from merge deletion (always keep all rows)
|
|
880
|
+
# See: https://github.com/jlevy/tbd/issues/99
|
|
881
|
+
**/mappings/ids.yml merge=union
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
> **Why this is needed:** When a feature branch with outbox changes is merged back to
|
|
885
|
+
> main (which has no outbox), git’s 3-way merge can delete `ids.yml` entirely — treating
|
|
886
|
+
> “no file” on main as the correct state.
|
|
887
|
+
> This causes all tbd commands to crash with “No short ID mapping found”.
|
|
888
|
+
> The `merge=union` built-in merge driver keeps all lines from both sides, preventing
|
|
889
|
+
> row deletion. Duplicate YAML keys (if any) are tolerated by the parser and auto-fixed
|
|
890
|
+
> on next save. See [#99](https://github.com/jlevy/tbd/issues/99) for details.
|
|
891
|
+
|
|
851
892
|
#### Accessing Issues via Worktree
|
|
852
893
|
|
|
853
894
|
```bash
|
|
@@ -917,7 +958,7 @@ The worktree can be in one of four states, detected by `checkWorktreeHealth()`:
|
|
|
917
958
|
| State | Description | Detection | Recovery |
|
|
918
959
|
| --- | --- | --- | --- |
|
|
919
960
|
| `valid` | Healthy, ready to use | Directory exists, `.git` file valid, not prunable | None needed |
|
|
920
|
-
| `missing` | Directory doesn
|
|
961
|
+
| `missing` | Directory doesn’t exist | `!exists(.tbd/data-sync-worktree/)` | Create from local or remote branch |
|
|
921
962
|
| `prunable` | Directory deleted but git still tracks it | `git worktree list --porcelain` shows prunable | `git worktree prune`, then recreate |
|
|
922
963
|
| `corrupted` | Directory exists but invalid | Missing `.git` file or invalid gitdir pointer | **Backup to .tbd/backups/**, then recreate |
|
|
923
964
|
|
|
@@ -1760,9 +1801,9 @@ dependencies:
|
|
|
1760
1801
|
|
|
1761
1802
|
| Type | Affects Ready? | Use Case |
|
|
1762
1803
|
| --- | --- | --- |
|
|
1763
|
-
| `blocks` | **Yes** |
|
|
1764
|
-
| `related` | No | Soft link:
|
|
1765
|
-
| `discovered-from` | No | Provenance:
|
|
1804
|
+
| `blocks` | **Yes** | “This issue must close before target can proceed” |
|
|
1805
|
+
| `related` | No | Soft link: “See also” references (future) |
|
|
1806
|
+
| `discovered-from` | No | Provenance: “Found while working on X” (future) |
|
|
1766
1807
|
|
|
1767
1808
|
**Commands:**
|
|
1768
1809
|
|
|
@@ -1797,7 +1838,7 @@ tbd and Beads have different models for parent-child relationships:
|
|
|
1797
1838
|
| Aspect | tbd | Beads |
|
|
1798
1839
|
| --- | --- | --- |
|
|
1799
1840
|
| Parent-child storage | `parent_id` field | `dependencies[].type: parent-child` |
|
|
1800
|
-
| Parent-child blocking | **No** (organizational only) | **Transitive** (inherits parent
|
|
1841
|
+
| Parent-child blocking | **No** (organizational only) | **Transitive** (inherits parent’s blocked state) |
|
|
1801
1842
|
| Dependency types | `blocks` only (more planned) | `blocks`, `parent-child`, `related`, `discovered-from`, + more |
|
|
1802
1843
|
|
|
1803
1844
|
**Understanding Beads’ parent-child behavior:**
|
|
@@ -1935,9 +1976,10 @@ main branch: tbd-sync branch:
|
|
|
1935
1976
|
├── tests/ └── data-sync/
|
|
1936
1977
|
├── README.md ├── issues/
|
|
1937
1978
|
├── .tbd/ ├── attic/
|
|
1938
|
-
│ ├── config.yml
|
|
1939
|
-
│ ├── .gitignore
|
|
1940
|
-
│ ├──
|
|
1979
|
+
│ ├── config.yml (committed) └── meta.yml
|
|
1980
|
+
│ ├── .gitignore (committed)
|
|
1981
|
+
│ ├── .gitattributes (committed)
|
|
1982
|
+
│ ├── workspaces/ (committed)
|
|
1941
1983
|
│ ├── state.yml (gitignored)
|
|
1942
1984
|
│ ├── docs/ (gitignored)
|
|
1943
1985
|
│ └── data-sync-worktree/ (gitignored)
|
|
@@ -1960,6 +2002,7 @@ main branch: tbd-sync branch:
|
|
|
1960
2002
|
```
|
|
1961
2003
|
.tbd/config.yml # Project configuration (YAML)
|
|
1962
2004
|
.tbd/.gitignore # Controls what's gitignored below
|
|
2005
|
+
.tbd/.gitattributes # Merge strategies (merge=union for ids.yml)
|
|
1963
2006
|
.tbd/workspaces/ # Persistent state (outbox, named workspaces)
|
|
1964
2007
|
```
|
|
1965
2008
|
|
|
@@ -2324,14 +2367,14 @@ If `.tbd/config.yml` does not exist or is invalid, commands exit with an error:
|
|
|
2324
2367
|
| `init` | No | Creates `.tbd/` directory and sync branch |
|
|
2325
2368
|
| `status` | No | Shows detection results and guidance (see §4.9) |
|
|
2326
2369
|
| `import --from-beads` | No | Auto-initializes, then imports |
|
|
2327
|
-
| `import <file>` | Yes | Error:
|
|
2328
|
-
| `list`, `show`, `stats` | Yes | Error:
|
|
2329
|
-
| `create`, `update`, `close`, `reopen` | Yes | Error:
|
|
2330
|
-
| `ready`, `blocked`, `stale` | Yes | Error:
|
|
2331
|
-
| `label`, `dep` | Yes | Error:
|
|
2332
|
-
| `sync`, `search`, `doctor`, `config` | Yes | Error:
|
|
2333
|
-
| `attic list/show/restore` | Yes | Error:
|
|
2334
|
-
| All other commands | Yes | Error:
|
|
2370
|
+
| `import <file>` | Yes | Error: “Not a tbd repository” |
|
|
2371
|
+
| `list`, `show`, `stats` | Yes | Error: “Not a tbd repository” |
|
|
2372
|
+
| `create`, `update`, `close`, `reopen` | Yes | Error: “Not a tbd repository” |
|
|
2373
|
+
| `ready`, `blocked`, `stale` | Yes | Error: “Not a tbd repository” |
|
|
2374
|
+
| `label`, `dep` | Yes | Error: “Not a tbd repository” |
|
|
2375
|
+
| `sync`, `search`, `doctor`, `config` | Yes | Error: “Not a tbd repository” |
|
|
2376
|
+
| `attic list/show/restore` | Yes | Error: “Not a tbd repository” |
|
|
2377
|
+
| All other commands | Yes | Error: “Not a tbd repository” |
|
|
2335
2378
|
|
|
2336
2379
|
**`import --from-beads` auto-initialization:**
|
|
2337
2380
|
|
|
@@ -2382,8 +2425,8 @@ repository (which automatically detects and uses the beads prefix).
|
|
|
2382
2425
|
|
|
2383
2426
|
**What it does:**
|
|
2384
2427
|
|
|
2385
|
-
1. Creates `.tbd/` directory with `config.yml` (including display.id_prefix)
|
|
2386
|
-
`.gitignore`
|
|
2428
|
+
1. Creates `.tbd/` directory with `config.yml` (including display.id_prefix),
|
|
2429
|
+
`.gitignore`, and `.gitattributes` (merge=union for ids.yml)
|
|
2387
2430
|
|
|
2388
2431
|
2. Creates `.tbd/docs/` directories for shortcuts, guidelines, templates (gitignored)
|
|
2389
2432
|
|
|
@@ -2403,7 +2446,7 @@ Created sync branch: tbd-sync
|
|
|
2403
2446
|
Pushed sync branch to origin
|
|
2404
2447
|
|
|
2405
2448
|
To complete setup, commit the config files:
|
|
2406
|
-
git add .tbd/config.yml .tbd/.gitignore
|
|
2449
|
+
git add .tbd/config.yml .tbd/.gitignore .tbd/.gitattributes
|
|
2407
2450
|
git commit -m "Initialize tbd"
|
|
2408
2451
|
```
|
|
2409
2452
|
|
|
@@ -3251,7 +3294,7 @@ The doctor command performs comprehensive health checks organized into categorie
|
|
|
3251
3294
|
|
|
3252
3295
|
| Check | Severity | Auto-fixable | Detection |
|
|
3253
3296
|
| --- | --- | --- | --- |
|
|
3254
|
-
| Worktree missing | error | yes | Directory doesn
|
|
3297
|
+
| Worktree missing | error | yes | Directory doesn’t exist |
|
|
3255
3298
|
| Worktree prunable | error | yes | `git worktree list` shows prunable |
|
|
3256
3299
|
| Worktree corrupted | error | yes | Missing `.git` file or invalid gitdir |
|
|
3257
3300
|
|
|
@@ -3259,8 +3302,8 @@ The doctor command performs comprehensive health checks organized into categorie
|
|
|
3259
3302
|
|
|
3260
3303
|
| Check | Severity | Auto-fixable | Detection |
|
|
3261
3304
|
| --- | --- | --- | --- |
|
|
3262
|
-
| Local branch missing | error | yes | `refs/heads/tbd-sync` doesn
|
|
3263
|
-
| Remote branch missing | warning | no | `refs/remotes/origin/tbd-sync` doesn
|
|
3305
|
+
| Local branch missing | error | yes | `refs/heads/tbd-sync` doesn’t exist |
|
|
3306
|
+
| Remote branch missing | warning | no | `refs/remotes/origin/tbd-sync` doesn’t exist |
|
|
3264
3307
|
| Local/remote diverged | warning | no | `git merge-base` != either HEAD |
|
|
3265
3308
|
|
|
3266
3309
|
**3. Sync State Consistency Check**
|
|
@@ -3285,7 +3328,7 @@ Only runs if worktree is healthy:
|
|
|
3285
3328
|
| Check | Severity | Auto-fixable | Detection |
|
|
3286
3329
|
| --- | --- | --- | --- |
|
|
3287
3330
|
| Schema version incompatible | error | no | `meta.yml` version > supported |
|
|
3288
|
-
| Orphaned dependencies | warning | yes | Dependency target doesn
|
|
3331
|
+
| Orphaned dependencies | warning | yes | Dependency target doesn’t exist |
|
|
3289
3332
|
| Duplicate IDs | error | yes | Multiple files with same short ID |
|
|
3290
3333
|
| Invalid references | warning | yes | `parent_id` points to missing issue |
|
|
3291
3334
|
|
|
@@ -3379,8 +3422,6 @@ Available on all commands:
|
|
|
3379
3422
|
--verbose Enable verbose output
|
|
3380
3423
|
--quiet Suppress non-essential output
|
|
3381
3424
|
--debug Show internal IDs alongside public IDs for debugging
|
|
3382
|
-
--non-interactive Disable all prompts, fail if input required
|
|
3383
|
-
--yes Assume yes to confirmation prompts
|
|
3384
3425
|
```
|
|
3385
3426
|
|
|
3386
3427
|
**Color Output:**
|
|
@@ -3420,16 +3461,7 @@ Example: `TBD_ACTOR=claude-agent-1 tbd create "Fix bug"`
|
|
|
3420
3461
|
|
|
3421
3462
|
**Agent/Automation Flags:**
|
|
3422
3463
|
|
|
3423
|
-
These options enable
|
|
3424
|
-
|
|
3425
|
-
- `--non-interactive`: Disables all interactive prompts.
|
|
3426
|
-
Commands that require user input will fail with an error instead of blocking.
|
|
3427
|
-
Automatically enabled when `CI` environment variable is set or when stdin is not a
|
|
3428
|
-
TTY.
|
|
3429
|
-
|
|
3430
|
-
- `--yes`: Automatically answers “yes” to confirmation prompts.
|
|
3431
|
-
Useful for batch operations or scripts.
|
|
3432
|
-
Does not bypass `--non-interactive`—combine both for fully automated execution.
|
|
3464
|
+
These options enable automation in CI/CD pipelines and by AI agents:
|
|
3433
3465
|
|
|
3434
3466
|
- `--dry-run`: Shows what changes would be made without actually making them.
|
|
3435
3467
|
Essential for verifying agent-planned operations before execution.
|
|
@@ -3444,14 +3476,14 @@ These options enable non-interactive use in CI/CD pipelines and by AI agents:
|
|
|
3444
3476
|
Example agent workflow:
|
|
3445
3477
|
|
|
3446
3478
|
```bash
|
|
3447
|
-
# CI pipeline: create issue
|
|
3479
|
+
# CI pipeline: create issue with JSON output
|
|
3448
3480
|
CI=1 tbd create "Deploy failed" --kind bug --priority=P2 --json
|
|
3449
3481
|
|
|
3450
3482
|
# Agent: preview changes before committing
|
|
3451
3483
|
tbd update td-abc1 --status done --dry-run --json
|
|
3452
3484
|
|
|
3453
3485
|
# Batch script: close multiple issues
|
|
3454
|
-
tbd close td-abc1 td-abc2 td-abc3 --
|
|
3486
|
+
tbd close td-abc1 td-abc2 td-abc3 --quiet
|
|
3455
3487
|
```
|
|
3456
3488
|
|
|
3457
3489
|
### 4.11 Attic Commands
|
|
@@ -3972,7 +4004,7 @@ On import:
|
|
|
3972
4004
|
|
|
3973
4005
|
| Beads Status | tbd Behavior | Rationale |
|
|
3974
4006
|
| --- | --- | --- |
|
|
3975
|
-
| `tombstone` (first import) | Skip by default | Don
|
|
4007
|
+
| `tombstone` (first import) | Skip by default | Don’t import deleted issues |
|
|
3976
4008
|
| `tombstone` (re-import) | Set `status: closed`, add label `deleted-in-beads` | Preserve history |
|
|
3977
4009
|
|
|
3978
4010
|
**Options:**
|
|
@@ -4104,7 +4136,7 @@ tbd sync
|
|
|
4104
4136
|
| `bd label add` | `tbd label add` | ✅ Full | Identical |
|
|
4105
4137
|
| `bd label remove` | `tbd label remove` | ✅ Full | Identical |
|
|
4106
4138
|
| `bd label list` | `tbd label list` | ✅ Full | Lists all labels |
|
|
4107
|
-
| `bd dep add` | `tbd dep add` | ✅ Full | Only
|
|
4139
|
+
| `bd dep add` | `tbd dep add` | ✅ Full | Only “blocks” type |
|
|
4108
4140
|
| `bd dep tree` | `tbd dep tree` | 🔄 Future | Visualize dependencies |
|
|
4109
4141
|
| `bd sync` | `tbd sync` | ✅ Full | Different mechanism, same UX |
|
|
4110
4142
|
| `bd stats` | `tbd stats` | ✅ Full | Same statistics |
|
|
@@ -4140,7 +4172,7 @@ tbd sync
|
|
|
4140
4172
|
| `priority` | `priority` | Identical (0-4) |
|
|
4141
4173
|
| `assignee` | `assignee` | Identical |
|
|
4142
4174
|
| `labels` | `labels` | Identical |
|
|
4143
|
-
| `dependencies` | `dependencies` | Only
|
|
4175
|
+
| `dependencies` | `dependencies` | Only “blocks” type currently |
|
|
4144
4176
|
| `created_at` | `created_at` | Identical |
|
|
4145
4177
|
| `updated_at` | `updated_at` | Identical |
|
|
4146
4178
|
| `closed_at` | `closed_at` | Identical |
|
|
@@ -5093,6 +5125,7 @@ repo/
|
|
|
5093
5125
|
│ │ Committed to the repo:
|
|
5094
5126
|
│ ├── config.yml # Project config
|
|
5095
5127
|
│ ├── .gitignore # Controls what's gitignored below
|
|
5128
|
+
│ ├── .gitattributes # Merge strategies (merge=union for ids.yml)
|
|
5096
5129
|
│ ├── workspaces/ # Persistent state (outbox, named workspaces)
|
|
5097
5130
|
│ │
|
|
5098
5131
|
│ │ Gitignored (local only):
|
|
@@ -5119,7 +5152,7 @@ repo/
|
|
|
5119
5152
|
|
|
5120
5153
|
| Location | Files | Size |
|
|
5121
5154
|
| --- | --- | --- |
|
|
5122
|
-
| `.tbd/` |
|
|
5155
|
+
| `.tbd/` | 4 | <1 KB |
|
|
5123
5156
|
| `.tbd/docs/` | ~30 | ~100 KB |
|
|
5124
5157
|
| `.tbd/data-sync/issues/` | 1,000 | ~2 MB |
|
|
5125
5158
|
| `.tbd/data-sync/attic/` | 10-50 | <100 KB |
|
|
@@ -5255,8 +5288,6 @@ This is sufficient for the `ready` command algorithm.
|
|
|
5255
5288
|
| *(n/a)* | `--dry-run` | ✅ tbd | Preview changes |
|
|
5256
5289
|
| *(n/a)* | `--verbose` | ✅ tbd | Debug output |
|
|
5257
5290
|
| *(n/a)* | `--quiet` | ✅ tbd | Minimal output |
|
|
5258
|
-
| *(n/a)* | `--non-interactive` | ✅ tbd | Agent/CI mode |
|
|
5259
|
-
| *(n/a)* | `--yes` | ✅ tbd | Auto-confirm |
|
|
5260
5291
|
| *(n/a)* | `--color <when>` | ✅ tbd | Color control |
|
|
5261
5292
|
|
|
5262
5293
|
### A.3 Data Model Mapping
|
|
@@ -5594,7 +5625,7 @@ See [§2.7.5](#275-comparison-with-beads) for details.
|
|
|
5594
5625
|
| `--no-daemon` | No daemon to disable |
|
|
5595
5626
|
| `--no-auto-flush` | No auto-flush mechanism |
|
|
5596
5627
|
| `--no-auto-import` | Different sync model |
|
|
5597
|
-
| `--sandbox` | tbd is always
|
|
5628
|
+
| `--sandbox` | tbd is always “sandbox safe” |
|
|
5598
5629
|
| `--allow-stale` | Different staleness model |
|
|
5599
5630
|
|
|
5600
5631
|
### B.11 Issue Types/Statuses Not Supported
|
|
@@ -5707,6 +5738,15 @@ never conflicts with existing ones.
|
|
|
5707
5738
|
Concurrent imports with the same source would produce identical mappings (idempotent).
|
|
5708
5739
|
Concurrent imports from different sources have distinct short IDs (no conflict).
|
|
5709
5740
|
|
|
5741
|
+
**Additional protection** (added in response to
|
|
5742
|
+
[#99](https://github.com/jlevy/tbd/issues/99)): `.tbd/.gitattributes` configures
|
|
5743
|
+
`merge=union` for all `ids.yml` files, preventing git from deleting rows during merge.
|
|
5744
|
+
The sync code also includes `reconcileMappings()` which detects missing mappings after
|
|
5745
|
+
merge and recovers original short IDs from git history before falling back to new random
|
|
5746
|
+
IDs. The `doctor --fix` command provides a manual recovery path.
|
|
5747
|
+
Together these provide three layers of defense: prevention (`.gitattributes`), detection
|
|
5748
|
+
and recovery (reconcileMappings), and manual repair (doctor).
|
|
5749
|
+
|
|
5710
5750
|
### 8.4 ID Length
|
|
5711
5751
|
|
|
5712
5752
|
**RESOLVED**: Adopted ULID-based internal IDs.
|
package/dist/docs/tbd-docs.md
CHANGED
|
@@ -729,8 +729,6 @@ tbd list --verbose # Enable verbose output
|
|
|
729
729
|
tbd create "Test" --dry-run # Show what would happen
|
|
730
730
|
tbd close proj-a7k2 --no-sync # Skip automatic sync
|
|
731
731
|
tbd list --debug # Show internal IDs
|
|
732
|
-
tbd update proj-a7k2 --yes # Assume yes to prompts
|
|
733
|
-
tbd list --non-interactive # Fail if input required
|
|
734
732
|
tbd list --color=never # Disable colors
|
|
735
733
|
```
|
|
736
734
|
|
|
@@ -741,8 +739,6 @@ Options:
|
|
|
741
739
|
- `--quiet` - Suppress non-essential output
|
|
742
740
|
- `--json` - Output as JSON
|
|
743
741
|
- `--color <when>` - Colorize output: auto, always, never
|
|
744
|
-
- `--non-interactive` - Disable all prompts, fail if input required
|
|
745
|
-
- `--yes` - Assume yes to confirmation prompts
|
|
746
742
|
- `--no-sync` - Skip automatic sync after write operations
|
|
747
743
|
- `--debug` - Show internal IDs alongside display IDs
|
|
748
744
|
|
|
@@ -766,8 +762,6 @@ tbd sync # Push changes
|
|
|
766
762
|
| Flag | Purpose |
|
|
767
763
|
| --- | --- |
|
|
768
764
|
| `--json` | Machine-parseable output |
|
|
769
|
-
| `--non-interactive` | Fail if input required (auto-enabled in CI) |
|
|
770
|
-
| `--yes` | Auto-confirm prompts |
|
|
771
765
|
| `--dry-run` | Preview changes before applying |
|
|
772
766
|
| `--quiet` | Suppress informational output |
|
|
773
767
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as hasShortId, c as parseIdMappingFromYaml, d as saveIdMapping, i as generateUniqueShortId, l as reconcileMappings, n as calculateOptimalLength, o as loadIdMapping, r as createShortIdMapping, s as mergeIdMappings, t as addIdMapping, u as resolveToInternalId } from "./id-mapping-JGow6Jk4.mjs";
|
|
2
|
+
|
|
3
|
+
export { loadIdMapping, parseIdMappingFromYaml, reconcileMappings, saveIdMapping };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { T as IdMappingYamlSchema, a as stringifyYaml, n as parseYamlToleratingDuplicateKeys } from "./yaml-utils-
|
|
1
|
+
import { T as IdMappingYamlSchema, a as stringifyYaml, n as parseYamlToleratingDuplicateKeys } from "./yaml-utils-U7l9hhkh.mjs";
|
|
2
2
|
import { mkdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { writeFile } from "atomically";
|
|
@@ -374,6 +374,22 @@ function hasShortId(mapping, shortId) {
|
|
|
374
374
|
return mapping.shortToUlid.has(shortId);
|
|
375
375
|
}
|
|
376
376
|
/**
|
|
377
|
+
* Create a short ID mapping for a new internal ID.
|
|
378
|
+
* Generates a unique short ID and registers it in the mapping.
|
|
379
|
+
*
|
|
380
|
+
* @param internalId - The internal ID (is-{ulid})
|
|
381
|
+
* @param mapping - The ID mapping to update
|
|
382
|
+
* @returns The generated short ID
|
|
383
|
+
*/
|
|
384
|
+
function createShortIdMapping(internalId, mapping) {
|
|
385
|
+
const ulid = extractUlidFromInternalId(internalId);
|
|
386
|
+
const existing = mapping.ulidToShort.get(ulid);
|
|
387
|
+
if (existing) return existing;
|
|
388
|
+
const shortId = generateUniqueShortId(mapping);
|
|
389
|
+
addIdMapping(mapping, ulid, shortId);
|
|
390
|
+
return shortId;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
377
393
|
* Resolve any ID input to an internal ID ({prefix}-{ulid}).
|
|
378
394
|
*
|
|
379
395
|
* Handles:
|
|
@@ -420,6 +436,44 @@ function parseIdMappingFromYaml(content) {
|
|
|
420
436
|
};
|
|
421
437
|
}
|
|
422
438
|
/**
|
|
439
|
+
* Ensure all given internal IDs have short ID mappings.
|
|
440
|
+
* Creates missing mappings for any IDs without entries.
|
|
441
|
+
*
|
|
442
|
+
* This repairs state after git merges that may add issue files
|
|
443
|
+
* without corresponding mapping entries (e.g., when outbox issues
|
|
444
|
+
* are merged from a feature branch but ids.yml doesn't include them).
|
|
445
|
+
*
|
|
446
|
+
* When a `historicalMapping` is provided, the function will try to recover
|
|
447
|
+
* the original short ID from that mapping before generating a new random one.
|
|
448
|
+
* This preserves ID stability so that existing references (in docs, PRs,
|
|
449
|
+
* conversations) remain valid.
|
|
450
|
+
*
|
|
451
|
+
* @param internalIds - Array of internal IDs (is-{ulid}) to reconcile
|
|
452
|
+
* @param mapping - The ID mapping to update (mutated in-place)
|
|
453
|
+
* @param historicalMapping - Optional mapping from prior state (e.g., git history) to recover original short IDs
|
|
454
|
+
* @returns Object with `created` (IDs that got new random short IDs) and `recovered` (IDs restored from history)
|
|
455
|
+
*/
|
|
456
|
+
function reconcileMappings(internalIds, mapping, historicalMapping) {
|
|
457
|
+
const created = [];
|
|
458
|
+
const recovered = [];
|
|
459
|
+
for (const id of internalIds) {
|
|
460
|
+
const ulid = extractUlidFromInternalId(id);
|
|
461
|
+
if (mapping.ulidToShort.has(ulid)) continue;
|
|
462
|
+
const historicalShortId = historicalMapping?.ulidToShort.get(ulid);
|
|
463
|
+
if (historicalShortId && !mapping.shortToUlid.has(historicalShortId)) {
|
|
464
|
+
addIdMapping(mapping, ulid, historicalShortId);
|
|
465
|
+
recovered.push(id);
|
|
466
|
+
} else {
|
|
467
|
+
createShortIdMapping(id, mapping);
|
|
468
|
+
created.push(id);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return {
|
|
472
|
+
created,
|
|
473
|
+
recovered
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
423
477
|
* Merge two ID mappings by combining all entries from both.
|
|
424
478
|
* ID mappings are always additive (new IDs are only added, never removed),
|
|
425
479
|
* so merging simply unions all key-value pairs.
|
|
@@ -448,5 +502,5 @@ function mergeIdMappings(local, remote) {
|
|
|
448
502
|
}
|
|
449
503
|
|
|
450
504
|
//#endregion
|
|
451
|
-
export {
|
|
452
|
-
//# sourceMappingURL=id-mapping-
|
|
505
|
+
export { generateInternalId as _, hasShortId as a, validateIssueId as b, parseIdMappingFromYaml as c, saveIdMapping as d, extractPrefix as f, formatDisplayId as g, formatDebugId as h, generateUniqueShortId as i, reconcileMappings as l, extractUlidFromInternalId as m, calculateOptimalLength as n, loadIdMapping as o, extractShortId as p, createShortIdMapping as r, mergeIdMappings as s, addIdMapping as t, resolveToInternalId as u, makeInternalId as v, normalizeIssueId as y };
|
|
506
|
+
//# sourceMappingURL=id-mapping-JGow6Jk4.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"id-mapping-CD5c_ZVA.mjs","names":[],"sources":["../src/lib/ids.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 * 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 { parseYamlToleratingDuplicateKeys, stringifyYaml } from '../utils/yaml-utils.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.\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 // Convert Map to sorted object for deterministic output\n // Use natural sort so \"1\", \"2\", \"10\" sorts correctly (not \"1\", \"10\", \"2\")\n const data: Record<string, string> = {};\n const sortedKeys = naturalSort(Array.from(mapping.shortToUlid.keys()));\n for (const key of sortedKeys) {\n data[key] = mapping.shortToUlid.get(key)!;\n }\n\n const content = stringifyYaml(data);\n await writeFile(filePath, content);\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 * 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"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"id-mapping-JGow6Jk4.mjs","names":[],"sources":["../src/lib/ids.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 * 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 { parseYamlToleratingDuplicateKeys, stringifyYaml } from '../utils/yaml-utils.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.\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 // Convert Map to sorted object for deterministic output\n // Use natural sort so \"1\", \"2\", \"10\" sorts correctly (not \"1\", \"10\", \"2\")\n const data: Record<string, string> = {};\n const sortedKeys = naturalSort(Array.from(mapping.shortToUlid.keys()));\n for (const key of sortedKeys) {\n data[key] = mapping.shortToUlid.get(key)!;\n }\n\n const content = stringifyYaml(data);\n await writeFile(filePath, content);\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"],"mappings":";;;;;;;;;;;;;;;;;;;AAkBA,MAAM,OAAO,kBAAkB;;;;;AAwC/B,SAAgB,aAAa,IAA6B;AACxD,QAAO;;;;;;AAeT,MAAa,qBAAqB;;;;AAKlC,MAAa,4BAA4B;;;;;;;AAQzC,SAAgB,eAAe,WAAoC;AACjE,QAAO,GAAG,mBAAmB,GAAG,UAAU,aAAa;;;;;;;;;;;;AAazD,SAAgB,qBAAsC;AACpD,QAAO,eAAe,MAAM,CAAC;;;;;;;;;AAU/B,SAAgB,gBAAgB,SAAS,GAAW;CAClD,MAAM,QAAQ;CACd,IAAI,SAAS;CACb,MAAM,QAAQ,YAAY,OAAO;AACjC,MAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,IAC1B,WAAU,MAAM,MAAM,KAAM;AAE9B,QAAO;;AAIT,MAAM,sBAAsB,IAAI,OAAO,IAAI,mBAAmB,gBAAgB;AAG9E,MAAM,qBAAqB,4BAA4B;;;;;AAMvD,SAAgB,gBAAgB,IAAqB;AACnD,QAAO,oBAAoB,KAAK,GAAG;;;;;AAcrC,SAAgB,aAAa,OAAwB;CACnD,MAAM,QAAQ,MAAM,aAAa;CAEjC,MAAM,mBAAmB,GAAG,mBAAmB;AAC/C,KAAI,MAAM,WAAW,iBAAiB,IAAI,MAAM,WAAW,mBACzD,QAAO,oBAAoB,KAAK,MAAM;AAExC,QAAO;;;;;;;;;;AAwBT,SAAgB,eAAe,YAA4B;AACzD,QAAO,WAAW,aAAa,CAAC,QAAQ,YAAY,GAAG;;;;;;;;;;;;AAazD,SAAgB,cAAc,YAAmC;AAE/D,QADc,gBAAgB,KAAK,WAAW,GAC/B,IAAI,aAAa,IAAI;;;;;;;;;;;;;;;AAgBtC,SAAgB,0BAA0B,YAA4B;AAEpE,QAAO,WAAW,aAAa,CAAC,QAAQ,YAAY,GAAG;;;AAIzD,MAAM,sBAAsB;;;;;;;;;;;;;AAc5B,SAAgB,iBAAiB,OAAuB;CACtD,MAAM,QAAQ,MAAM,aAAa;CACjC,MAAM,2BAA2B,GAAG,mBAAmB;CACvD,MAAM,wBAAwB,GAAG,oBAAoB;AAGrD,KAAI,gBAAgB,MAAM,CACxB,QAAO;AAIT,KAAI,MAAM,WAAW,yBAAyB,CAC5C,QAAO;AAIT,KAAI,MAAM,WAAW,sBAAsB,EAAE;EAC3C,MAAM,OAAO,MAAM,MAAM,sBAAsB,OAAO;AACtD,MAAI,KAAK,WAAW,GAClB,QAAO,eAAe,KAAK;AAG7B,SAAO;;AAIT,KAAI,MAAM,WAAW,MAAM,iBAAiB,KAAK,MAAM,CACrD,QAAO,eAAe,MAAM;AAI9B,QAAO;;;;;;;;;;;;;;;;AAmBT,SAAgB,gBACd,YACA,SACA,SAAS,OACO;CAEhB,MAAM,WAAW,0BAA0B,WAAW;CAGtD,MAAM,UAAU,QAAQ,YAAY,IAAI,SAAS;AACjD,KAAI,CAAC,QACH,OAAM,IAAI,MACR,8CAA8C,WAAW,4DAE1D;AAGH,QAAO,GAAG,OAAO,GAAG;;;;;;;;;AAUtB,SAAgB,cACd,YACA,SACA,SAAS,OACD;AAER,QAAO,GADW,gBAAgB,YAAY,SAAS,OAAO,CAC1C,IAAI,WAAW;;;;;;;;;;;;;;;;;;;;;ACxSrC,SAAS,gBAAgB,KAAkC;CACzD,MAAM,SAA8B,EAAE;CACtC,IAAI,UAAU;CACd,IAAI,mBAAmC;AAEvC,MAAK,MAAM,QAAQ,KAAK;EACtB,MAAM,UAAU,QAAQ,OAAO,QAAQ;AAEvC,MAAI,qBAAqB,MAAM;AAE7B,sBAAmB;AACnB,aAAU;aACD,YAAY,iBAErB,YAAW;OACN;AAEL,UAAO,KAAK,CAAC,kBAAkB,QAAQ,CAAC;AACxC,sBAAmB;AACnB,aAAU;;;AAKd,KAAI,QACF,QAAO,KAAK,CAAC,kBAAmB,QAAQ,CAAC;AAG3C,QAAO;;;;;;;;;;;;;;AAeT,SAAgB,eAAe,GAAW,GAAmB;AAE3D,KAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,KAAI,CAAC,EAAG,QAAO;AACf,KAAI,CAAC,EAAG,QAAO;CAEf,MAAM,UAAU,gBAAgB,EAAE;CAClC,MAAM,UAAU,gBAAgB,EAAE;CAElC,MAAM,SAAS,KAAK,IAAI,QAAQ,QAAQ,QAAQ,OAAO;AAEvD,MAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,KAAK;EAC/B,MAAM,CAAC,YAAY,UAAU,QAAQ;EACrC,MAAM,CAAC,YAAY,UAAU,QAAQ;AAErC,MAAI,cAAc,YAAY;GAE5B,MAAM,OAAO,SAAS,QAAQ,GAAG;GACjC,MAAM,OAAO,SAAS,QAAQ,GAAG;AACjC,OAAI,SAAS,KACX,QAAO,OAAO;AAIhB,OAAI,OAAO,WAAW,OAAO,OAC3B,QAAO,OAAO,SAAS,OAAO;aAEvB,CAAC,cAAc,CAAC,YAAY;GAErC,MAAM,SAAS,OAAO,aAAa;GACnC,MAAM,SAAS,OAAO,aAAa;AACnC,OAAI,WAAW,OACb,QAAO,OAAO,cAAc,OAAO;QAOrC,QAAO,aAAa,KAAK;;AAK7B,QAAO,QAAQ,SAAS,QAAQ;;;;;;;;AASlC,SAAgB,YAAY,KAAkC;AAC5D,QAAO,CAAC,GAAG,IAAI,CAAC,KAAK,eAAe;;;;;;;;;;;;;;;;ACzEtC,SAAS,eAAe,SAAyB;AAC/C,QAAO,KAAK,SAAS,YAAY,UAAU;;;;;;AAO7C,eAAsB,cAAc,SAAqC;CACvE,MAAM,WAAW,eAAe,QAAQ;CAExC,IAAI;AACJ,KAAI;AACF,YAAU,MAAM,SAAS,UAAU,QAAQ;SACrC;AAEN,SAAO;GACL,6BAAa,IAAI,KAAK;GACtB,6BAAa,IAAI,KAAK;GACvB;;CAMH,MAAM,EAAE,MAAM,SAAS,kBAAkB,iCACvC,SACA,SACD;CACD,MAAM,OAAO,WAAW,EAAE;AAE1B,KAAI,cAAc,SAAS,EACzB,SAAQ,KACN,YAAY,SAAS,YAAY,cAAc,OAAO,qBAAqB,cAAc,KAAK,KAAK,CAAC,yGAGrG;CAIH,MAAM,cAAc,oBAAoB,UAAU,KAAK;AACvD,KAAI,CAAC,YAAY,QACf,OAAM,IAAI,MAAM,gCAAgC,SAAS,IAAI,YAAY,MAAM,UAAU;CAE3F,MAAM,YAAY,YAAY;CAE9B,MAAM,8BAAc,IAAI,KAAqB;CAC7C,MAAM,8BAAc,IAAI,KAAqB;AAE7C,MAAK,MAAM,CAAC,SAAS,SAAS,OAAO,QAAQ,UAAU,EAAE;AACvD,cAAY,IAAI,SAAS,KAAK;AAC9B,cAAY,IAAI,MAAM,QAAQ;;AAGhC,QAAO;EAAE;EAAa;EAAa;;;;;AAMrC,eAAsB,cAAc,SAAiB,SAAmC;CACtF,MAAM,WAAW,eAAe,QAAQ;AAGxC,OAAM,MAAM,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;CAInD,MAAM,OAA+B,EAAE;CACvC,MAAM,aAAa,YAAY,MAAM,KAAK,QAAQ,YAAY,MAAM,CAAC,CAAC;AACtE,MAAK,MAAM,OAAO,WAChB,MAAK,OAAO,QAAQ,YAAY,IAAI,IAAI;AAI1C,OAAM,UAAU,UADA,cAAc,KAAK,CACD;;;;;;;;;;AAWpC,SAAgB,uBAAuB,eAA+B;AACpE,QAAO,gBAAgB,MAAS,IAAI;;;;;;;;;;;AAYtC,SAAgB,sBAAsB,SAA4B;CAChE,MAAM,sBAAsB;CAC5B,MAAM,gBAAgB,QAAQ,YAAY;CAC1C,MAAM,gBAAgB,uBAAuB,cAAc;AAG3D,MAAK,MAAM,UAAU,CAAC,eAAe,gBAAgB,EAAE,CACrD,MAAK,IAAI,UAAU,GAAG,UAAU,qBAAqB,WAAW;EAC9D,MAAM,UAAU,gBAAgB,OAAO;AACvC,MAAI,CAAC,QAAQ,YAAY,IAAI,QAAQ,CACnC,QAAO;;AAKb,OAAM,IAAI,MACR,6DAA6D,cAAc,qFAE5E;;;;;;;AAQH,SAAgB,aAAa,SAAoB,MAAc,SAAuB;AACpF,SAAQ,YAAY,IAAI,SAAS,KAAK;AACtC,SAAQ,YAAY,IAAI,MAAM,QAAQ;;;;;AAwBxC,SAAgB,WAAW,SAAoB,SAA0B;AACvE,QAAO,QAAQ,YAAY,IAAI,QAAQ;;;;;;;;;;AAWzC,SAAgB,qBAAqB,YAAoB,SAA4B;CAEnF,MAAM,OAAO,0BAA0B,WAAW;CAGlD,MAAM,WAAW,QAAQ,YAAY,IAAI,KAAK;AAC9C,KAAI,SACF,QAAO;CAIT,MAAM,UAAU,sBAAsB,QAAQ;AAG9C,cAAa,SAAS,MAAM,QAAQ;AAEpC,QAAO;;;;;;;;;;;;;;;AAgBT,SAAgB,oBAAoB,OAAe,SAAqC;CACtF,MAAM,QAAQ,MAAM,aAAa;AAGjC,KAAI,aAAa,MAAM,CACrB,QAAO,aAAa,MAAM;CAI5B,MAAM,UAAU,eAAe,MAAM;AAGrC,KAAI,QAAQ,WAAW,MAAM,iBAAiB,KAAK,QAAQ,CACzD,QAAO,eAAe,QAAQ;CAIhC,MAAM,OAAO,QAAQ,YAAY,IAAI,QAAQ;AAC7C,KAAI,CAAC,KACH,OAAM,IAAI,MAAM,qBAAqB,MAAM,cAAmB,QAAQ,yBAAyB;AAGjG,QAAO,eAAe,KAAK;;;;;;;;AAS7B,SAAgB,uBAAuB,SAA4B;CAEjE,MAAM,EAAE,MAAM,SAAS,kBAAkB,iCAA0C,QAAQ;CAC3F,MAAM,OAAO,WAAW,EAAE;AAE1B,KAAI,cAAc,SAAS,EACzB,SAAQ,KACN,qCAAqC,cAAc,OAAO,qBAAqB,cAAc,KAAK,KAAK,CAAC,qCAEzG;CAIH,MAAM,cAAc,oBAAoB,UAAU,KAAK;AACvD,KAAI,CAAC,YAAY,QACf,OAAM,IAAI,MAAM,8BAA8B,YAAY,MAAM,UAAU;CAE5E,MAAM,YAAY,YAAY;CAE9B,MAAM,8BAAc,IAAI,KAAqB;CAC7C,MAAM,8BAAc,IAAI,KAAqB;AAE7C,MAAK,MAAM,CAAC,SAAS,SAAS,OAAO,QAAQ,UAAU,EAAE;AACvD,cAAY,IAAI,SAAS,KAAK;AAC9B,cAAY,IAAI,MAAM,QAAQ;;AAGhC,QAAO;EAAE;EAAa;EAAa;;;;;;;;;;;;;;;;;;;;AAqBrC,SAAgB,kBACd,aACA,SACA,mBAC4C;CAC5C,MAAM,UAAoB,EAAE;CAC5B,MAAM,YAAsB,EAAE;AAE9B,MAAK,MAAM,MAAM,aAAa;EAC5B,MAAM,OAAO,0BAA0B,GAAG;AAC1C,MAAI,QAAQ,YAAY,IAAI,KAAK,CAC/B;EAIF,MAAM,oBAAoB,mBAAmB,YAAY,IAAI,KAAK;AAClE,MAAI,qBAAqB,CAAC,QAAQ,YAAY,IAAI,kBAAkB,EAAE;AAEpE,gBAAa,SAAS,MAAM,kBAAkB;AAC9C,aAAU,KAAK,GAAG;SACb;AAEL,wBAAqB,IAAI,QAAQ;AACjC,WAAQ,KAAK,GAAG;;;AAIpB,QAAO;EAAE;EAAS;EAAW;;;;;;;;;;;;;;AAe/B,SAAgB,gBAAgB,OAAkB,QAA8B;CAC9E,MAAM,SAAoB;EACxB,aAAa,IAAI,IAAI,MAAM,YAAY;EACvC,aAAa,IAAI,IAAI,MAAM,YAAY;EACxC;AAGD,MAAK,MAAM,CAAC,SAAS,SAAS,OAAO,YACnC,KAAI,CAAC,OAAO,YAAY,IAAI,QAAQ,EAAE;AACpC,SAAO,YAAY,IAAI,SAAS,KAAK;AACrC,SAAO,YAAY,IAAI,MAAM,QAAQ;;AAOzC,MAAK,MAAM,CAAC,MAAM,YAAY,OAAO,YACnC,KAAI,CAAC,OAAO,YAAY,IAAI,KAAK,IAAI,CAAC,OAAO,YAAY,IAAI,QAAQ,EAAE;AACrE,SAAO,YAAY,IAAI,SAAS,KAAK;AACrC,SAAO,YAAY,IAAI,MAAM,QAAQ;;AAIzC,QAAO"}
|
package/dist/index.d.mts
CHANGED
|
@@ -106,6 +106,12 @@ declare const Dependency: z.ZodObject<{
|
|
|
106
106
|
*
|
|
107
107
|
* Note: Fields use .nullable() in addition to .optional() because
|
|
108
108
|
* YAML parses `field: null` as JavaScript null, not undefined.
|
|
109
|
+
*
|
|
110
|
+
* Design note: We could add the short ID to this schema. We didn't originally
|
|
111
|
+
* because it's one more field to maintain consistency around across files.
|
|
112
|
+
* Having it here might make recovery of lost ID mappings far easier, but for
|
|
113
|
+
* now we have more reliable management of the mappings file (ids.yml) and
|
|
114
|
+
* consider it authoritative. See IdMappingYamlSchema (§2.6.8).
|
|
109
115
|
*/
|
|
110
116
|
declare const IssueSchema: z.ZodObject<{
|
|
111
117
|
id: z.ZodString;
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as LOCAL_STATE_FIELD_ORDER, C as GitRemoteName, D as IssueKind, E as IssueId, F as ShortId, I as Timestamp, L as Ulid, M as META_FIELD_ORDER, N as MetaSchema, O as IssueSchema, P as Priority, R as Version, S as GitBranchName, T as IdMappingYamlSchema, _ as DependencyRelationType, b as EntityType, d as ATTIC_ENTRY_FIELD_ORDER, f as AtticEntrySchema, g as Dependency, h as ConfigSchema, j as LocalStateSchema, k as IssueStatus, m as CONFIG_FIELD_ORDER, p as BaseEntity, v as DocCacheConfigSchema, w as ISSUE_FIELD_ORDER, x as ExternalIssueIdInput, y as DocsCacheSchema } from "./yaml-utils-
|
|
2
|
-
import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-
|
|
1
|
+
import { A as LOCAL_STATE_FIELD_ORDER, C as GitRemoteName, D as IssueKind, E as IssueId, F as ShortId, I as Timestamp, L as Ulid, M as META_FIELD_ORDER, N as MetaSchema, O as IssueSchema, P as Priority, R as Version, S as GitBranchName, T as IdMappingYamlSchema, _ as DependencyRelationType, b as EntityType, d as ATTIC_ENTRY_FIELD_ORDER, f as AtticEntrySchema, g as Dependency, h as ConfigSchema, j as LocalStateSchema, k as IssueStatus, m as CONFIG_FIELD_ORDER, p as BaseEntity, v as DocCacheConfigSchema, w as ISSUE_FIELD_ORDER, x as ExternalIssueIdInput, y as DocsCacheSchema } from "./yaml-utils-U7l9hhkh.mjs";
|
|
2
|
+
import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-7qUDeWJf.mjs";
|
|
3
3
|
|
|
4
4
|
export { ATTIC_ENTRY_FIELD_ORDER, AtticEntrySchema, BaseEntity, CONFIG_FIELD_ORDER, ConfigSchema, Dependency, DependencyRelationType, DocCacheConfigSchema, DocsCacheSchema, EntityType, ExternalIssueIdInput, GitBranchName, GitRemoteName, ISSUE_FIELD_ORDER, IdMappingYamlSchema, IssueId, IssueKind, IssueSchema, IssueStatus, LOCAL_STATE_FIELD_ORDER, LocalStateSchema, META_FIELD_ORDER, MetaSchema, Priority, ShortId, Timestamp, Ulid, VERSION, Version, noopLogger, parseIssue, serializeIssue };
|