get-tbd 0.1.26 → 0.1.28
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/bin.mjs +258 -132
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +154 -49
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-b20Kf5pW.mjs → config-B38rbI9u.mjs} +2 -2
- package/dist/{config-b20Kf5pW.mjs.map → config-B38rbI9u.mjs.map} +1 -1
- package/dist/{config-BZte2m3w.mjs → config-C0ITTrtc.mjs} +1 -1
- package/dist/docs/guidelines/bun-monorepo-patterns.md +328 -58
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +399 -64
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +39 -8
- package/dist/docs/guidelines/typescript-code-coverage.md +27 -3
- package/dist/docs/guidelines/typescript-rules.md +18 -0
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +12 -0
- package/dist/docs/tbd-design.md +16 -1
- package/dist/docs/tbd-docs.md +11 -2
- package/dist/{id-mapping-BtBwq5nG.mjs → id-mapping-CqrrLgeX.mjs} +2 -2
- package/dist/{id-mapping-BtBwq5nG.mjs.map → id-mapping-CqrrLgeX.mjs.map} +1 -1
- package/dist/{id-mapping-BA_xn516.mjs → id-mapping-Ctfl_nc1.mjs} +1 -1
- package/dist/index.d.mts +13 -1
- package/dist/index.mjs +3 -3
- package/dist/{schemas-BQYmDnkv.mjs → schemas-C8mOQykE.mjs} +17 -5
- package/dist/schemas-C8mOQykE.mjs.map +1 -0
- package/dist/{src-DQcOQnFp.mjs → src-D2xEmH4L.mjs} +3 -3
- package/dist/{src-DQcOQnFp.mjs.map → src-D2xEmH4L.mjs.map} +1 -1
- package/dist/tbd +258 -132
- package/package.json +2 -2
- package/dist/schemas-BQYmDnkv.mjs.map +0 -1
|
@@ -5,6 +5,20 @@ author: Joshua Levy (github.com/jlevy) with LLM assistance
|
|
|
5
5
|
---
|
|
6
6
|
# CLI Tool Development Rules
|
|
7
7
|
|
|
8
|
+
**Last Updated**: 2026-05-21
|
|
9
|
+
|
|
10
|
+
**Tracks**: Commander.js `^15.0.0` (ESM-only, requires Node v22.12.0+),
|
|
11
|
+
picocolors `^1.1.1` (stable; no recent changes), Node.js 24 LTS / Node 26 Current.
|
|
12
|
+
Commander 14 moves to security-only maintenance until May 2027.
|
|
13
|
+
|
|
14
|
+
**Related**:
|
|
15
|
+
|
|
16
|
+
- [TypeScript Rules](./typescript-rules.md)
|
|
17
|
+
- [Supply-Chain Mitigation](./pnpm-monorepo-patterns.md#supply-chain-mitigation) —
|
|
18
|
+
follow the 14-day package-age rule for every CLI dependency. Bundlers and
|
|
19
|
+
CLI dependencies that execute at install time (`postinstall` scripts) are a
|
|
20
|
+
primary attack surface.
|
|
21
|
+
|
|
8
22
|
These rules apply to all CLI tools, command-line scripts, and terminal utilities.
|
|
9
23
|
Examples may be inspired by modern TypeScript repos, but guidance here is intentionally
|
|
10
24
|
generic and reusable across projects.
|
|
@@ -93,7 +107,9 @@ generic and reusable across projects.
|
|
|
93
107
|
## Commander.js Patterns
|
|
94
108
|
|
|
95
109
|
- **Use Commander.js for all CLI tools:** Import from `commander` and follow established
|
|
96
|
-
patterns for command registration and option handling.
|
|
110
|
+
patterns for command registration and option handling. **Target Commander 15+
|
|
111
|
+
(ESM-only, requires Node v22.12.0+).** Commander 14 is security-maintenance
|
|
112
|
+
only until May 2027; do not start new projects on it.
|
|
97
113
|
|
|
98
114
|
- **Apply colored help globally, not per-command:** Use Commander v14+ `configureHelp()`
|
|
99
115
|
with style functions, applied recursively to all commands at program initialization.
|
|
@@ -549,15 +565,30 @@ When supporting environment variables, especially those used by SDK libraries (l
|
|
|
549
565
|
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.), also support `.env` loading so CLIs work
|
|
550
566
|
seamlessly in local dev and in remote environments.
|
|
551
567
|
|
|
552
|
-
- **
|
|
553
|
-
|
|
568
|
+
- **Prefer Node.js native `--env-file` for Node ≥20.6**: Production-ready on
|
|
569
|
+
Node 24 LTS. Pass `--env-file=.env.local --env-file=.env` on the CLI
|
|
570
|
+
invocation; later files do not override earlier ones, so list higher-priority
|
|
571
|
+
first. No dependency required:
|
|
572
|
+
|
|
573
|
+
```bash
|
|
574
|
+
node --env-file=.env.local --env-file=.env ./dist/bin.js
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Make this the default for new projects. Reserve `dotenv` for advanced needs
|
|
578
|
+
(variable expansion, multiline values, custom precedence logic, or runtime
|
|
579
|
+
conditional loading from inside the CLI).
|
|
580
|
+
|
|
581
|
+
- **Use `dotenv` only when needed:** Add `dotenv` only if your CLI must load
|
|
582
|
+
env files programmatically — e.g., it reads them after parsing command-line
|
|
583
|
+
flags, performs variable expansion, or supports custom file paths the user
|
|
584
|
+
cannot pre-bake into the `node` invocation.
|
|
554
585
|
|
|
555
|
-
- **Load `.env.local` and `.env` automatically (recommended):**
|
|
556
|
-
|
|
557
|
-
`.env
|
|
586
|
+
- **Load `.env.local` and `.env` automatically (recommended):** Whichever
|
|
587
|
+
mechanism you use, support both `.env.local` (higher priority, gitignored)
|
|
588
|
+
and `.env` (lower priority, committed defaults only).
|
|
558
589
|
|
|
559
|
-
- **Manual dotenv loading:**
|
|
560
|
-
|
|
590
|
+
- **Manual `dotenv` loading:** When you do need `dotenv`, load with explicit
|
|
591
|
+
precedence:
|
|
561
592
|
|
|
562
593
|
```ts
|
|
563
594
|
import dotenv from 'dotenv';
|
|
@@ -5,6 +5,18 @@ author: Joshua Levy (github.com/jlevy) with LLM assistance
|
|
|
5
5
|
---
|
|
6
6
|
# Code Coverage Best Practices for TypeScript with Vitest
|
|
7
7
|
|
|
8
|
+
**Last Updated**: 2026-05-21
|
|
9
|
+
|
|
10
|
+
**Tracks**: Vitest `^4.1.7`, `@vitest/coverage-v8` `^4.1.7`. Vitest 5.0 is in
|
|
11
|
+
beta — do not adopt yet.
|
|
12
|
+
|
|
13
|
+
**Related**:
|
|
14
|
+
|
|
15
|
+
- [Companion: pnpm Monorepo Patterns — Testing](./pnpm-monorepo-patterns.md#8-testing)
|
|
16
|
+
- [Supply-Chain Mitigation](./pnpm-monorepo-patterns.md#supply-chain-mitigation) —
|
|
17
|
+
follow the 14-day package-age rule when installing or upgrading `vitest` and
|
|
18
|
+
`@vitest/coverage-v8`.
|
|
19
|
+
|
|
8
20
|
## Coverage Metrics
|
|
9
21
|
|
|
10
22
|
### Essential Metrics
|
|
@@ -70,9 +82,21 @@ Low branch coverage often indicates untested error paths and edge cases.
|
|
|
70
82
|
### Installation
|
|
71
83
|
|
|
72
84
|
```bash
|
|
73
|
-
|
|
85
|
+
# Pin to a specific minor to stay on a stable line; align both packages.
|
|
86
|
+
pnpm add -D vitest@^4.1 @vitest/coverage-v8@^4.1
|
|
74
87
|
```
|
|
75
88
|
|
|
89
|
+
Follow the [14-day package-age rule](./pnpm-monorepo-patterns.md#supply-chain-mitigation)
|
|
90
|
+
on every upgrade: use `ncu --cooldown 14` or
|
|
91
|
+
`pnpm install --frozen-lockfile`.
|
|
92
|
+
|
|
93
|
+
### Vitest 4.x changes that affect coverage
|
|
94
|
+
|
|
95
|
+
- **`coverage.all` was removed** in Vitest 4. Use `coverage.include` and
|
|
96
|
+
`coverage.exclude` to define exactly which files are reported.
|
|
97
|
+
- Coverage reporters and v8 provider now ship as part of `@vitest/coverage-v8`
|
|
98
|
+
aligned with the Vitest major version — pin them together.
|
|
99
|
+
|
|
76
100
|
### Example Configuration
|
|
77
101
|
|
|
78
102
|
```typescript
|
|
@@ -131,10 +155,10 @@ export default defineConfig({
|
|
|
131
155
|
|
|
132
156
|
```yaml
|
|
133
157
|
- name: Run tests with coverage
|
|
134
|
-
run:
|
|
158
|
+
run: pnpm run test:coverage
|
|
135
159
|
|
|
136
160
|
- name: Upload coverage to Codecov
|
|
137
|
-
uses: codecov/codecov-action@
|
|
161
|
+
uses: codecov/codecov-action@v5
|
|
138
162
|
with:
|
|
139
163
|
files: ./coverage/lcov.info
|
|
140
164
|
fail_ci_if_error: true
|
|
@@ -7,6 +7,24 @@ alwaysApply: true
|
|
|
7
7
|
---
|
|
8
8
|
# TypeScript Rules
|
|
9
9
|
|
|
10
|
+
**Last Updated**: 2026-05-21
|
|
11
|
+
|
|
12
|
+
**Tracks**: TypeScript `^6.0.3` (stable). TypeScript 7.0 Beta
|
|
13
|
+
(`@typescript/native-preview`, binary `tsgo`) is available but **not yet
|
|
14
|
+
production-ready** — do not adopt for shipped builds.
|
|
15
|
+
|
|
16
|
+
**Related**:
|
|
17
|
+
|
|
18
|
+
- [TypeScript CLI Tool Rules](./typescript-cli-tool-rules.md)
|
|
19
|
+
- [TypeScript Sorting Patterns](./typescript-sorting-patterns.md)
|
|
20
|
+
- [TypeScript YAML Handling Rules](./typescript-yaml-handling-rules.md)
|
|
21
|
+
- [TypeScript Code Coverage](./typescript-code-coverage.md)
|
|
22
|
+
- [pnpm Monorepo Patterns](./pnpm-monorepo-patterns.md) and
|
|
23
|
+
[Bun Monorepo Patterns](./bun-monorepo-patterns.md)
|
|
24
|
+
- [Supply-Chain Mitigation](./pnpm-monorepo-patterns.md#supply-chain-mitigation) —
|
|
25
|
+
the 14-day package-age rule applies to every TypeScript dependency
|
|
26
|
+
(`zod`, `commander`, `vitest`, `eslint`, type packages, etc.).
|
|
27
|
+
|
|
10
28
|
## Coding Style
|
|
11
29
|
|
|
12
30
|
- Use clear lowerCamelCase or UpperCamelCase names for functions and variables, per
|
|
@@ -6,6 +6,18 @@ globs: "*.ts"
|
|
|
6
6
|
---
|
|
7
7
|
# TypeScript YAML Handling Rules
|
|
8
8
|
|
|
9
|
+
**Last Updated**: 2026-05-21
|
|
10
|
+
|
|
11
|
+
**Tracks**: `yaml@^2.8.4` (latest stable; 2026-05-02). The `yaml@3.0.0-1`
|
|
12
|
+
release is tagged `next` (pre-release) — do not adopt yet. Zod 4.x is the
|
|
13
|
+
recommended validation companion.
|
|
14
|
+
|
|
15
|
+
**Related**:
|
|
16
|
+
|
|
17
|
+
- [TypeScript Rules](./typescript-rules.md)
|
|
18
|
+
- [Supply-Chain Mitigation](./pnpm-monorepo-patterns.md#supply-chain-mitigation) —
|
|
19
|
+
follow the 14-day package-age rule for `yaml`, `zod`, and `gray-matter`.
|
|
20
|
+
|
|
9
21
|
These guidelines ensure consistent, safe, and readable YAML handling across TypeScript
|
|
10
22
|
codebases. YAML is deceptively tricky—inconsistent quoting, serialization differences,
|
|
11
23
|
and lack of validation cause subtle bugs.
|
package/dist/docs/tbd-design.md
CHANGED
|
@@ -2631,9 +2631,24 @@ Options:
|
|
|
2631
2631
|
|
|
2632
2632
|
**Output:**
|
|
2633
2633
|
|
|
2634
|
-
The `show` command outputs the issue in
|
|
2634
|
+
The `show` command outputs the issue in a storage-compatible format (YAML frontmatter +
|
|
2635
2635
|
Markdown body). This format is both human-readable and machine-parseable, enabling
|
|
2636
2636
|
round-trip editing workflows.
|
|
2637
|
+
For dependency direction, text output may include YAML comments immediately above the
|
|
2638
|
+
`dependencies` field:
|
|
2639
|
+
|
|
2640
|
+
```yaml
|
|
2641
|
+
# Blocks: proj-c3d4
|
|
2642
|
+
# Blocked by: proj-f14c
|
|
2643
|
+
dependencies:
|
|
2644
|
+
- type: blocks
|
|
2645
|
+
target: is-c3d4...
|
|
2646
|
+
```
|
|
2647
|
+
|
|
2648
|
+
These comments are ignored by `tbd update --from-file` and exist only to clarify the raw
|
|
2649
|
+
edge list. In storage, `type: blocks` means the shown issue blocks the target.
|
|
2650
|
+
For dependency-direction checks, prefer `tbd dep list <id>`, which renders the
|
|
2651
|
+
human-facing `Blocks:` and `Blocked by:` sections directly.
|
|
2637
2652
|
|
|
2638
2653
|
**Parent context (auto-displayed for child issues):**
|
|
2639
2654
|
|
package/dist/docs/tbd-docs.md
CHANGED
|
@@ -292,8 +292,13 @@ tbd show proj-a7k2 --no-parent # Suppress parent context
|
|
|
292
292
|
|
|
293
293
|
Output includes all fields: title, description, status, priority, labels, dependencies,
|
|
294
294
|
timestamps, and working notes.
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
The raw `dependencies` field is a storage-format edge list: an entry with `type: blocks`
|
|
296
|
+
means the shown issue blocks the target.
|
|
297
|
+
When dependency directions exist, text output adds YAML comments above `dependencies`
|
|
298
|
+
with human-facing `Blocks:` and `Blocked by:` sections using display IDs.
|
|
299
|
+
For dependency-direction checks, prefer `tbd dep list <id>`. For child issues, the
|
|
300
|
+
parent’s details (ID, title, status, priority, description) are automatically displayed
|
|
301
|
+
below the child for context.
|
|
297
302
|
|
|
298
303
|
### update
|
|
299
304
|
|
|
@@ -450,6 +455,10 @@ Subcommands:
|
|
|
450
455
|
- `remove <issue> <depends-on>` - Remove dependency
|
|
451
456
|
- `list <id>` - List dependencies for an issue (what it blocks and what blocks it)
|
|
452
457
|
|
|
458
|
+
Use `tbd dep list <id>` when checking dependency direction.
|
|
459
|
+
The raw `dependencies` frontmatter in `tbd show` stores graph edges where `type: blocks`
|
|
460
|
+
means the shown issue blocks the target.
|
|
461
|
+
|
|
453
462
|
### sync
|
|
454
463
|
|
|
455
464
|
Synchronize issues with remote repository.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as IdMappingYamlSchema } from "./schemas-C8mOQykE.mjs";
|
|
2
2
|
import { i as parseYamlToleratingDuplicateKeys, r as hasMergeConflictMarkers, s as stringifyYaml } from "./yaml-utils-BPy991by.mjs";
|
|
3
3
|
import { mkdir, readFile, rmdir, stat } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
@@ -699,4 +699,4 @@ function resolveIdMappingConflicts(content) {
|
|
|
699
699
|
|
|
700
700
|
//#endregion
|
|
701
701
|
export { formatDisplayId as _, hasShortId as a, normalizeIssueId as b, parseIdMappingFromYaml as c, resolveToInternalId as d, saveIdMapping as f, formatDebugId as g, extractUlidFromInternalId as h, generateUniqueShortId as i, reconcileMappings as l, extractShortId as m, calculateOptimalLength as n, loadIdMapping as o, extractPrefix as p, createShortIdMapping as r, mergeIdMappings as s, addIdMapping as t, resolveIdMappingConflicts as u, generateInternalId as v, validateIssueId as x, makeInternalId as y };
|
|
702
|
-
//# sourceMappingURL=id-mapping-
|
|
702
|
+
//# sourceMappingURL=id-mapping-CqrrLgeX.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"id-mapping-BtBwq5nG.mjs","names":[],"sources":["../src/lib/ids.ts","../src/utils/lockfile.ts","../src/lib/sort.ts","../src/file/id-mapping.ts"],"sourcesContent":["/**\n * ID generation and validation utilities.\n *\n * The system uses dual IDs for usability:\n * - Internal ID: is-{ulid} - ULID-based (26 lowercase chars), stored in files\n * - External ID: {prefix}-{short} - 4-5 base36 chars for CLI display/input\n *\n * For Beads compatibility, bd- prefix is accepted on input for external IDs.\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { monotonicFactory } from 'ulid';\nimport { randomBytes } from 'node:crypto';\n\n// Monotonic factory ensures ULIDs are strictly increasing even within the same\n// millisecond. This guarantees that lexicographic sort = creation order, which\n// is critical for deterministic list output (the tiebreaker sort is by ULID).\nconst ulid = monotonicFactory();\n\n// =============================================================================\n// Branded Types for Type-Safe ID Handling\n// =============================================================================\n\n/**\n * Branded type for internal issue IDs (is-{ulid} format).\n *\n * Internal IDs are stored in files and used as the canonical identifier.\n * Format: is-{26 lowercase alphanumeric chars}\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * Use this type when:\n * - Reading/writing issue files\n * - Storing parent_id, dependencies, child_order_hints\n * - Passing IDs between internal functions\n */\ndeclare const InternalIssueIdBrand: unique symbol;\nexport type InternalIssueId = string & { [InternalIssueIdBrand]: never };\n\n/**\n * Branded type for display issue IDs ({prefix}-{short} format).\n *\n * Display IDs are shown to users and accepted as CLI input.\n * Format: {prefix}-{short} where short is typically 4 base36 chars\n * Example: tbd-a7k2, bd-100\n *\n * Use this type when:\n * - Formatting output for users\n * - Accepting user input (before resolution)\n * - Building tree views for display\n */\ndeclare const DisplayIssueIdBrand: unique symbol;\nexport type DisplayIssueId = string & { [DisplayIssueIdBrand]: never };\n\n/**\n * Cast a string to InternalIssueId after validation.\n * Use this when you've validated that a string is a valid internal ID.\n */\nexport function asInternalId(id: string): InternalIssueId {\n return id as InternalIssueId;\n}\n\n/**\n * Cast a string to DisplayIssueId.\n * Use this when formatting an ID for display.\n */\nexport function asDisplayId(id: string): DisplayIssueId {\n return id as DisplayIssueId;\n}\n\n/**\n * Prefix for internal IDs (ULID-based).\n * All internal IDs are formatted as: {INTERNAL_ID_PREFIX}-{ulid}\n */\nexport const INTERNAL_ID_PREFIX = 'is';\n\n/**\n * Length of internal ID prefix including the hyphen (e.g., \"is-\" = 3).\n */\nexport const INTERNAL_ID_PREFIX_LENGTH = INTERNAL_ID_PREFIX.length + 1;\n\n/**\n * Construct an internal ID from a ULID.\n *\n * @param ulidValue - The ULID (26 chars)\n * @returns Internal ID in format {prefix}-{ulid}\n */\nexport function makeInternalId(ulidValue: string): InternalIssueId {\n return `${INTERNAL_ID_PREFIX}-${ulidValue.toLowerCase()}` as InternalIssueId;\n}\n\n/**\n * Generate a unique internal ID using ULID.\n * Format: is-{ulid} (26 lowercase alphanumeric chars)\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * ULID provides:\n * - Time-ordered sorting (48-bit timestamp)\n * - 80-bit randomness (no collisions)\n * - Lexicographic sort = chronological order\n */\nexport function generateInternalId(): InternalIssueId {\n return makeInternalId(ulid());\n}\n\n/**\n * Generate a short ID for external display.\n * Format: base36 characters (a-z, 0-9)\n * Example: a7k2\n *\n * @param length - Number of characters (default 4)\n */\nexport function generateShortId(length = 4): string {\n const chars = '0123456789abcdefghijklmnopqrstuvwxyz';\n let result = '';\n const bytes = randomBytes(length);\n for (let i = 0; i < length; i++) {\n result += chars[bytes[i]! % 36];\n }\n return result;\n}\n\n// Regex pattern for validating internal IDs - built from prefix constant\nconst INTERNAL_ID_PATTERN = new RegExp(`^${INTERNAL_ID_PREFIX}-[0-9a-z]{26}$`);\n\n// Expected length of a full internal ID (prefix + hyphen + 26-char ULID)\nconst INTERNAL_ID_LENGTH = INTERNAL_ID_PREFIX_LENGTH + 26;\n\n/**\n * Validate an internal issue ID matches the ULID format.\n * Format: {prefix}-{26 lowercase alphanumeric chars}\n */\nexport function validateIssueId(id: string): boolean {\n return INTERNAL_ID_PATTERN.test(id);\n}\n\n/**\n * Validate a short/external ID format.\n * Format: 1+ base36 characters (typically 4 for new IDs, but imports may preserve longer IDs).\n */\nexport function validateShortId(id: string): boolean {\n return /^[0-9a-z]+$/.test(id);\n}\n\n/**\n * Check if an input looks like an internal ID (ULID-based).\n */\nexport function isInternalId(input: string): boolean {\n const lower = input.toLowerCase();\n // Check if it starts with the internal prefix and has correct length\n const prefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n if (lower.startsWith(prefixWithHyphen) && lower.length === INTERNAL_ID_LENGTH) {\n return INTERNAL_ID_PATTERN.test(lower);\n }\n return false;\n}\n\n/**\n * Check if an input looks like a short/external ID.\n * Returns true for IDs like \"a7k2\", \"bd-a7k2\", \"100\", \"tbd-100\".\n * Short IDs are 16 characters or less (ULIDs are 26 characters).\n */\nexport function isShortId(input: string): boolean {\n const lower = input.toLowerCase();\n // Strip prefix if present\n const stripped = lower.replace(/^[a-z]+-/, '');\n // Must be 1-16 alphanumeric chars (short IDs, not ULIDs which are 26 chars)\n return /^[0-9a-z]+$/.test(stripped) && stripped.length >= 1 && stripped.length <= 16;\n}\n\n/**\n * Extract the short ID portion from an external ID.\n * Examples:\n * \"tbd-100\" -> \"100\"\n * \"bd-a7k2\" -> \"a7k2\"\n * \"a7k2\" -> \"a7k2\"\n * \"100\" -> \"100\"\n */\nexport function extractShortId(externalId: string): string {\n return externalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/**\n * Extract the prefix portion from an external ID.\n * Returns the prefix (letters before the hyphen) or null if no prefix found.\n * Examples:\n * \"tbd-100\" -> \"tbd\"\n * \"bd-a7k2\" -> \"bd\"\n * \"TBD-100\" -> \"tbd\" (normalized to lowercase)\n * \"a7k2\" -> null (no prefix)\n * \"100\" -> null (no prefix)\n */\nexport function extractPrefix(externalId: string): string | null {\n const match = /^([a-zA-Z]+)-/.exec(externalId);\n return match?.[1]?.toLowerCase() ?? null;\n}\n\n/**\n * Extract the ULID portion from an internal ID.\n *\n * Internal IDs have the format: {prefix}-{ulid}\n * This function strips any prefix to return just the ULID.\n *\n * Examples:\n * \"is-01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\"\n * \"01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\" (no prefix)\n *\n * @param internalId - The internal ID (with or without prefix)\n * @returns The ULID portion without any prefix\n */\nexport function extractUlidFromInternalId(internalId: string): string {\n // Strip any prefix in format {letters}- (e.g., \"is-\", \"bd-\")\n return internalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/** Prefix used in Beads for compatibility */\nconst BEADS_COMPAT_PREFIX = 'bd';\n\n/**\n * Normalize an internal issue ID.\n *\n * This function expects a full internal ID ({prefix}-{ulid}).\n * If given a short ID, it won't be able to resolve it without\n * access to the ID mapping.\n *\n * Handles:\n * - Uppercase (converts to lowercase)\n * - Ensures internal ID prefix\n * - Beads compatibility (bd- prefix)\n */\nexport function normalizeIssueId(input: string): string {\n const lower = input.toLowerCase();\n const internalPrefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n const beadsPrefixWithHyphen = `${BEADS_COMPAT_PREFIX}-`;\n\n // If already a valid internal ID, return as-is\n if (validateIssueId(lower)) {\n return lower;\n }\n\n // If it starts with internal prefix but wrong length, might be corrupted\n if (lower.startsWith(internalPrefixWithHyphen)) {\n return lower; // Return as-is, let validation fail later\n }\n\n // If it starts with bd- (Beads compat), convert prefix\n if (lower.startsWith(beadsPrefixWithHyphen)) {\n const rest = lower.slice(beadsPrefixWithHyphen.length);\n if (rest.length === 26) {\n return makeInternalId(rest);\n }\n // Short ID - can't resolve without mapping\n return lower;\n }\n\n // Bare ID without prefix\n if (lower.length === 26 && /^[0-9a-z]{26}$/.test(lower)) {\n return makeInternalId(lower);\n }\n\n // Can't normalize - return as-is\n return lower;\n}\n\nimport type { IdMapping } from '../file/id-mapping.js';\n\n/**\n * Format an internal ID for display with the configured prefix.\n *\n * Uses the short ID (4 chars) from the mapping.\n * Throws an error if the mapping is missing or doesn't contain the ID.\n *\n * IMPORTANT: All user-facing output MUST use short IDs, never internal ULIDs.\n * If you see a ULID in user output, it's a bug.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup (required)\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n * @throws Error if mapping is missing or ID not found in mapping\n */\nexport function formatDisplayId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): DisplayIssueId {\n // Extract the ULID portion\n const ulidPart = extractUlidFromInternalId(internalId);\n\n // Get short ID from mapping\n const shortId = mapping.ulidToShort.get(ulidPart);\n if (!shortId) {\n throw new Error(\n `No short ID mapping found for internal ID: ${internalId}. ` +\n `This is a bug - all issues must have a short ID mapping.`,\n );\n }\n\n return `${prefix}-${shortId}` as DisplayIssueId;\n}\n\n/**\n * Format an ID for debug output, showing both public and internal IDs.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n */\nexport function formatDebugId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): string {\n const displayId = formatDisplayId(internalId, mapping, prefix);\n return `${displayId} (${internalId})`;\n}\n","/**\n * Directory-based mutual exclusion for concurrent file access.\n *\n * Note: Despite the name \"lockfile\", this is NOT a POSIX file lock (flock/fcntl).\n * It uses mkdir to create a lock *directory* as a coordination convention — no\n * OS-level file locking syscalls are involved. This makes it portable across all\n * filesystems, including NFS and other network mounts where flock/fcntl locks\n * are unreliable or unsupported.\n *\n * This is the same strategy used by:\n *\n * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)\n * See: https://git-scm.com/docs/gitrepository-layout (\"lockfile protocol\")\n * - **npm** for package-lock.json concurrent access\n *\n * ## Why mkdir?\n *\n * `mkdir(2)` is atomic on all common filesystems (local and network): it either\n * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,\n * a directory lock is trivially distinguishable from normal files.\n *\n * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving\n * the atomicity guarantee:\n * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback\n *\n * ## Lock lifecycle\n *\n * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process\n * 2. **Hold**: Execute the critical section\n * 3. **Release**: `rmdir(lockDir)` — in a finally block\n * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder\n * crashed and break the lock. This is a heuristic — safe when the critical\n * section is short-lived (sub-second for file I/O).\n *\n * ## Failure on timeout\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError is\n * thrown. This prevents the dangerous \"degraded mode\" where the critical section\n * runs without mutual exclusion, which can cause data loss (e.g., lost ID\n * mappings during concurrent `tbd create`).\n *\n * IMPORTANT: `timeoutMs` must be greater than `staleMs` so stale locks from\n * crashed processes are always detected and broken before the timeout expires.\n */\n\nimport { mkdir, rmdir, stat } from 'node:fs/promises';\n\n/** Options for `withLockfile`. */\nexport interface LockfileOptions {\n /** Maximum time (ms) to wait for the lock. Default: 10000 */\n timeoutMs?: number;\n /** Polling interval (ms) between acquisition attempts. Default: 50 */\n pollMs?: number;\n /** Age (ms) after which a lock is considered stale. Default: 5000 */\n staleMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_POLL_MS = 50;\nconst DEFAULT_STALE_MS = 5_000;\n\n/**\n * Error thrown when the lock cannot be acquired within the timeout.\n */\nexport class LockAcquisitionError extends Error {\n constructor(lockPath: string, timeoutMs: number) {\n super(\n `Failed to acquire lock at ${lockPath} within ${timeoutMs}ms. ` +\n `Another process may be holding the lock. If this persists, ` +\n `delete the lock directory manually and retry.`,\n );\n this.name = 'LockAcquisitionError';\n }\n}\n\n/**\n * Execute `fn` while holding a lockfile.\n *\n * The lock is a directory at `lockPath` (typically `<target-file>.lock`).\n * Concurrent callers will wait up to `timeoutMs` for the lock, polling\n * every `pollMs`. Stale locks older than `staleMs` are broken automatically.\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError\n * is thrown. This ensures mutual exclusion is never silently bypassed, which\n * prevents data loss from concurrent writes.\n *\n * @param lockPath - Path to use as the lock directory (e.g., \"/path/to/ids.yml.lock\")\n * @param fn - Critical section to execute under the lock\n * @param options - Timing parameters for lock acquisition\n * @returns The return value of `fn`\n * @throws LockAcquisitionError if the lock cannot be acquired within the timeout\n *\n * @example\n * ```ts\n * await withLockfile('/path/to/ids.yml.lock', async () => {\n * const data = await readFile('/path/to/ids.yml', 'utf-8');\n * const updated = mergeEntries(data, newEntries);\n * await writeFile('/path/to/ids.yml', updated);\n * });\n * ```\n */\nexport async function withLockfile<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options?: LockfileOptions,\n): Promise<T> {\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;\n const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;\n\n const deadline = Date.now() + timeoutMs;\n let acquired = false;\n\n while (Date.now() < deadline) {\n try {\n // mkdir is atomic per POSIX.1-2017 — fails with EEXIST if already held\n await mkdir(lockPath);\n acquired = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n // Unexpected error (permissions, disk full, missing parent, etc.) —\n // preserve the original failure instead of misreporting lock contention.\n throw error;\n }\n\n // Lock exists — check if it's stale (holder likely crashed)\n try {\n const lockStat = await stat(lockPath);\n if (Date.now() - lockStat.mtimeMs > staleMs) {\n try {\n await rmdir(lockPath);\n } catch {\n // Another process may have already broken/released it\n }\n continue; // Retry immediately after breaking stale lock\n }\n } catch {\n // Lock was released between our mkdir and stat — retry immediately\n continue;\n }\n\n // Lock is fresh — wait and retry\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n }\n }\n\n if (!acquired) {\n throw new LockAcquisitionError(lockPath, timeoutMs);\n }\n\n try {\n return await fn();\n } finally {\n try {\n await rmdir(lockPath);\n } catch {\n // Best-effort cleanup; stale lock detection handles the rest\n }\n }\n}\n","/**\n * Natural sort utilities.\n *\n * Provides alphanumeric sorting where numeric portions are sorted numerically.\n * Similar to `sort -V` (version sort) or `sort -n` for numbers.\n *\n * Examples:\n * [\"1\", \"2\", \"9\", \"10\", \"11\"] instead of [\"1\", \"10\", \"11\", \"2\", \"9\"]\n * [\"a1\", \"a2\", \"a10\"] instead of [\"a1\", \"a10\", \"a2\"]\n * [\"file1.txt\", \"file2.txt\", \"file10.txt\"] instead of [\"file1.txt\", \"file10.txt\", \"file2.txt\"]\n */\n\n/**\n * Split a string into alternating runs of digits and non-digits.\n * @param str - The string to split\n * @returns Array of [isNumeric, value] tuples\n */\nfunction splitIntoChunks(str: string): [boolean, string][] {\n const chunks: [boolean, string][] = [];\n let current = '';\n let currentIsNumeric: boolean | null = null;\n\n for (const char of str) {\n const isDigit = char >= '0' && char <= '9';\n\n if (currentIsNumeric === null) {\n // First character\n currentIsNumeric = isDigit;\n current = char;\n } else if (isDigit === currentIsNumeric) {\n // Same type, continue accumulating\n current += char;\n } else {\n // Type changed, push current chunk and start new one\n chunks.push([currentIsNumeric, current]);\n currentIsNumeric = isDigit;\n current = char;\n }\n }\n\n // Push final chunk\n if (current) {\n chunks.push([currentIsNumeric!, current]);\n }\n\n return chunks;\n}\n\n/**\n * Compare two strings using natural (alphanumeric) ordering.\n *\n * Numeric portions are compared numerically, non-numeric portions are\n * compared lexicographically (case-insensitive). Numbers sort before letters\n * when they appear at the same position in mixed comparisons, matching\n * the behavior of `sort -V` (version sort).\n *\n * @param a - First string\n * @param b - Second string\n * @returns Negative if a < b, positive if a > b, zero if equal\n */\nexport function naturalCompare(a: string, b: string): number {\n // Handle empty strings\n if (!a && !b) return 0;\n if (!a) return -1;\n if (!b) return 1;\n\n const chunksA = splitIntoChunks(a);\n const chunksB = splitIntoChunks(b);\n\n const minLen = Math.min(chunksA.length, chunksB.length);\n\n for (let i = 0; i < minLen; i++) {\n const [isNumericA, valueA] = chunksA[i]!;\n const [isNumericB, valueB] = chunksB[i]!;\n\n if (isNumericA && isNumericB) {\n // Both are numeric - compare as numbers\n const numA = parseInt(valueA, 10);\n const numB = parseInt(valueB, 10);\n if (numA !== numB) {\n return numA - numB;\n }\n // If numerically equal but different strings (e.g., \"01\" vs \"1\"),\n // prefer shorter (fewer leading zeros)\n if (valueA.length !== valueB.length) {\n return valueA.length - valueB.length;\n }\n } else if (!isNumericA && !isNumericB) {\n // Both are non-numeric - compare lexicographically (case-insensitive)\n const lowerA = valueA.toLowerCase();\n const lowerB = valueB.toLowerCase();\n if (lowerA !== lowerB) {\n return lowerA.localeCompare(lowerB);\n }\n // Same when lowercased - they're equal for sorting purposes\n } else {\n // Mixed: numeric comes before non-numeric\n // (so \"1\" comes before \"a\" at the same position)\n // This matches `sort -V` behavior\n return isNumericA ? -1 : 1;\n }\n }\n\n // All compared chunks are equal, shorter string comes first\n return chunksA.length - chunksB.length;\n}\n\n/**\n * Sort an array of strings using natural (alphanumeric) ordering.\n *\n * @param arr - Array to sort\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSort(arr: readonly string[]): string[] {\n return [...arr].sort(naturalCompare);\n}\n\n/**\n * Sort an array of objects by a string key using natural ordering.\n *\n * @param arr - Array to sort\n * @param keyFn - Function to extract the sort key from each element\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSortBy<T>(arr: readonly T[], keyFn: (item: T) => string): T[] {\n return [...arr].sort((a, b) => naturalCompare(keyFn(a), keyFn(b)));\n}\n","/**\n * ID mapping management for short public IDs.\n *\n * Maps 4-char base36 short IDs to 26-char ULIDs.\n * Stored in .tbd/data-sync/mappings/ids.yml\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { readFile, mkdir } from 'node:fs/promises';\nimport { join, dirname } from 'node:path';\nimport { writeFile } from 'atomically';\n\nimport {\n parseYamlToleratingDuplicateKeys,\n stringifyYaml,\n hasMergeConflictMarkers,\n} from '../utils/yaml-utils.js';\nimport { withLockfile } from '../utils/lockfile.js';\n\nimport {\n generateShortId,\n extractUlidFromInternalId,\n makeInternalId,\n isInternalId,\n extractShortId,\n asInternalId,\n type InternalIssueId,\n} from '../lib/ids.js';\nimport { naturalSort } from '../lib/sort.js';\nimport { IdMappingYamlSchema } from '../lib/schemas.js';\n\n/**\n * ID mapping from short ID to ULID.\n * Format in ids.yml:\n * a7k2: 01hx5zzkbkactav9wevgemmvrz\n * b3m9: 01hx5zzkbkbctav9wevgemmvrz\n */\nexport interface IdMapping {\n shortToUlid: Map<string, string>;\n ulidToShort: Map<string, string>;\n}\n\n/**\n * Get the path to the ids.yml mapping file.\n */\nfunction getMappingPath(baseDir: string): string {\n return join(baseDir, 'mappings', 'ids.yml');\n}\n\n/**\n * Load the ID mapping from disk.\n * Returns empty mapping if file doesn't exist.\n */\nexport async function loadIdMapping(baseDir: string): Promise<IdMapping> {\n const filePath = getMappingPath(baseDir);\n\n let content: string;\n try {\n content = await readFile(filePath, 'utf-8');\n } catch {\n // File doesn't exist - return empty mapping\n return {\n shortToUlid: new Map(),\n ulidToShort: new Map(),\n };\n }\n\n // Parse tolerating duplicate keys — this handles the case where a git merge\n // conflict resolution kept entries from both sides, creating duplicate YAML keys.\n // Without this, the yaml parser throws \"Map keys must be unique\".\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(\n content,\n filePath,\n );\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ${filePath} contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `This usually happens after a git merge conflict resolution. ` +\n `The file will be auto-fixed on next save.`,\n );\n }\n\n // Validate with Zod schema - ensures all keys are valid short IDs and values are ULIDs\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Save the ID mapping to disk with mutual exclusion.\n *\n * Uses a lockfile to serialize concurrent writers, then performs read-merge-write\n * inside the lock. This prevents the lost-update problem when multiple `tbd create`\n * commands run in parallel.\n *\n * The merge is safe because ID mappings are append-only — entries are never\n * intentionally removed. If the lock cannot be acquired within the timeout,\n * a LockAcquisitionError is thrown rather than proceeding without protection.\n */\nexport async function saveIdMapping(baseDir: string, mapping: IdMapping): Promise<void> {\n const filePath = getMappingPath(baseDir);\n\n // Ensure directory exists\n await mkdir(dirname(filePath), { recursive: true });\n\n await withLockfile(filePath + '.lock', async () => {\n // Inside the lock: read current on-disk state, merge with our in-memory\n // mapping, and write the result. Our entries take precedence for short ID\n // conflicts (extremely unlikely with random 4-char base36 IDs).\n let merged = mapping;\n let onDiskSize = 0;\n try {\n const onDisk = await loadIdMappingRaw(filePath);\n onDiskSize = onDisk.shortToUlid.size;\n if (onDiskSize > 0) {\n merged = mergeIdMappings(mapping, onDisk);\n }\n } catch {\n // File doesn't exist or is unreadable — proceed with our mapping only\n }\n\n // Safety check: ID mappings are append-only. If the merged result has fewer\n // entries than what's on disk, something went wrong. Refuse to write so the\n // caller can investigate rather than silently destroying entries.\n if (merged.shortToUlid.size < onDiskSize) {\n throw new Error(\n `Refusing to save ID mapping: would lose ${onDiskSize - merged.shortToUlid.size} entries ` +\n `(on-disk: ${onDiskSize}, proposed: ${merged.shortToUlid.size}). ` +\n `ID mappings are append-only — this indicates a bug.`,\n );\n }\n\n const data: Record<string, string> = {};\n const sortedKeys = naturalSort(Array.from(merged.shortToUlid.keys()));\n for (const key of sortedKeys) {\n data[key] = merged.shortToUlid.get(key)!;\n }\n\n const content = stringifyYaml(data);\n await writeFile(filePath, content);\n });\n}\n\n/**\n * Load an ID mapping directly from a file path (internal helper for save merging).\n * Separated from loadIdMapping to avoid coupling the save path to baseDir resolution.\n */\nasync function loadIdMappingRaw(filePath: string): Promise<IdMapping> {\n const content = await readFile(filePath, 'utf-8');\n\n const { data: rawData } = parseYamlToleratingDuplicateKeys<unknown>(content, filePath);\n const data = rawData ?? {};\n\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Calculate the optimal short ID length based on existing ID count.\n *\n * At 50K issues, switches from 4-char to 5-char IDs to keep\n * collision probability low (~3% per attempt with 4 chars at 50K).\n *\n * With 10 retries per length, actual failure probability is astronomically low.\n */\nexport function calculateOptimalLength(existingCount: number): number {\n return existingCount < 50_000 ? 4 : 5;\n}\n\n/**\n * Generate a unique short ID that doesn't collide with existing ones.\n *\n * Calculates optimal length (4 or 5 chars) based on existing ID count,\n * then retries with the next length if collisions occur.\n *\n * @returns The new short ID\n * @throws If unable to generate a unique ID after max attempts\n */\nexport function generateUniqueShortId(mapping: IdMapping): string {\n const ATTEMPTS_PER_LENGTH = 10;\n const existingCount = mapping.shortToUlid.size;\n const optimalLength = calculateOptimalLength(existingCount);\n\n // Try optimal length first, then fall back to longer if needed\n for (const length of [optimalLength, optimalLength + 1]) {\n for (let attempt = 0; attempt < ATTEMPTS_PER_LENGTH; attempt++) {\n const shortId = generateShortId(length);\n if (!mapping.shortToUlid.has(shortId)) {\n return shortId;\n }\n }\n }\n\n throw new Error(\n `Failed to generate unique short ID after 20 attempts with ${existingCount} existing IDs. ` +\n `This should be extremely rare - please report if you see this error.`,\n );\n}\n\n/**\n * Register a new ID mapping.\n * @param ulid - The ULID (without is- prefix)\n * @param shortId - The short ID (4 chars)\n */\nexport function addIdMapping(mapping: IdMapping, ulid: string, shortId: string): void {\n mapping.shortToUlid.set(shortId, ulid);\n mapping.ulidToShort.set(ulid, shortId);\n}\n\n/**\n * Get the short ID for a ULID.\n * @param ulid - The ULID (without is- prefix)\n * @returns The short ID, or undefined if not found\n */\nexport function getShortId(mapping: IdMapping, ulid: string): string | undefined {\n return mapping.ulidToShort.get(ulid);\n}\n\n/**\n * Get the ULID for a short ID.\n * @param shortId - The short ID\n * @returns The ULID (without is- prefix), or undefined if not found\n */\nexport function getUlid(mapping: IdMapping, shortId: string): string | undefined {\n return mapping.shortToUlid.get(shortId);\n}\n\n/**\n * Check if a short ID exists in the mapping.\n */\nexport function hasShortId(mapping: IdMapping, shortId: string): boolean {\n return mapping.shortToUlid.has(shortId);\n}\n\n/**\n * Create a short ID mapping for a new internal ID.\n * Generates a unique short ID and registers it in the mapping.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - The ID mapping to update\n * @returns The generated short ID\n */\nexport function createShortIdMapping(internalId: string, mapping: IdMapping): string {\n // Extract ULID from internal ID (remove prefix)\n const ulid = extractUlidFromInternalId(internalId);\n\n // Check if already mapped\n const existing = mapping.ulidToShort.get(ulid);\n if (existing) {\n return existing;\n }\n\n // Generate unique short ID\n const shortId = generateUniqueShortId(mapping);\n\n // Register mapping\n addIdMapping(mapping, ulid, shortId);\n\n return shortId;\n}\n\n/**\n * Resolve any ID input to an internal ID ({prefix}-{ulid}).\n *\n * Handles:\n * - Internal IDs: {prefix}-{ulid} -> {prefix}-{ulid}\n * - Short IDs: a7k2 -> {prefix}-{ulid from mapping}\n * - Prefixed short IDs: bd-a7k2 -> {prefix}-{ulid from mapping}\n *\n * @param input - The ID input (short ID, prefixed short ID, or internal ID)\n * @param mapping - The ID mapping for short ID resolution\n * @returns The internal ID ({prefix}-{ulid})\n * @throws If the short ID is not found in the mapping\n */\nexport function resolveToInternalId(input: string, mapping: IdMapping): InternalIssueId {\n const lower = input.toLowerCase();\n\n // If it's already an internal ID, return it\n if (isInternalId(lower)) {\n return asInternalId(lower);\n }\n\n // Extract the short ID portion (strips any prefix like \"bd-\" or \"is-\")\n const shortId = extractShortId(lower);\n\n // If it's a full ULID (26 chars), it might be a bare internal ID\n if (shortId.length === 26 && /^[0-9a-z]{26}$/.test(shortId)) {\n return makeInternalId(shortId);\n }\n\n // Must be a short ID - look it up in the mapping\n const ulid = mapping.shortToUlid.get(shortId);\n if (!ulid) {\n throw new Error(`Unknown issue ID: ${input}. ` + `Short ID \"${shortId}\" not found in mapping.`);\n }\n\n return makeInternalId(ulid);\n}\n\n/**\n * Parse an ID mapping from raw YAML content.\n * Used for loading mappings from git show output during conflict resolution.\n *\n * @throws MergeConflictError if content contains merge conflict markers\n */\nexport function parseIdMappingFromYaml(content: string): IdMapping {\n // Parse tolerating duplicate keys — handles post-merge-conflict duplicates\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(content);\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ID mapping YAML contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `Duplicates will be auto-resolved.`,\n );\n }\n\n // Validate with Zod schema\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Ensure all given internal IDs have short ID mappings.\n * Creates missing mappings for any IDs without entries.\n *\n * This repairs state after git merges that may add issue files\n * without corresponding mapping entries (e.g., when outbox issues\n * are merged from a feature branch but ids.yml doesn't include them).\n *\n * When a `historicalMapping` is provided, the function will try to recover\n * the original short ID from that mapping before generating a new random one.\n * This preserves ID stability so that existing references (in docs, PRs,\n * conversations) remain valid.\n *\n * @param internalIds - Array of internal IDs (is-{ulid}) to reconcile\n * @param mapping - The ID mapping to update (mutated in-place)\n * @param historicalMapping - Optional mapping from prior state (e.g., git history) to recover original short IDs\n * @returns Object with `created` (IDs that got new random short IDs) and `recovered` (IDs restored from history)\n */\nexport function reconcileMappings(\n internalIds: string[],\n mapping: IdMapping,\n historicalMapping?: IdMapping,\n): { created: string[]; recovered: string[] } {\n const created: string[] = [];\n const recovered: string[] = [];\n\n for (const id of internalIds) {\n const ulid = extractUlidFromInternalId(id);\n if (mapping.ulidToShort.has(ulid)) {\n continue; // Already has a mapping\n }\n\n // Try to recover original short ID from historical mapping\n const historicalShortId = historicalMapping?.ulidToShort.get(ulid);\n if (historicalShortId && !mapping.shortToUlid.has(historicalShortId)) {\n // Recovered: restore the original short ID\n addIdMapping(mapping, ulid, historicalShortId);\n recovered.push(id);\n } else {\n // No history available or short ID conflicts — generate new random one\n createShortIdMapping(id, mapping);\n created.push(id);\n }\n }\n\n return { created, recovered };\n}\n\n/**\n * Merge two ID mappings by combining all entries from both.\n * ID mappings are always additive (new IDs are only added, never removed),\n * so merging simply unions all key-value pairs.\n *\n * If the same short ID maps to different ULIDs in each mapping (a conflict),\n * the local mapping takes precedence (caller should log a warning).\n *\n * @param local - The local ID mapping\n * @param remote - The remote ID mapping\n * @returns Merged mapping with all entries from both\n */\nexport function mergeIdMappings(local: IdMapping, remote: IdMapping): IdMapping {\n const merged: IdMapping = {\n shortToUlid: new Map(local.shortToUlid),\n ulidToShort: new Map(local.ulidToShort),\n };\n\n // Add all remote entries that don't conflict\n for (const [shortId, ulid] of remote.shortToUlid) {\n if (!merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n // If shortId already exists with different ulid, keep local (conflict resolution)\n }\n\n // Also check for ULIDs that exist in remote but not in local\n // (different short ID for same ULID - shouldn't happen but handle gracefully)\n for (const [ulid, shortId] of remote.ulidToShort) {\n if (!merged.ulidToShort.has(ulid) && !merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n }\n\n return merged;\n}\n\n/**\n * Resolve merge conflicts in ids.yml content by extracting both sides and merging.\n *\n * ids.yml is a sorted key-value YAML map where entries are append-only.\n * The most common merge conflict is both sides adding non-overlapping keys,\n * which is trivially auto-resolvable by keeping all entries from both sides.\n *\n * @param content - Raw file content that may contain git merge conflict markers\n * @returns Merged IdMapping with entries from both sides\n */\nexport function resolveIdMappingConflicts(content: string): IdMapping {\n if (!hasMergeConflictMarkers(content)) {\n return parseIdMappingFromYaml(content);\n }\n\n const lines = content.split('\\n');\n const oursLines: string[] = [];\n const theirsLines: string[] = [];\n let inConflict: 'none' | 'ours' | 'theirs' = 'none';\n\n for (const line of lines) {\n if (line.startsWith('<<<<<<< ')) {\n inConflict = 'ours';\n continue;\n }\n if (line === '=======' && inConflict === 'ours') {\n inConflict = 'theirs';\n continue;\n }\n if (line.startsWith('>>>>>>> ') && inConflict === 'theirs') {\n inConflict = 'none';\n continue;\n }\n\n if (inConflict === 'none') {\n oursLines.push(line);\n theirsLines.push(line);\n } else if (inConflict === 'ours') {\n oursLines.push(line);\n } else {\n theirsLines.push(line);\n }\n }\n\n const oursMapping = parseIdMappingFromYaml(oursLines.join('\\n'));\n const theirsMapping = parseIdMappingFromYaml(theirsLines.join('\\n'));\n\n return mergeIdMappings(oursMapping, theirsMapping);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"id-mapping-CqrrLgeX.mjs","names":[],"sources":["../src/lib/ids.ts","../src/utils/lockfile.ts","../src/lib/sort.ts","../src/file/id-mapping.ts"],"sourcesContent":["/**\n * ID generation and validation utilities.\n *\n * The system uses dual IDs for usability:\n * - Internal ID: is-{ulid} - ULID-based (26 lowercase chars), stored in files\n * - External ID: {prefix}-{short} - 4-5 base36 chars for CLI display/input\n *\n * For Beads compatibility, bd- prefix is accepted on input for external IDs.\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { monotonicFactory } from 'ulid';\nimport { randomBytes } from 'node:crypto';\n\n// Monotonic factory ensures ULIDs are strictly increasing even within the same\n// millisecond. This guarantees that lexicographic sort = creation order, which\n// is critical for deterministic list output (the tiebreaker sort is by ULID).\nconst ulid = monotonicFactory();\n\n// =============================================================================\n// Branded Types for Type-Safe ID Handling\n// =============================================================================\n\n/**\n * Branded type for internal issue IDs (is-{ulid} format).\n *\n * Internal IDs are stored in files and used as the canonical identifier.\n * Format: is-{26 lowercase alphanumeric chars}\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * Use this type when:\n * - Reading/writing issue files\n * - Storing parent_id, dependencies, child_order_hints\n * - Passing IDs between internal functions\n */\ndeclare const InternalIssueIdBrand: unique symbol;\nexport type InternalIssueId = string & { [InternalIssueIdBrand]: never };\n\n/**\n * Branded type for display issue IDs ({prefix}-{short} format).\n *\n * Display IDs are shown to users and accepted as CLI input.\n * Format: {prefix}-{short} where short is typically 4 base36 chars\n * Example: tbd-a7k2, bd-100\n *\n * Use this type when:\n * - Formatting output for users\n * - Accepting user input (before resolution)\n * - Building tree views for display\n */\ndeclare const DisplayIssueIdBrand: unique symbol;\nexport type DisplayIssueId = string & { [DisplayIssueIdBrand]: never };\n\n/**\n * Cast a string to InternalIssueId after validation.\n * Use this when you've validated that a string is a valid internal ID.\n */\nexport function asInternalId(id: string): InternalIssueId {\n return id as InternalIssueId;\n}\n\n/**\n * Cast a string to DisplayIssueId.\n * Use this when formatting an ID for display.\n */\nexport function asDisplayId(id: string): DisplayIssueId {\n return id as DisplayIssueId;\n}\n\n/**\n * Prefix for internal IDs (ULID-based).\n * All internal IDs are formatted as: {INTERNAL_ID_PREFIX}-{ulid}\n */\nexport const INTERNAL_ID_PREFIX = 'is';\n\n/**\n * Length of internal ID prefix including the hyphen (e.g., \"is-\" = 3).\n */\nexport const INTERNAL_ID_PREFIX_LENGTH = INTERNAL_ID_PREFIX.length + 1;\n\n/**\n * Construct an internal ID from a ULID.\n *\n * @param ulidValue - The ULID (26 chars)\n * @returns Internal ID in format {prefix}-{ulid}\n */\nexport function makeInternalId(ulidValue: string): InternalIssueId {\n return `${INTERNAL_ID_PREFIX}-${ulidValue.toLowerCase()}` as InternalIssueId;\n}\n\n/**\n * Generate a unique internal ID using ULID.\n * Format: is-{ulid} (26 lowercase alphanumeric chars)\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * ULID provides:\n * - Time-ordered sorting (48-bit timestamp)\n * - 80-bit randomness (no collisions)\n * - Lexicographic sort = chronological order\n */\nexport function generateInternalId(): InternalIssueId {\n return makeInternalId(ulid());\n}\n\n/**\n * Generate a short ID for external display.\n * Format: base36 characters (a-z, 0-9)\n * Example: a7k2\n *\n * @param length - Number of characters (default 4)\n */\nexport function generateShortId(length = 4): string {\n const chars = '0123456789abcdefghijklmnopqrstuvwxyz';\n let result = '';\n const bytes = randomBytes(length);\n for (let i = 0; i < length; i++) {\n result += chars[bytes[i]! % 36];\n }\n return result;\n}\n\n// Regex pattern for validating internal IDs - built from prefix constant\nconst INTERNAL_ID_PATTERN = new RegExp(`^${INTERNAL_ID_PREFIX}-[0-9a-z]{26}$`);\n\n// Expected length of a full internal ID (prefix + hyphen + 26-char ULID)\nconst INTERNAL_ID_LENGTH = INTERNAL_ID_PREFIX_LENGTH + 26;\n\n/**\n * Validate an internal issue ID matches the ULID format.\n * Format: {prefix}-{26 lowercase alphanumeric chars}\n */\nexport function validateIssueId(id: string): boolean {\n return INTERNAL_ID_PATTERN.test(id);\n}\n\n/**\n * Validate a short/external ID format.\n * Format: 1+ base36 characters (typically 4 for new IDs, but imports may preserve longer IDs).\n */\nexport function validateShortId(id: string): boolean {\n return /^[0-9a-z]+$/.test(id);\n}\n\n/**\n * Check if an input looks like an internal ID (ULID-based).\n */\nexport function isInternalId(input: string): boolean {\n const lower = input.toLowerCase();\n // Check if it starts with the internal prefix and has correct length\n const prefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n if (lower.startsWith(prefixWithHyphen) && lower.length === INTERNAL_ID_LENGTH) {\n return INTERNAL_ID_PATTERN.test(lower);\n }\n return false;\n}\n\n/**\n * Check if an input looks like a short/external ID.\n * Returns true for IDs like \"a7k2\", \"bd-a7k2\", \"100\", \"tbd-100\".\n * Short IDs are 16 characters or less (ULIDs are 26 characters).\n */\nexport function isShortId(input: string): boolean {\n const lower = input.toLowerCase();\n // Strip prefix if present\n const stripped = lower.replace(/^[a-z]+-/, '');\n // Must be 1-16 alphanumeric chars (short IDs, not ULIDs which are 26 chars)\n return /^[0-9a-z]+$/.test(stripped) && stripped.length >= 1 && stripped.length <= 16;\n}\n\n/**\n * Extract the short ID portion from an external ID.\n * Examples:\n * \"tbd-100\" -> \"100\"\n * \"bd-a7k2\" -> \"a7k2\"\n * \"a7k2\" -> \"a7k2\"\n * \"100\" -> \"100\"\n */\nexport function extractShortId(externalId: string): string {\n return externalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/**\n * Extract the prefix portion from an external ID.\n * Returns the prefix (letters before the hyphen) or null if no prefix found.\n * Examples:\n * \"tbd-100\" -> \"tbd\"\n * \"bd-a7k2\" -> \"bd\"\n * \"TBD-100\" -> \"tbd\" (normalized to lowercase)\n * \"a7k2\" -> null (no prefix)\n * \"100\" -> null (no prefix)\n */\nexport function extractPrefix(externalId: string): string | null {\n const match = /^([a-zA-Z]+)-/.exec(externalId);\n return match?.[1]?.toLowerCase() ?? null;\n}\n\n/**\n * Extract the ULID portion from an internal ID.\n *\n * Internal IDs have the format: {prefix}-{ulid}\n * This function strips any prefix to return just the ULID.\n *\n * Examples:\n * \"is-01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\"\n * \"01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\" (no prefix)\n *\n * @param internalId - The internal ID (with or without prefix)\n * @returns The ULID portion without any prefix\n */\nexport function extractUlidFromInternalId(internalId: string): string {\n // Strip any prefix in format {letters}- (e.g., \"is-\", \"bd-\")\n return internalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/** Prefix used in Beads for compatibility */\nconst BEADS_COMPAT_PREFIX = 'bd';\n\n/**\n * Normalize an internal issue ID.\n *\n * This function expects a full internal ID ({prefix}-{ulid}).\n * If given a short ID, it won't be able to resolve it without\n * access to the ID mapping.\n *\n * Handles:\n * - Uppercase (converts to lowercase)\n * - Ensures internal ID prefix\n * - Beads compatibility (bd- prefix)\n */\nexport function normalizeIssueId(input: string): string {\n const lower = input.toLowerCase();\n const internalPrefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n const beadsPrefixWithHyphen = `${BEADS_COMPAT_PREFIX}-`;\n\n // If already a valid internal ID, return as-is\n if (validateIssueId(lower)) {\n return lower;\n }\n\n // If it starts with internal prefix but wrong length, might be corrupted\n if (lower.startsWith(internalPrefixWithHyphen)) {\n return lower; // Return as-is, let validation fail later\n }\n\n // If it starts with bd- (Beads compat), convert prefix\n if (lower.startsWith(beadsPrefixWithHyphen)) {\n const rest = lower.slice(beadsPrefixWithHyphen.length);\n if (rest.length === 26) {\n return makeInternalId(rest);\n }\n // Short ID - can't resolve without mapping\n return lower;\n }\n\n // Bare ID without prefix\n if (lower.length === 26 && /^[0-9a-z]{26}$/.test(lower)) {\n return makeInternalId(lower);\n }\n\n // Can't normalize - return as-is\n return lower;\n}\n\nimport type { IdMapping } from '../file/id-mapping.js';\n\n/**\n * Format an internal ID for display with the configured prefix.\n *\n * Uses the short ID (4 chars) from the mapping.\n * Throws an error if the mapping is missing or doesn't contain the ID.\n *\n * IMPORTANT: All user-facing output MUST use short IDs, never internal ULIDs.\n * If you see a ULID in user output, it's a bug.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup (required)\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n * @throws Error if mapping is missing or ID not found in mapping\n */\nexport function formatDisplayId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): DisplayIssueId {\n // Extract the ULID portion\n const ulidPart = extractUlidFromInternalId(internalId);\n\n // Get short ID from mapping\n const shortId = mapping.ulidToShort.get(ulidPart);\n if (!shortId) {\n throw new Error(\n `No short ID mapping found for internal ID: ${internalId}. ` +\n `This is a bug - all issues must have a short ID mapping.`,\n );\n }\n\n return `${prefix}-${shortId}` as DisplayIssueId;\n}\n\n/**\n * Format an ID for debug output, showing both public and internal IDs.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n */\nexport function formatDebugId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): string {\n const displayId = formatDisplayId(internalId, mapping, prefix);\n return `${displayId} (${internalId})`;\n}\n","/**\n * Directory-based mutual exclusion for concurrent file access.\n *\n * Note: Despite the name \"lockfile\", this is NOT a POSIX file lock (flock/fcntl).\n * It uses mkdir to create a lock *directory* as a coordination convention — no\n * OS-level file locking syscalls are involved. This makes it portable across all\n * filesystems, including NFS and other network mounts where flock/fcntl locks\n * are unreliable or unsupported.\n *\n * This is the same strategy used by:\n *\n * - **Git** for ref updates (e.g., `.git/refs/heads/main.lock`)\n * See: https://git-scm.com/docs/gitrepository-layout (\"lockfile protocol\")\n * - **npm** for package-lock.json concurrent access\n *\n * ## Why mkdir?\n *\n * `mkdir(2)` is atomic on all common filesystems (local and network): it either\n * creates the directory or returns EEXIST. Unlike `open(O_CREAT|O_EXCL)`,\n * a directory lock is trivially distinguishable from normal files.\n *\n * Node.js `fs.mkdir` maps directly to the mkdir(2) syscall, preserving\n * the atomicity guarantee:\n * https://nodejs.org/api/fs.html#fsmkdirpath-options-callback\n *\n * ## Lock lifecycle\n *\n * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process\n * 2. **Hold**: Execute the critical section\n * 3. **Release**: `rmdir(lockDir)` — in a finally block\n * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder\n * crashed and break the lock. This is a heuristic — safe when the critical\n * section is short-lived (sub-second for file I/O).\n *\n * ## Failure on timeout\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError is\n * thrown. This prevents the dangerous \"degraded mode\" where the critical section\n * runs without mutual exclusion, which can cause data loss (e.g., lost ID\n * mappings during concurrent `tbd create`).\n *\n * IMPORTANT: `timeoutMs` must be greater than `staleMs` so stale locks from\n * crashed processes are always detected and broken before the timeout expires.\n */\n\nimport { mkdir, rmdir, stat } from 'node:fs/promises';\n\n/** Options for `withLockfile`. */\nexport interface LockfileOptions {\n /** Maximum time (ms) to wait for the lock. Default: 10000 */\n timeoutMs?: number;\n /** Polling interval (ms) between acquisition attempts. Default: 50 */\n pollMs?: number;\n /** Age (ms) after which a lock is considered stale. Default: 5000 */\n staleMs?: number;\n}\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_POLL_MS = 50;\nconst DEFAULT_STALE_MS = 5_000;\n\n/**\n * Error thrown when the lock cannot be acquired within the timeout.\n */\nexport class LockAcquisitionError extends Error {\n constructor(lockPath: string, timeoutMs: number) {\n super(\n `Failed to acquire lock at ${lockPath} within ${timeoutMs}ms. ` +\n `Another process may be holding the lock. If this persists, ` +\n `delete the lock directory manually and retry.`,\n );\n this.name = 'LockAcquisitionError';\n }\n}\n\n/**\n * Execute `fn` while holding a lockfile.\n *\n * The lock is a directory at `lockPath` (typically `<target-file>.lock`).\n * Concurrent callers will wait up to `timeoutMs` for the lock, polling\n * every `pollMs`. Stale locks older than `staleMs` are broken automatically.\n *\n * If the lock cannot be acquired within the timeout, a LockAcquisitionError\n * is thrown. This ensures mutual exclusion is never silently bypassed, which\n * prevents data loss from concurrent writes.\n *\n * @param lockPath - Path to use as the lock directory (e.g., \"/path/to/ids.yml.lock\")\n * @param fn - Critical section to execute under the lock\n * @param options - Timing parameters for lock acquisition\n * @returns The return value of `fn`\n * @throws LockAcquisitionError if the lock cannot be acquired within the timeout\n *\n * @example\n * ```ts\n * await withLockfile('/path/to/ids.yml.lock', async () => {\n * const data = await readFile('/path/to/ids.yml', 'utf-8');\n * const updated = mergeEntries(data, newEntries);\n * await writeFile('/path/to/ids.yml', updated);\n * });\n * ```\n */\nexport async function withLockfile<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options?: LockfileOptions,\n): Promise<T> {\n const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const pollMs = options?.pollMs ?? DEFAULT_POLL_MS;\n const staleMs = options?.staleMs ?? DEFAULT_STALE_MS;\n\n const deadline = Date.now() + timeoutMs;\n let acquired = false;\n\n while (Date.now() < deadline) {\n try {\n // mkdir is atomic per POSIX.1-2017 — fails with EEXIST if already held\n await mkdir(lockPath);\n acquired = true;\n break;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n // Unexpected error (permissions, disk full, missing parent, etc.) —\n // preserve the original failure instead of misreporting lock contention.\n throw error;\n }\n\n // Lock exists — check if it's stale (holder likely crashed)\n try {\n const lockStat = await stat(lockPath);\n if (Date.now() - lockStat.mtimeMs > staleMs) {\n try {\n await rmdir(lockPath);\n } catch {\n // Another process may have already broken/released it\n }\n continue; // Retry immediately after breaking stale lock\n }\n } catch {\n // Lock was released between our mkdir and stat — retry immediately\n continue;\n }\n\n // Lock is fresh — wait and retry\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n }\n }\n\n if (!acquired) {\n throw new LockAcquisitionError(lockPath, timeoutMs);\n }\n\n try {\n return await fn();\n } finally {\n try {\n await rmdir(lockPath);\n } catch {\n // Best-effort cleanup; stale lock detection handles the rest\n }\n }\n}\n","/**\n * Natural sort utilities.\n *\n * Provides alphanumeric sorting where numeric portions are sorted numerically.\n * Similar to `sort -V` (version sort) or `sort -n` for numbers.\n *\n * Examples:\n * [\"1\", \"2\", \"9\", \"10\", \"11\"] instead of [\"1\", \"10\", \"11\", \"2\", \"9\"]\n * [\"a1\", \"a2\", \"a10\"] instead of [\"a1\", \"a10\", \"a2\"]\n * [\"file1.txt\", \"file2.txt\", \"file10.txt\"] instead of [\"file1.txt\", \"file10.txt\", \"file2.txt\"]\n */\n\n/**\n * Split a string into alternating runs of digits and non-digits.\n * @param str - The string to split\n * @returns Array of [isNumeric, value] tuples\n */\nfunction splitIntoChunks(str: string): [boolean, string][] {\n const chunks: [boolean, string][] = [];\n let current = '';\n let currentIsNumeric: boolean | null = null;\n\n for (const char of str) {\n const isDigit = char >= '0' && char <= '9';\n\n if (currentIsNumeric === null) {\n // First character\n currentIsNumeric = isDigit;\n current = char;\n } else if (isDigit === currentIsNumeric) {\n // Same type, continue accumulating\n current += char;\n } else {\n // Type changed, push current chunk and start new one\n chunks.push([currentIsNumeric, current]);\n currentIsNumeric = isDigit;\n current = char;\n }\n }\n\n // Push final chunk\n if (current) {\n chunks.push([currentIsNumeric!, current]);\n }\n\n return chunks;\n}\n\n/**\n * Compare two strings using natural (alphanumeric) ordering.\n *\n * Numeric portions are compared numerically, non-numeric portions are\n * compared lexicographically (case-insensitive). Numbers sort before letters\n * when they appear at the same position in mixed comparisons, matching\n * the behavior of `sort -V` (version sort).\n *\n * @param a - First string\n * @param b - Second string\n * @returns Negative if a < b, positive if a > b, zero if equal\n */\nexport function naturalCompare(a: string, b: string): number {\n // Handle empty strings\n if (!a && !b) return 0;\n if (!a) return -1;\n if (!b) return 1;\n\n const chunksA = splitIntoChunks(a);\n const chunksB = splitIntoChunks(b);\n\n const minLen = Math.min(chunksA.length, chunksB.length);\n\n for (let i = 0; i < minLen; i++) {\n const [isNumericA, valueA] = chunksA[i]!;\n const [isNumericB, valueB] = chunksB[i]!;\n\n if (isNumericA && isNumericB) {\n // Both are numeric - compare as numbers\n const numA = parseInt(valueA, 10);\n const numB = parseInt(valueB, 10);\n if (numA !== numB) {\n return numA - numB;\n }\n // If numerically equal but different strings (e.g., \"01\" vs \"1\"),\n // prefer shorter (fewer leading zeros)\n if (valueA.length !== valueB.length) {\n return valueA.length - valueB.length;\n }\n } else if (!isNumericA && !isNumericB) {\n // Both are non-numeric - compare lexicographically (case-insensitive)\n const lowerA = valueA.toLowerCase();\n const lowerB = valueB.toLowerCase();\n if (lowerA !== lowerB) {\n return lowerA.localeCompare(lowerB);\n }\n // Same when lowercased - they're equal for sorting purposes\n } else {\n // Mixed: numeric comes before non-numeric\n // (so \"1\" comes before \"a\" at the same position)\n // This matches `sort -V` behavior\n return isNumericA ? -1 : 1;\n }\n }\n\n // All compared chunks are equal, shorter string comes first\n return chunksA.length - chunksB.length;\n}\n\n/**\n * Sort an array of strings using natural (alphanumeric) ordering.\n *\n * @param arr - Array to sort\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSort(arr: readonly string[]): string[] {\n return [...arr].sort(naturalCompare);\n}\n\n/**\n * Sort an array of objects by a string key using natural ordering.\n *\n * @param arr - Array to sort\n * @param keyFn - Function to extract the sort key from each element\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSortBy<T>(arr: readonly T[], keyFn: (item: T) => string): T[] {\n return [...arr].sort((a, b) => naturalCompare(keyFn(a), keyFn(b)));\n}\n","/**\n * ID mapping management for short public IDs.\n *\n * Maps 4-char base36 short IDs to 26-char ULIDs.\n * Stored in .tbd/data-sync/mappings/ids.yml\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { readFile, mkdir } from 'node:fs/promises';\nimport { join, dirname } from 'node:path';\nimport { writeFile } from 'atomically';\n\nimport {\n parseYamlToleratingDuplicateKeys,\n stringifyYaml,\n hasMergeConflictMarkers,\n} from '../utils/yaml-utils.js';\nimport { withLockfile } from '../utils/lockfile.js';\n\nimport {\n generateShortId,\n extractUlidFromInternalId,\n makeInternalId,\n isInternalId,\n extractShortId,\n asInternalId,\n type InternalIssueId,\n} from '../lib/ids.js';\nimport { naturalSort } from '../lib/sort.js';\nimport { IdMappingYamlSchema } from '../lib/schemas.js';\n\n/**\n * ID mapping from short ID to ULID.\n * Format in ids.yml:\n * a7k2: 01hx5zzkbkactav9wevgemmvrz\n * b3m9: 01hx5zzkbkbctav9wevgemmvrz\n */\nexport interface IdMapping {\n shortToUlid: Map<string, string>;\n ulidToShort: Map<string, string>;\n}\n\n/**\n * Get the path to the ids.yml mapping file.\n */\nfunction getMappingPath(baseDir: string): string {\n return join(baseDir, 'mappings', 'ids.yml');\n}\n\n/**\n * Load the ID mapping from disk.\n * Returns empty mapping if file doesn't exist.\n */\nexport async function loadIdMapping(baseDir: string): Promise<IdMapping> {\n const filePath = getMappingPath(baseDir);\n\n let content: string;\n try {\n content = await readFile(filePath, 'utf-8');\n } catch {\n // File doesn't exist - return empty mapping\n return {\n shortToUlid: new Map(),\n ulidToShort: new Map(),\n };\n }\n\n // Parse tolerating duplicate keys — this handles the case where a git merge\n // conflict resolution kept entries from both sides, creating duplicate YAML keys.\n // Without this, the yaml parser throws \"Map keys must be unique\".\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(\n content,\n filePath,\n );\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ${filePath} contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `This usually happens after a git merge conflict resolution. ` +\n `The file will be auto-fixed on next save.`,\n );\n }\n\n // Validate with Zod schema - ensures all keys are valid short IDs and values are ULIDs\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Save the ID mapping to disk with mutual exclusion.\n *\n * Uses a lockfile to serialize concurrent writers, then performs read-merge-write\n * inside the lock. This prevents the lost-update problem when multiple `tbd create`\n * commands run in parallel.\n *\n * The merge is safe because ID mappings are append-only — entries are never\n * intentionally removed. If the lock cannot be acquired within the timeout,\n * a LockAcquisitionError is thrown rather than proceeding without protection.\n */\nexport async function saveIdMapping(baseDir: string, mapping: IdMapping): Promise<void> {\n const filePath = getMappingPath(baseDir);\n\n // Ensure directory exists\n await mkdir(dirname(filePath), { recursive: true });\n\n await withLockfile(filePath + '.lock', async () => {\n // Inside the lock: read current on-disk state, merge with our in-memory\n // mapping, and write the result. Our entries take precedence for short ID\n // conflicts (extremely unlikely with random 4-char base36 IDs).\n let merged = mapping;\n let onDiskSize = 0;\n try {\n const onDisk = await loadIdMappingRaw(filePath);\n onDiskSize = onDisk.shortToUlid.size;\n if (onDiskSize > 0) {\n merged = mergeIdMappings(mapping, onDisk);\n }\n } catch {\n // File doesn't exist or is unreadable — proceed with our mapping only\n }\n\n // Safety check: ID mappings are append-only. If the merged result has fewer\n // entries than what's on disk, something went wrong. Refuse to write so the\n // caller can investigate rather than silently destroying entries.\n if (merged.shortToUlid.size < onDiskSize) {\n throw new Error(\n `Refusing to save ID mapping: would lose ${onDiskSize - merged.shortToUlid.size} entries ` +\n `(on-disk: ${onDiskSize}, proposed: ${merged.shortToUlid.size}). ` +\n `ID mappings are append-only — this indicates a bug.`,\n );\n }\n\n const data: Record<string, string> = {};\n const sortedKeys = naturalSort(Array.from(merged.shortToUlid.keys()));\n for (const key of sortedKeys) {\n data[key] = merged.shortToUlid.get(key)!;\n }\n\n const content = stringifyYaml(data);\n await writeFile(filePath, content);\n });\n}\n\n/**\n * Load an ID mapping directly from a file path (internal helper for save merging).\n * Separated from loadIdMapping to avoid coupling the save path to baseDir resolution.\n */\nasync function loadIdMappingRaw(filePath: string): Promise<IdMapping> {\n const content = await readFile(filePath, 'utf-8');\n\n const { data: rawData } = parseYamlToleratingDuplicateKeys<unknown>(content, filePath);\n const data = rawData ?? {};\n\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format in ${filePath}: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Calculate the optimal short ID length based on existing ID count.\n *\n * At 50K issues, switches from 4-char to 5-char IDs to keep\n * collision probability low (~3% per attempt with 4 chars at 50K).\n *\n * With 10 retries per length, actual failure probability is astronomically low.\n */\nexport function calculateOptimalLength(existingCount: number): number {\n return existingCount < 50_000 ? 4 : 5;\n}\n\n/**\n * Generate a unique short ID that doesn't collide with existing ones.\n *\n * Calculates optimal length (4 or 5 chars) based on existing ID count,\n * then retries with the next length if collisions occur.\n *\n * @returns The new short ID\n * @throws If unable to generate a unique ID after max attempts\n */\nexport function generateUniqueShortId(mapping: IdMapping): string {\n const ATTEMPTS_PER_LENGTH = 10;\n const existingCount = mapping.shortToUlid.size;\n const optimalLength = calculateOptimalLength(existingCount);\n\n // Try optimal length first, then fall back to longer if needed\n for (const length of [optimalLength, optimalLength + 1]) {\n for (let attempt = 0; attempt < ATTEMPTS_PER_LENGTH; attempt++) {\n const shortId = generateShortId(length);\n if (!mapping.shortToUlid.has(shortId)) {\n return shortId;\n }\n }\n }\n\n throw new Error(\n `Failed to generate unique short ID after 20 attempts with ${existingCount} existing IDs. ` +\n `This should be extremely rare - please report if you see this error.`,\n );\n}\n\n/**\n * Register a new ID mapping.\n * @param ulid - The ULID (without is- prefix)\n * @param shortId - The short ID (4 chars)\n */\nexport function addIdMapping(mapping: IdMapping, ulid: string, shortId: string): void {\n mapping.shortToUlid.set(shortId, ulid);\n mapping.ulidToShort.set(ulid, shortId);\n}\n\n/**\n * Get the short ID for a ULID.\n * @param ulid - The ULID (without is- prefix)\n * @returns The short ID, or undefined if not found\n */\nexport function getShortId(mapping: IdMapping, ulid: string): string | undefined {\n return mapping.ulidToShort.get(ulid);\n}\n\n/**\n * Get the ULID for a short ID.\n * @param shortId - The short ID\n * @returns The ULID (without is- prefix), or undefined if not found\n */\nexport function getUlid(mapping: IdMapping, shortId: string): string | undefined {\n return mapping.shortToUlid.get(shortId);\n}\n\n/**\n * Check if a short ID exists in the mapping.\n */\nexport function hasShortId(mapping: IdMapping, shortId: string): boolean {\n return mapping.shortToUlid.has(shortId);\n}\n\n/**\n * Create a short ID mapping for a new internal ID.\n * Generates a unique short ID and registers it in the mapping.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - The ID mapping to update\n * @returns The generated short ID\n */\nexport function createShortIdMapping(internalId: string, mapping: IdMapping): string {\n // Extract ULID from internal ID (remove prefix)\n const ulid = extractUlidFromInternalId(internalId);\n\n // Check if already mapped\n const existing = mapping.ulidToShort.get(ulid);\n if (existing) {\n return existing;\n }\n\n // Generate unique short ID\n const shortId = generateUniqueShortId(mapping);\n\n // Register mapping\n addIdMapping(mapping, ulid, shortId);\n\n return shortId;\n}\n\n/**\n * Resolve any ID input to an internal ID ({prefix}-{ulid}).\n *\n * Handles:\n * - Internal IDs: {prefix}-{ulid} -> {prefix}-{ulid}\n * - Short IDs: a7k2 -> {prefix}-{ulid from mapping}\n * - Prefixed short IDs: bd-a7k2 -> {prefix}-{ulid from mapping}\n *\n * @param input - The ID input (short ID, prefixed short ID, or internal ID)\n * @param mapping - The ID mapping for short ID resolution\n * @returns The internal ID ({prefix}-{ulid})\n * @throws If the short ID is not found in the mapping\n */\nexport function resolveToInternalId(input: string, mapping: IdMapping): InternalIssueId {\n const lower = input.toLowerCase();\n\n // If it's already an internal ID, return it\n if (isInternalId(lower)) {\n return asInternalId(lower);\n }\n\n // Extract the short ID portion (strips any prefix like \"bd-\" or \"is-\")\n const shortId = extractShortId(lower);\n\n // If it's a full ULID (26 chars), it might be a bare internal ID\n if (shortId.length === 26 && /^[0-9a-z]{26}$/.test(shortId)) {\n return makeInternalId(shortId);\n }\n\n // Must be a short ID - look it up in the mapping\n const ulid = mapping.shortToUlid.get(shortId);\n if (!ulid) {\n throw new Error(`Unknown issue ID: ${input}. ` + `Short ID \"${shortId}\" not found in mapping.`);\n }\n\n return makeInternalId(ulid);\n}\n\n/**\n * Parse an ID mapping from raw YAML content.\n * Used for loading mappings from git show output during conflict resolution.\n *\n * @throws MergeConflictError if content contains merge conflict markers\n */\nexport function parseIdMappingFromYaml(content: string): IdMapping {\n // Parse tolerating duplicate keys — handles post-merge-conflict duplicates\n const { data: rawData, duplicateKeys } = parseYamlToleratingDuplicateKeys<unknown>(content);\n const data = rawData ?? {};\n\n if (duplicateKeys.length > 0) {\n console.warn(\n `Warning: ID mapping YAML contains ${duplicateKeys.length} duplicate key(s): ${duplicateKeys.join(', ')}. ` +\n `Duplicates will be auto-resolved.`,\n );\n }\n\n // Validate with Zod schema\n const parseResult = IdMappingYamlSchema.safeParse(data);\n if (!parseResult.success) {\n throw new Error(`Invalid ID mapping format: ${parseResult.error.message}`);\n }\n const validData = parseResult.data;\n\n const shortToUlid = new Map<string, string>();\n const ulidToShort = new Map<string, string>();\n\n for (const [shortId, ulid] of Object.entries(validData)) {\n shortToUlid.set(shortId, ulid);\n ulidToShort.set(ulid, shortId);\n }\n\n return { shortToUlid, ulidToShort };\n}\n\n/**\n * Ensure all given internal IDs have short ID mappings.\n * Creates missing mappings for any IDs without entries.\n *\n * This repairs state after git merges that may add issue files\n * without corresponding mapping entries (e.g., when outbox issues\n * are merged from a feature branch but ids.yml doesn't include them).\n *\n * When a `historicalMapping` is provided, the function will try to recover\n * the original short ID from that mapping before generating a new random one.\n * This preserves ID stability so that existing references (in docs, PRs,\n * conversations) remain valid.\n *\n * @param internalIds - Array of internal IDs (is-{ulid}) to reconcile\n * @param mapping - The ID mapping to update (mutated in-place)\n * @param historicalMapping - Optional mapping from prior state (e.g., git history) to recover original short IDs\n * @returns Object with `created` (IDs that got new random short IDs) and `recovered` (IDs restored from history)\n */\nexport function reconcileMappings(\n internalIds: string[],\n mapping: IdMapping,\n historicalMapping?: IdMapping,\n): { created: string[]; recovered: string[] } {\n const created: string[] = [];\n const recovered: string[] = [];\n\n for (const id of internalIds) {\n const ulid = extractUlidFromInternalId(id);\n if (mapping.ulidToShort.has(ulid)) {\n continue; // Already has a mapping\n }\n\n // Try to recover original short ID from historical mapping\n const historicalShortId = historicalMapping?.ulidToShort.get(ulid);\n if (historicalShortId && !mapping.shortToUlid.has(historicalShortId)) {\n // Recovered: restore the original short ID\n addIdMapping(mapping, ulid, historicalShortId);\n recovered.push(id);\n } else {\n // No history available or short ID conflicts — generate new random one\n createShortIdMapping(id, mapping);\n created.push(id);\n }\n }\n\n return { created, recovered };\n}\n\n/**\n * Merge two ID mappings by combining all entries from both.\n * ID mappings are always additive (new IDs are only added, never removed),\n * so merging simply unions all key-value pairs.\n *\n * If the same short ID maps to different ULIDs in each mapping (a conflict),\n * the local mapping takes precedence (caller should log a warning).\n *\n * @param local - The local ID mapping\n * @param remote - The remote ID mapping\n * @returns Merged mapping with all entries from both\n */\nexport function mergeIdMappings(local: IdMapping, remote: IdMapping): IdMapping {\n const merged: IdMapping = {\n shortToUlid: new Map(local.shortToUlid),\n ulidToShort: new Map(local.ulidToShort),\n };\n\n // Add all remote entries that don't conflict\n for (const [shortId, ulid] of remote.shortToUlid) {\n if (!merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n // If shortId already exists with different ulid, keep local (conflict resolution)\n }\n\n // Also check for ULIDs that exist in remote but not in local\n // (different short ID for same ULID - shouldn't happen but handle gracefully)\n for (const [ulid, shortId] of remote.ulidToShort) {\n if (!merged.ulidToShort.has(ulid) && !merged.shortToUlid.has(shortId)) {\n merged.shortToUlid.set(shortId, ulid);\n merged.ulidToShort.set(ulid, shortId);\n }\n }\n\n return merged;\n}\n\n/**\n * Resolve merge conflicts in ids.yml content by extracting both sides and merging.\n *\n * ids.yml is a sorted key-value YAML map where entries are append-only.\n * The most common merge conflict is both sides adding non-overlapping keys,\n * which is trivially auto-resolvable by keeping all entries from both sides.\n *\n * @param content - Raw file content that may contain git merge conflict markers\n * @returns Merged IdMapping with entries from both sides\n */\nexport function resolveIdMappingConflicts(content: string): IdMapping {\n if (!hasMergeConflictMarkers(content)) {\n return parseIdMappingFromYaml(content);\n }\n\n const lines = content.split('\\n');\n const oursLines: string[] = [];\n const theirsLines: string[] = [];\n let inConflict: 'none' | 'ours' | 'theirs' = 'none';\n\n for (const line of lines) {\n if (line.startsWith('<<<<<<< ')) {\n inConflict = 'ours';\n continue;\n }\n if (line === '=======' && inConflict === 'ours') {\n inConflict = 'theirs';\n continue;\n }\n if (line.startsWith('>>>>>>> ') && inConflict === 'theirs') {\n inConflict = 'none';\n continue;\n }\n\n if (inConflict === 'none') {\n oursLines.push(line);\n theirsLines.push(line);\n } else if (inConflict === 'ours') {\n oursLines.push(line);\n } else {\n theirsLines.push(line);\n }\n }\n\n const oursMapping = parseIdMappingFromYaml(oursLines.join('\\n'));\n const theirsMapping = parseIdMappingFromYaml(theirsLines.join('\\n'));\n\n return mergeIdMappings(oursMapping, theirsMapping);\n}\n"],"mappings":"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"}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as hasShortId, c as parseIdMappingFromYaml, d as resolveToInternalId, f 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 resolveIdMappingConflicts } from "./id-mapping-
|
|
1
|
+
import { a as hasShortId, c as parseIdMappingFromYaml, d as resolveToInternalId, f 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 resolveIdMappingConflicts } from "./id-mapping-CqrrLgeX.mjs";
|
|
2
2
|
|
|
3
3
|
export { loadIdMapping, mergeIdMappings, parseIdMappingFromYaml, reconcileMappings, resolveIdMappingConflicts, saveIdMapping };
|
package/dist/index.d.mts
CHANGED
|
@@ -75,6 +75,18 @@ declare const IssueStatus: z.ZodEnum<["open", "in_progress", "blocked", "deferre
|
|
|
75
75
|
* Note: CLI uses --type flag, which maps to this `kind` field.
|
|
76
76
|
*/
|
|
77
77
|
declare const IssueKind: z.ZodEnum<["bug", "feature", "task", "epic", "chore"]>;
|
|
78
|
+
/**
|
|
79
|
+
* Maximum issue title length before detail belongs in the description body.
|
|
80
|
+
*/
|
|
81
|
+
declare const ISSUE_TITLE_MAX_LENGTH = 500;
|
|
82
|
+
/**
|
|
83
|
+
* Maximum issue body section length to keep issue files practical to review and sync.
|
|
84
|
+
*/
|
|
85
|
+
declare const ISSUE_BODY_MAX_LENGTH = 50000;
|
|
86
|
+
/**
|
|
87
|
+
* Issue title text as persisted in issue files.
|
|
88
|
+
*/
|
|
89
|
+
declare const IssueTitle: z.ZodString;
|
|
78
90
|
/**
|
|
79
91
|
* Priority: 0 (highest/critical) to 4 (lowest).
|
|
80
92
|
*/
|
|
@@ -646,5 +658,5 @@ declare function serializeIssue(issue: Issue): string;
|
|
|
646
658
|
*/
|
|
647
659
|
declare const VERSION: string;
|
|
648
660
|
//#endregion
|
|
649
|
-
export { ATTIC_ENTRY_FIELD_ORDER, AtticEntry, AtticEntrySchema, BaseEntity, CONFIG_FIELD_ORDER, Config, ConfigSchema, CreateIssueOptions, Dependency, DependencyRelationType, DependencyType, DocCacheConfigSchema, DocSection, DocsCacheSchema, EntityType, ExternalIssueIdInput, GitBranchName, GitRemoteName, ISSUE_FIELD_ORDER, IdMappingYamlSchema, Issue, IssueId, IssueKind, IssueKindType, IssueSchema, IssueStatus, IssueStatusType, LOCAL_STATE_FIELD_ORDER, ListIssuesOptions, LocalState, LocalStateSchema, META_FIELD_ORDER, Meta, MetaSchema, OperationLogger, Priority, PriorityType, SearchIssuesOptions, ShortId, Timestamp, Ulid, UpdateIssueOptions, VERSION, Version, noopLogger, parseIssue, serializeIssue };
|
|
661
|
+
export { ATTIC_ENTRY_FIELD_ORDER, AtticEntry, AtticEntrySchema, BaseEntity, CONFIG_FIELD_ORDER, Config, ConfigSchema, CreateIssueOptions, Dependency, DependencyRelationType, DependencyType, DocCacheConfigSchema, DocSection, DocsCacheSchema, EntityType, ExternalIssueIdInput, GitBranchName, GitRemoteName, ISSUE_BODY_MAX_LENGTH, ISSUE_FIELD_ORDER, ISSUE_TITLE_MAX_LENGTH, IdMappingYamlSchema, Issue, IssueId, IssueKind, IssueKindType, IssueSchema, IssueStatus, IssueStatusType, IssueTitle, LOCAL_STATE_FIELD_ORDER, ListIssuesOptions, LocalState, LocalStateSchema, META_FIELD_ORDER, Meta, MetaSchema, OperationLogger, Priority, PriorityType, SearchIssuesOptions, ShortId, Timestamp, Ulid, UpdateIssueOptions, VERSION, Version, noopLogger, parseIssue, serializeIssue };
|
|
650
662
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-
|
|
1
|
+
import { A as Ulid, C as LOCAL_STATE_FIELD_ORDER, D as Priority, E as MetaSchema, O as ShortId, S as IssueTitle, T as META_FIELD_ORDER, _ as IdMappingYamlSchema, a as ConfigSchema, b as IssueSchema, c as DocCacheConfigSchema, d as ExternalIssueIdInput, f as GitBranchName, g as ISSUE_TITLE_MAX_LENGTH, h as ISSUE_FIELD_ORDER, i as CONFIG_FIELD_ORDER, j as Version, k as Timestamp, l as DocsCacheSchema, m as ISSUE_BODY_MAX_LENGTH, n as AtticEntrySchema, o as Dependency, p as GitRemoteName, r as BaseEntity, s as DependencyRelationType, t as ATTIC_ENTRY_FIELD_ORDER, u as EntityType, v as IssueId, w as LocalStateSchema, x as IssueStatus, y as IssueKind } from "./schemas-C8mOQykE.mjs";
|
|
2
|
+
import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-D2xEmH4L.mjs";
|
|
3
3
|
|
|
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 };
|
|
4
|
+
export { ATTIC_ENTRY_FIELD_ORDER, AtticEntrySchema, BaseEntity, CONFIG_FIELD_ORDER, ConfigSchema, Dependency, DependencyRelationType, DocCacheConfigSchema, DocsCacheSchema, EntityType, ExternalIssueIdInput, GitBranchName, GitRemoteName, ISSUE_BODY_MAX_LENGTH, ISSUE_FIELD_ORDER, ISSUE_TITLE_MAX_LENGTH, IdMappingYamlSchema, IssueId, IssueKind, IssueSchema, IssueStatus, IssueTitle, LOCAL_STATE_FIELD_ORDER, LocalStateSchema, META_FIELD_ORDER, MetaSchema, Priority, ShortId, Timestamp, Ulid, VERSION, Version, noopLogger, parseIssue, serializeIssue };
|
|
@@ -80,6 +80,18 @@ const IssueKind = z.enum([
|
|
|
80
80
|
"chore"
|
|
81
81
|
]);
|
|
82
82
|
/**
|
|
83
|
+
* Maximum issue title length before detail belongs in the description body.
|
|
84
|
+
*/
|
|
85
|
+
const ISSUE_TITLE_MAX_LENGTH = 500;
|
|
86
|
+
/**
|
|
87
|
+
* Maximum issue body section length to keep issue files practical to review and sync.
|
|
88
|
+
*/
|
|
89
|
+
const ISSUE_BODY_MAX_LENGTH = 5e4;
|
|
90
|
+
/**
|
|
91
|
+
* Issue title text as persisted in issue files.
|
|
92
|
+
*/
|
|
93
|
+
const IssueTitle = z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH);
|
|
94
|
+
/**
|
|
83
95
|
* Priority: 0 (highest/critical) to 4 (lowest).
|
|
84
96
|
*/
|
|
85
97
|
const Priority = z.number().int().min(0).max(4);
|
|
@@ -113,12 +125,12 @@ const Dependency = z.object({
|
|
|
113
125
|
*/
|
|
114
126
|
const IssueSchema = BaseEntity.extend({
|
|
115
127
|
type: z.literal("is"),
|
|
116
|
-
title:
|
|
128
|
+
title: IssueTitle,
|
|
117
129
|
kind: IssueKind.default("task"),
|
|
118
130
|
status: IssueStatus.default("open"),
|
|
119
131
|
priority: Priority.default(2),
|
|
120
|
-
description: z.string().max(
|
|
121
|
-
notes: z.string().max(
|
|
132
|
+
description: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),
|
|
133
|
+
notes: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),
|
|
122
134
|
spec_path: z.string().nullable().optional(),
|
|
123
135
|
assignee: z.string().nullable().optional(),
|
|
124
136
|
labels: z.array(z.string()).default([]),
|
|
@@ -307,5 +319,5 @@ const LOCAL_STATE_FIELD_ORDER = [
|
|
|
307
319
|
];
|
|
308
320
|
|
|
309
321
|
//#endregion
|
|
310
|
-
export {
|
|
311
|
-
//# sourceMappingURL=schemas-
|
|
322
|
+
export { Ulid as A, LOCAL_STATE_FIELD_ORDER as C, Priority as D, MetaSchema as E, ShortId as O, IssueTitle as S, META_FIELD_ORDER as T, IdMappingYamlSchema as _, ConfigSchema as a, IssueSchema as b, DocCacheConfigSchema as c, ExternalIssueIdInput as d, GitBranchName as f, ISSUE_TITLE_MAX_LENGTH as g, ISSUE_FIELD_ORDER as h, CONFIG_FIELD_ORDER as i, Version as j, Timestamp as k, DocsCacheSchema as l, ISSUE_BODY_MAX_LENGTH as m, AtticEntrySchema as n, Dependency as o, GitRemoteName as p, BaseEntity as r, DependencyRelationType as s, ATTIC_ENTRY_FIELD_ORDER as t, EntityType as u, IssueId as v, LocalStateSchema as w, IssueStatus as x, IssueKind as y };
|
|
323
|
+
//# sourceMappingURL=schemas-C8mOQykE.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemas-C8mOQykE.mjs","names":[],"sources":["../src/lib/schemas.ts"],"sourcesContent":["/**\n * Zod schemas for tbd entities.\n *\n * These schemas are the normative specification for the file format.\n * See: tbd-design.md §2.6 Schemas\n */\n\nimport { z } from 'zod';\n\n// =============================================================================\n// Common Types (§2.6.1)\n// =============================================================================\n\n/**\n * ISO8601 timestamp with Z suffix (UTC).\n */\nexport const Timestamp = z.string().datetime();\n\n/**\n * Issue ID: prefix + 26 lowercase alphanumeric characters (ULID format).\n * Format: is-{ulid} where ulid is 26 chars (a-z, 0-9).\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n */\nexport const IssueId = z.string().regex(/^is-[0-9a-z]{26}$/);\n\n/**\n * Short ID: 1+ base36 characters used for external/display IDs.\n * Typically 4 chars for new IDs (e.g., a7k2, b3m9).\n * Imports may preserve longer numeric IDs (e.g., \"100\" from \"tbd-100\").\n * Legacy imports may include dots (e.g., \"208.1\" from hierarchical numbering).\n * Imports may include dashes/underscores (e.g., \"stat-in_progress\" from JSONL).\n */\nexport const ShortId = z.string().regex(/^[0-9a-z._-]+$/);\n\n/**\n * ULID: 26 lowercase alphanumeric characters.\n * Used in internal IDs and ID mappings.\n * Example: 01hx5zzkbkactav9wevgemmvrz\n */\nexport const Ulid = z.string().regex(/^[0-9a-z]{26}$/);\n\n/**\n * External Issue ID input: accepts {prefix}-{short} or just {short}.\n * Examples: bd-a7k2, a7k2, bd-100, 100\n */\nexport const ExternalIssueIdInput = z.string().regex(/^([a-z]+-)?[0-9a-z]+$/);\n\n/**\n * Edit counter - incremented on every local change.\n * NOTE: Version is NOT used for conflict detection (Git push rejection is used).\n * Content hash is used as tiebreaker during merge resolution.\n * Version is informational only - set to max(local, remote) + 1 after merges.\n */\nexport const Version = z.number().int().nonnegative();\n\n/**\n * Entity type discriminator.\n */\nexport const EntityType = z.literal('is');\n\n// =============================================================================\n// BaseEntity (§2.6.2)\n// =============================================================================\n\n/**\n * All entities share common fields.\n */\nexport const BaseEntity = z.object({\n type: EntityType,\n id: IssueId,\n version: Version,\n created_at: Timestamp,\n updated_at: Timestamp,\n\n // Extensibility namespace for third-party data\n extensions: z.record(z.string(), z.unknown()).optional(),\n});\n\n// =============================================================================\n// Issue Schema (§2.6.3)\n// =============================================================================\n\n/**\n * Issue status values matching Beads.\n */\nexport const IssueStatus = z.enum(['open', 'in_progress', 'blocked', 'deferred', 'closed']);\n\n/**\n * Issue kind/type values matching Beads.\n * Note: CLI uses --type flag, which maps to this `kind` field.\n */\nexport const IssueKind = z.enum(['bug', 'feature', 'task', 'epic', 'chore']);\n\n/**\n * Maximum issue title length before detail belongs in the description body.\n */\nexport const ISSUE_TITLE_MAX_LENGTH = 500;\n\n/**\n * Maximum issue body section length to keep issue files practical to review and sync.\n */\nexport const ISSUE_BODY_MAX_LENGTH = 50_000;\n\n/**\n * Issue title text as persisted in issue files.\n */\nexport const IssueTitle = z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH);\n\n/**\n * Priority: 0 (highest/critical) to 4 (lowest).\n */\nexport const Priority = z.number().int().min(0).max(4);\n\n/**\n * Dependency types - only \"blocks\" supported initially.\n */\nexport const DependencyRelationType = z.enum(['blocks']);\n\n/**\n * A dependency relationship.\n */\nexport const Dependency = z.object({\n type: DependencyRelationType,\n target: IssueId,\n});\n\n/**\n * Full issue schema.\n *\n * Field order is canonical and mirrored by ISSUE_FIELD_ORDER below:\n * type, id, title, kind, status, priority, version (the \"header seven\"),\n * then linkages, assignment, hierarchy, scheduling, provenance,\n * timestamps, lifecycle, and extensions.\n *\n * Note: Fields use .nullable() in addition to .optional() because\n * YAML parses `field: null` as JavaScript null, not undefined.\n *\n * Design note: We could add the short ID to this schema. We didn't originally\n * because it's one more field to maintain consistency around across files.\n * Having it here might make recovery of lost ID mappings far easier, but for\n * now we have more reliable management of the mappings file (ids.yml) and\n * consider it authoritative. See IdMappingYamlSchema (§2.6.8).\n */\nexport const IssueSchema = BaseEntity.extend({\n // Header seven: the fields you always want to see at a glance\n type: z.literal('is'),\n // id, version inherited from BaseEntity\n title: IssueTitle,\n kind: IssueKind.default('task'),\n status: IssueStatus.default('open'),\n priority: Priority.default(2),\n\n // Body content (serialized outside frontmatter)\n description: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),\n notes: z.string().max(ISSUE_BODY_MAX_LENGTH).nullable().optional(),\n\n // Linkages\n spec_path: z.string().nullable().optional(),\n\n // Assignment and categorization\n assignee: z.string().nullable().optional(),\n labels: z.array(z.string()).default([]),\n dependencies: z.array(Dependency).default([]),\n\n // Hierarchical issues\n parent_id: IssueId.nullable().optional(),\n\n // Child ordering hints - soft ordering for children under this parent.\n // Array of internal IssueIds in preferred display order.\n // May contain stale IDs; display logic filters for actual children.\n child_order_hints: z.array(IssueId).nullable().optional(),\n\n // Scheduling\n due_date: Timestamp.nullable().optional(),\n deferred_until: Timestamp.nullable().optional(),\n\n // Provenance and lifecycle\n created_by: z.string().nullable().optional(),\n closed_at: Timestamp.nullable().optional(),\n close_reason: z.string().nullable().optional(),\n});\n\n// =============================================================================\n// Config Schema (§2.6.4)\n// =============================================================================\n\n/**\n * Git branch name - restricted to safe characters.\n * Allows: alphanumeric, hyphens, underscores, forward slashes, and dots.\n * Prevents shell injection in git commands.\n */\nexport const GitBranchName = z\n .string()\n .min(1)\n .max(255)\n .regex(\n /^[a-zA-Z0-9._/-]+$/,\n 'Invalid branch name: only alphanumeric, dots, underscores, hyphens, and slashes allowed',\n );\n\n/**\n * Git remote name - restricted to safe characters.\n * Allows: alphanumeric, hyphens, underscores, and dots.\n * Prevents shell injection in git commands.\n */\nexport const GitRemoteName = z\n .string()\n .min(1)\n .max(255)\n .regex(\n /^[a-zA-Z0-9._-]+$/,\n 'Invalid remote name: only alphanumeric, dots, underscores, and hyphens allowed',\n );\n\n/**\n * Doc cache configuration - maps destination paths to source locations.\n *\n * Keys are destination paths relative to .tbd/docs/ (e.g., \"shortcuts/standard/code-review-and-commit.md\")\n * Values are source locations:\n * - internal: prefix for bundled docs (e.g., \"internal:shortcuts/standard/code-review-and-commit.md\")\n * - Full URL for external docs (e.g., \"https://raw.githubusercontent.com/org/repo/main/file.md\")\n *\n * Example:\n * ```yaml\n * doc_cache:\n * shortcuts/standard/code-review-and-commit.md: internal:shortcuts/standard/code-review-and-commit.md\n * shortcuts/custom/my-shortcut.md: https://raw.githubusercontent.com/org/repo/main/shortcuts/my-shortcut.md\n * ```\n */\nexport const DocCacheConfigSchema = z.record(z.string(), z.string());\n\n/**\n * Documentation cache configuration (consolidated structure).\n *\n * Combines file sync mappings and lookup paths into a single config block.\n * See: docs/project/specs/active/plan-2026-01-26-docs-cache-config-restructure.md\n */\nexport const DocsCacheSchema = z.object({\n /**\n * Files to sync: maps destination paths to source locations.\n * Keys are destination paths relative to .tbd/docs/\n * Values are source locations:\n * - internal: prefix for bundled docs (e.g., \"internal:shortcuts/standard/code-review-and-commit.md\")\n * - Full URL for external docs (e.g., \"https://raw.githubusercontent.com/org/repo/main/file.md\")\n */\n files: z.record(z.string(), z.string()).optional(),\n /**\n * Search paths for doc lookup (like shell $PATH).\n * Earlier paths take precedence when names conflict.\n */\n lookup_path: z\n .array(z.string())\n .default(['.tbd/docs/shortcuts/system', '.tbd/docs/shortcuts/standard']),\n});\n\n/**\n * Project configuration stored in .tbd/config.yml\n *\n * ⚠️ FORMAT VERSIONING: See tbd-format.ts for version history and migration rules.\n * The tbd_format field tracks breaking changes to this schema.\n *\n * ⚠️ FORWARD COMPATIBILITY POLICY:\n * This schema uses Zod's default strip() mode, which discards unknown fields.\n * To prevent data loss when users mix tbd versions:\n *\n * 1. **When changing config schema (adding, removing, or modifying fields), ALWAYS\n * bump the format version** (e.g., f03 → f04)\n * 2. Older tbd versions will error when they see an unknown format version\n * 3. The error message tells users to upgrade tbd\n *\n * This ensures older versions fail fast rather than silently corrupting config.\n * The format version check happens in config.ts via isCompatibleFormat().\n *\n * See tbd-format.ts for format version history and migration rules.\n */\nexport const ConfigSchema = z.object({\n /**\n * Format version for the .tbd/ directory structure.\n * See tbd-format.ts for version history and migration rules.\n * Only bumped for breaking changes that require migration.\n */\n tbd_format: z.string().default('f01'),\n\n tbd_version: z.string(),\n sync: z\n .object({\n branch: GitBranchName.default('tbd-sync'),\n remote: GitRemoteName.default('origin'),\n })\n .default({}),\n display: z.object({\n id_prefix: z.string().min(1).max(20), // Required: set during init --prefix or import\n }),\n settings: z\n .object({\n auto_sync: z.boolean().default(false),\n /**\n * How often to automatically sync documentation cache (in hours).\n * - Default: 24 (sync once per day when actively using tbd)\n * - Set to 0 to disable auto-sync\n * - Only triggers when accessing docs (shortcut, guidelines, template commands)\n */\n doc_auto_sync_hours: z.number().default(24),\n /**\n * Whether to install the ensure-gh-cli.sh hook script during setup.\n * When true (default), `tbd setup` installs a SessionStart hook that\n * ensures the GitHub CLI is available in agent sessions.\n * Set to false or use `tbd setup --no-gh-cli` to disable.\n */\n use_gh_cli: z.boolean().default(true),\n })\n .default({}),\n /**\n * Documentation cache configuration (consolidated).\n * Contains files to sync and lookup paths.\n * See DocsCacheSchema for structure details.\n */\n docs_cache: DocsCacheSchema.optional(),\n});\n\n// =============================================================================\n// Meta Schema (§2.6.5)\n// =============================================================================\n\n/**\n * Shared metadata stored in .tbd/data-sync/meta.yml\n */\nexport const MetaSchema = z.object({\n schema_version: z.number().int(),\n created_at: Timestamp,\n});\n\n// =============================================================================\n// Local State Schema (§2.6.6)\n// =============================================================================\n\n/**\n * Per-node state stored in .tbd/state.yml (gitignored).\n * Tracks local timing information that shouldn't be shared across nodes.\n */\nexport const LocalStateSchema = z.object({\n /** When this node last synced issues successfully */\n last_sync_at: Timestamp.optional(),\n /** When this node last synced the doc cache successfully */\n last_doc_sync_at: Timestamp.optional(),\n /** Whether the user has seen the welcome message */\n welcome_seen: z.boolean().optional(),\n});\n\n// =============================================================================\n// Attic Entry Schema (§2.6.7)\n// =============================================================================\n\n/**\n * Preserved conflict losers.\n */\nexport const AtticEntrySchema = z.object({\n entity_id: IssueId,\n timestamp: Timestamp,\n field: z.string(),\n lost_value: z.string(),\n winner_source: z.enum(['local', 'remote']),\n loser_source: z.enum(['local', 'remote']),\n context: z.object({\n local_version: Version,\n remote_version: Version,\n local_updated_at: Timestamp,\n remote_updated_at: Timestamp,\n }),\n});\n\n// =============================================================================\n// ID Mapping Schema (§2.6.8)\n// =============================================================================\n\n/**\n * ID mapping YAML file schema for ids.yml.\n * Maps short IDs to ULIDs.\n * Format: { \"a7k2\": \"01hx5zzkbkactav9wevgemmvrz\", ... }\n */\nexport const IdMappingYamlSchema = z.record(ShortId, Ulid);\n\n// =============================================================================\n// Field Order Constants for YAML Serialization\n// =============================================================================\n//\n// Each array defines the canonical field order for YAML output.\n// Order mirrors the Zod schema definition above: identity first,\n// human-relevant fields next, bookkeeping last.\n//\n// Used with sortKeys() from yaml-utils.ts and ordering.manual()\n// from comparison-chain.ts. Fields not listed sort to the end.\n\n/**\n * Canonical field order for issue YAML frontmatter.\n * (description and notes are body content, not frontmatter)\n */\nexport const ISSUE_FIELD_ORDER = [\n // Header seven: the fields you always want to see at a glance\n 'type',\n 'id',\n 'title',\n 'kind',\n 'status',\n 'priority',\n 'version',\n\n // Linkages\n 'spec_path',\n\n // Assignment and categorization\n 'assignee',\n 'labels',\n 'dependencies',\n\n // Hierarchy\n 'parent_id',\n 'child_order_hints',\n\n // Scheduling\n 'due_date',\n 'deferred_until',\n\n // Provenance\n 'created_by',\n\n // Timestamps\n 'created_at',\n 'updated_at',\n\n // Lifecycle (closure)\n 'closed_at',\n 'close_reason',\n\n // Extensibility\n 'extensions',\n] as const;\n\n/**\n * Canonical field order for config YAML.\n */\nexport const CONFIG_FIELD_ORDER = [\n 'tbd_format',\n 'tbd_version',\n 'display',\n 'sync',\n 'settings',\n 'docs_cache',\n] as const;\n\n/**\n * Canonical field order for attic entry YAML.\n */\nexport const ATTIC_ENTRY_FIELD_ORDER = [\n 'entity_id',\n 'timestamp',\n 'field',\n 'lost_value',\n 'winner_source',\n 'loser_source',\n 'context',\n] as const;\n\n/**\n * Canonical field order for meta YAML.\n */\nexport const META_FIELD_ORDER = ['schema_version', 'created_at'] as const;\n\n/**\n * Canonical field order for local state YAML.\n */\nexport const LOCAL_STATE_FIELD_ORDER = [\n 'last_sync_at',\n 'last_doc_sync_at',\n 'welcome_seen',\n] as const;\n"],"mappings":"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"}
|