get-tbd 0.2.0 → 0.2.1

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.
@@ -144,7 +144,7 @@ working branch. See `tbd guidelines tbd-sync-troubleshooting` for details.
144
144
 
145
145
  | Command | Purpose |
146
146
  | --- | --- |
147
- | `tbd create "title" --type task\|bug\|feature --priority=P2` | New bead (P0-P4, not “high/medium/low”) |
147
+ | `tbd create "title" --type=bug --priority=1` | New bead; run `tbd create --help` for all types and priorities (P0-P4, not “high/medium/low”) |
148
148
  | `tbd update <id> --status in_progress` | Claim work |
149
149
  | `tbd close <id> [--reason "..."]` | Mark complete |
150
150
 
@@ -182,6 +182,6 @@ working branch. See `tbd guidelines tbd-sync-troubleshooting` for details.
182
182
  ## Quick Reference
183
183
 
184
184
  - **Priority**: P0=critical, P1=high, P2=medium (default), P3=low, P4=backlog
185
- - **Types**: task, bug, feature, epic
185
+ - **Types**: issues default to `task`; run `tbd create --help` for the valid types
186
186
  - **Status**: open, in_progress, closed
187
187
  - **JSON output**: Add `--json` to any command
@@ -100,6 +100,15 @@ So:
100
100
  Stop here unless you have many subcommands or need cross-session state, structured
101
101
  auth, or background services — then see §6 (CLI-as-skill) and §7 (MCP).
102
102
 
103
+ > **The skill points; the CLI documents.** A CLI’s `--help` (and per-command
104
+ > `mycli <cmd> --help`) is the source of truth for flags, arguments, and exact command
105
+ > sequences. The skill’s job is to name each capability and the command that reaches it,
106
+ > then let the agent open the CLI’s own help for the details.
107
+ > A skill carries the focused context an agent needs to judge that the tool is relevant;
108
+ > copying its help text, flag tables, and command recipes wholesale is the most common
109
+ > way a CLI-backed skill goes wrong.
110
+ > See §3.1 for why and §6.5 for how to avoid it.
111
+
103
112
  ### 0.3 The one-paragraph decision guide
104
113
 
105
114
  - **Prompt/instructions only** → ship a `SKILL.md`. (§3, §4)
@@ -231,6 +240,17 @@ material (schemas, examples, scripts) in supporting files.
231
240
  Scripts execute *outside* the context window — only their output costs tokens, which is
232
241
  why bundling a script can be far cheaper than inlining instructions.
233
242
 
243
+ **For a CLI-backed skill, the CLI itself is the Level-3 layer.** Treat `mycli --help`,
244
+ `mycli <command> --help`, and the tool’s informational subcommands (§6.1) as the
245
+ on-demand disclosure tier, the role supporting files play for a prompt-only skill.
246
+ The body (Level 2) should route to them, not transcribe them.
247
+ Inlining a command’s flags, arguments, or step-by-step usage pulls Level-3 detail up
248
+ into Level 2, so it loads on every activation and goes stale whenever the CLI changes.
249
+ The body names the capability and the command; the agent runs that command’s `--help` or
250
+ `--list` when it needs the mechanics.
251
+ This is progressive disclosure applied to a CLI: the tool documents itself, and the
252
+ skill stays a thin pointer to it.
253
+
234
254
  ### 3.2 Bundled scripts and resources
235
255
 
236
256
  A skill folder can ship executable helpers:
@@ -552,6 +572,37 @@ Rules: reference commands **explicitly** (`mycli command arg`, never “see the
552
572
  (e.g., `mycli prime`), and a “Getting Started” one-liner.
553
573
  - **A `prime` command** (dashboard, status, and rules) for session start and
554
574
  post-compact, distinct from `skill` (pure documentation).
575
+ `prime` is **read-only**: it restores context but does not rewrite project files.
576
+ Because a `SessionStart` hook runs it on every session (§8), have `prime` print a
577
+ short reminder that the agent or user can run `setup` to refresh skills and settings
578
+ (for example after upgrading the tool).
579
+ That keeps the refresh **opt-in** rather than silently mutating the repo from a hook,
580
+ while still nudging stale installs forward.
581
+
582
+ **Route, don’t restate: the skill is a thin pointer, not a copy of `--help`.** The most
583
+ common failure when packaging a CLI as a skill is over-documentation, where the author
584
+ (often an LLM, eagerly) copies the CLI’s help, flag lists, and command recipes wholesale
585
+ into the skill body.
586
+ Don’t. A self-documenting CLI already carries that detail in `mycli --help`,
587
+ `mycli <command> --help`, and its informational subcommands (§6.1), so duplicating it
588
+ only adds cost and drift (§3.1). The skill is a **knowledge, awareness, and routing
589
+ layer**: it carries the focused context an agent needs to judge that the tool is
590
+ relevant, names each key use case once, and gives the one command that reaches it, then
591
+ trusts the agent to run that command (or its `--help`) for the specifics.
592
+
593
+ | In the skill body (awareness and routing) | In the CLI’s own help (mechanics) |
594
+ | --- | --- |
595
+ | Each key capability or use case, named once | The full flag and argument reference |
596
+ | The single command that reaches it (`mycli create`, `mycli guidelines <name>`) | Exact option syntax, defaults, and edge cases |
597
+ | When to use the tool, and which command for which intent | Step-by-step recipes for a specific command |
598
+ | Pointers: `mycli --help`, `mycli <cmd> --help`, `mycli <cmd> --list` | Examples and output formats |
599
+
600
+ A quick test: if a line in the skill would become *wrong* when you add a flag or change
601
+ behavior in the CLI, it belongs in the CLI’s help, not the skill.
602
+ Mention every key use case, but push the “how exactly” (the sequence of commands and
603
+ their options) down into the tool.
604
+ Adequate beats exhaustive: a short skill that reliably routes the agent to the right
605
+ command beats a long one that mirrors the manual.
555
606
 
556
607
  ### 6.6 Distribution & multi-agent install
557
608
 
@@ -660,6 +711,20 @@ hook commands (or leave a wrapper) so existing Claude hooks keep working.
660
711
  skill content evolves *will* leave older generated files in users’ repos.
661
712
  Treat generated integration files like config migrations:
662
713
 
714
+ Reserve an `fNN` **format bump** for changes big enough to need an explicit migration: a
715
+ different on-disk shape, a moved or renamed managed region, or a changed hook contract.
716
+ Routine content edits (new skill text, an added shortcut, reworded guidance) are **not**
717
+ a format change. They ship by regenerating the surface, a full overwrite on the next
718
+ `setup` run, so they need no bump and no migration; bumping the format on every content
719
+ tweak would force needless migrations and churn.
720
+
721
+ The upgrade is also **opt-in, not silent**. A tool rewrites a user’s committed files
722
+ only when the user or agent explicitly runs `setup`/`setup --auto`, never from a
723
+ background hook or an ordinary read command.
724
+ Stamping a format lets an explicit `setup` detect an older layout and offer to migrate
725
+ it; it does not license the tool to mutate the repo on its own.
726
+ (This is why a `SessionStart` hook should run a read-only `prime`, not `setup`.)
727
+
663
728
  - Version the generated surfaces with an `fNN` format code.
664
729
  Prefer **one format code for all the tool’s managed surfaces** — reuse the tool’s
665
730
  existing config/data-format version as the single source of truth (tbd stamps the
@@ -670,6 +735,16 @@ Treat generated integration files like config migrations:
670
735
  line (`<!-- BEGIN … format=fNN … -->`), the skill “DO NOT EDIT” marker, script
671
736
  headers, or an equivalent hook signature.
672
737
  Prefer one marker line over a separate metadata comment.
738
+ - A **fully overwritten content surface** (a generated skill that is always rewritten
739
+ whole, never merged) does not strictly need its own stamp to upgrade cleanly: the next
740
+ `setup` replaces it outright.
741
+ It can lean on the single shared format code carried by a structural surface (such as
742
+ the `AGENTS.md` block).
743
+ But that only protects it if the forward-compatibility check runs **before** any
744
+ surface is written. If `setup` rewrites the skill first and only checks the `AGENTS.md`
745
+ format later, an older tool can partial-downgrade a newer committed skill before it
746
+ aborts. So either stamp and guard each generated surface, or run the format check up
747
+ front and write nothing until it passes.
673
748
  - On every `setup`/`setup --auto` run, **self-upgrade in place, safely and
674
749
  idempotently**: detect older formats and rewrite only the tool-owned regions (managed
675
750
  `AGENTS.md` block, generated skills, tool-owned hooks, `.codex/` config), re-running
@@ -1031,6 +1106,10 @@ going:
1031
1106
  - Two-part rule: *what it does* + *when to use it*; third person; front-load keywords.
1032
1107
  - Progressive disclosure: metadata → body → supporting files; bundle scripts
1033
1108
  (output-only cost).
1109
+ - Route, don’t restate: name each capability and the command to run; let the CLI’s
1110
+ `--help` and informational subcommands hold the flags and recipes.
1111
+ Carry the focused context an agent needs to judge that the tool is relevant, but don’t
1112
+ blindly copy help into the skill; that wastes context and goes stale (§3.1, §6.5).
1034
1113
  - Respect the budget; verify the current model for your target agent (Claude Code ≈ 1%
1035
1114
  of context window, not a flat char count).
1036
1115
 
@@ -1065,6 +1144,9 @@ going:
1065
1144
  **Baseline (every skill)**
1066
1145
  - [ ] `SKILL.md` with `name` + two-part `description`
1067
1146
  - [ ] Body < 500 lines; bulky material in supporting files one level deep
1147
+ - [ ] Body carries the essential context to judge whether the tool is relevant and to
1148
+ name each key use case, but routes to `mycli <cmd> --help` or `--list` for flags and
1149
+ recipes instead of copying help wholesale
1068
1150
  - [ ] Third-person description, trigger keywords front-loaded
1069
1151
  - [ ] Installable via commit to `.agents/skills/`, Claude mirror at `.claude/skills/`,
1070
1152
  and/or `npx skills add`
@@ -130,7 +130,7 @@ working branch. See `tbd guidelines tbd-sync-troubleshooting` for details.
130
130
 
131
131
  | Command | Purpose |
132
132
  | --- | --- |
133
- | `tbd create "title" --type task\|bug\|feature --priority=P2` | New bead (P0-P4, not “high/medium/low”) |
133
+ | `tbd create "title" --type=bug --priority=1` | New bead; run `tbd create --help` for all types and priorities (P0-P4, not “high/medium/low”) |
134
134
  | `tbd update <id> --status in_progress` | Claim work |
135
135
  | `tbd close <id> [--reason "..."]` | Mark complete |
136
136
 
@@ -168,6 +168,6 @@ working branch. See `tbd guidelines tbd-sync-troubleshooting` for details.
168
168
  ## Quick Reference
169
169
 
170
170
  - **Priority**: P0=critical, P1=high, P2=medium (default), P3=low, P4=backlog
171
- - **Types**: task, bug, feature, epic
171
+ - **Types**: issues default to `task`; run `tbd create --help` for the valid types
172
172
  - **Status**: open, in_progress, closed
173
173
  - **JSON output**: Add `--json` to any command
@@ -1,10 +1,10 @@
1
1
  import { b as IdMappingYamlSchema } from "./schemas-f0EcuAVu.mjs";
2
2
  import { i as parseYamlToleratingDuplicateKeys, r as hasMergeConflictMarkers, s as stringifyYaml } from "./yaml-utils-BPy991by.mjs";
3
- import { mkdir, readFile, rmdir, stat } from "node:fs/promises";
3
+ import { mkdir, readFile, rename, rmdir, stat } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { writeFile } from "atomically";
6
+ import { randomBytes, randomUUID } from "node:crypto";
6
7
  import { monotonicFactory } from "ulid";
7
- import { randomBytes } from "node:crypto";
8
8
 
9
9
  //#region src/utils/lockfile.ts
10
10
  /**
@@ -36,10 +36,15 @@ import { randomBytes } from "node:crypto";
36
36
  *
37
37
  * 1. **Acquire**: `mkdir(lockDir)` — fails with EEXIST if held by another process
38
38
  * 2. **Hold**: Execute the critical section
39
- * 3. **Release**: `rmdir(lockDir)` — in a finally block
39
+ * 3. **Release**: `rmdir(lockDir)` — in a finally block, with a bounded retry to
40
+ * absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering
41
+ * handles) that would otherwise orphan the lock directory.
40
42
  * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder
41
- * crashed and break the lock. This is a heuristic safe when the critical
42
- * section is short-lived (sub-second for file I/O).
43
+ * crashed and break the lock. Breaking is done **atomically** by renaming the
44
+ * stale directory aside (only one waiter can win the rename), so two waiters can
45
+ * never both break the same lock and end up running concurrently. This is a
46
+ * heuristic — safe when the critical section is short-lived (sub-second for
47
+ * file I/O).
43
48
  *
44
49
  * ## Failure on timeout
45
50
  *
@@ -87,6 +92,53 @@ var LockAcquisitionError = class extends Error {
87
92
  this.name = "LockAcquisitionError";
88
93
  }
89
94
  };
95
+ /** Filesystem error codes that are transient on Windows and worth retrying. */
96
+ const TRANSIENT_RMDIR_CODES = new Set([
97
+ "EBUSY",
98
+ "EPERM",
99
+ "EACCES",
100
+ "ENOTEMPTY"
101
+ ]);
102
+ /**
103
+ * Remove a lock directory, tolerating transient Windows failures.
104
+ *
105
+ * `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners
106
+ * or lingering directory handles). A few short retries make release reliable; if it
107
+ * still fails, we give up and let stale detection reclaim the directory rather than
108
+ * throwing from a best-effort cleanup path.
109
+ */
110
+ async function removeLockDir(lockPath, attempts = 5) {
111
+ for (let attempt = 0; attempt < attempts; attempt++) try {
112
+ await rmdir(lockPath);
113
+ return;
114
+ } catch (error) {
115
+ const code = error.code;
116
+ if (code === "ENOENT") return;
117
+ if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {
118
+ await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
119
+ continue;
120
+ }
121
+ return;
122
+ }
123
+ }
124
+ /**
125
+ * Atomically break a stale lock.
126
+ *
127
+ * Renames the stale directory to a unique sidecar path and removes it. `rename` is
128
+ * atomic, so when several waiters race to break the same stale lock only one wins the
129
+ * rename; the losers see ENOENT and simply retry. This prevents the classic
130
+ * non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both
131
+ * acquire it, defeating mutual exclusion.
132
+ */
133
+ async function breakStaleLock(lockPath) {
134
+ const sidecar = `${lockPath}.stale-${randomUUID()}`;
135
+ try {
136
+ await rename(lockPath, sidecar);
137
+ } catch {
138
+ return;
139
+ }
140
+ await removeLockDir(sidecar);
141
+ }
90
142
  /**
91
143
  * Execute `fn` while holding a lockfile.
92
144
  *
@@ -125,26 +177,24 @@ async function withLockfile(lockPath, fn, options) {
125
177
  break;
126
178
  } catch (error) {
127
179
  if (error.code !== "EEXIST") throw error;
180
+ let lockStat;
128
181
  try {
129
- const lockStat = await stat(lockPath);
130
- if (Date.now() - lockStat.mtimeMs > staleMs) {
131
- try {
132
- await rmdir(lockPath);
133
- } catch {}
134
- continue;
135
- }
182
+ lockStat = await stat(lockPath);
136
183
  } catch {
137
184
  continue;
138
185
  }
186
+ if (!lockStat.isDirectory()) throw new Error(`Lock path exists but is not a directory: ${lockPath}. Refusing to break it; remove the conflicting file and retry.`);
187
+ if (Date.now() - lockStat.mtimeMs > staleMs) {
188
+ await breakStaleLock(lockPath);
189
+ continue;
190
+ }
139
191
  await new Promise((resolve) => setTimeout(resolve, pollMs));
140
192
  }
141
193
  if (!acquired) throw new LockAcquisitionError(lockPath, timeoutMs);
142
194
  try {
143
195
  return await fn();
144
196
  } finally {
145
- try {
146
- await rmdir(lockPath);
147
- } catch {}
197
+ await removeLockDir(lockPath);
148
198
  }
149
199
  }
150
200
 
@@ -723,4 +773,4 @@ function resolveIdMappingConflicts(content) {
723
773
 
724
774
  //#endregion
725
775
  export { withLockfile as C, DATA_SYNC_LOCK_OPTIONS as S, 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 };
726
- //# sourceMappingURL=id-mapping-CtfTfGIh.mjs.map
776
+ //# sourceMappingURL=id-mapping-687_UEsy.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"id-mapping-687_UEsy.mjs","names":[],"sources":["../src/utils/lockfile.ts","../src/lib/ids.ts","../src/lib/sort.ts","../src/file/id-mapping.ts"],"sourcesContent":["/**\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, with a bounded retry to\n * absorb transient Windows failures (EBUSY/EPERM from AV scanners or lingering\n * handles) that would otherwise orphan the lock directory.\n * 4. **Stale detection**: If lock mtime exceeds a threshold, assume the holder\n * crashed and break the lock. Breaking is done **atomically** by renaming the\n * stale directory aside (only one waiter can win the rename), so two waiters can\n * never both break the same lock and end up running concurrently. This is a\n * heuristic — safe when the critical section is short-lived (sub-second for\n * 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, rename, rmdir, stat } from 'node:fs/promises';\nimport { randomUUID } from 'node:crypto';\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 * Lock timing profile for shared data-sync operations.\n *\n * Issue sync can include fetch, merge, push, and outbox import work, so it must\n * not use the short stale window intended for single-file writes. `timeoutMs`\n * is kept just above `staleMs` so a crashed-process lock is always broken as\n * stale before the timeout expires, matching the invariant documented above.\n *\n * Accepted trade-off (no heartbeat): a live `tbd sync` that hangs longer than\n * `staleMs` (30 min) can have its lock broken by another process mid-operation.\n * For current data sizes this is acceptable — single-repo sync workloads\n * complete well under the window — and adding heartbeat metadata adds\n * cross-process state machinery without changing the common case. If sync\n * workloads grow or the lock-break race becomes observable in practice,\n * revisit by adding heartbeat metadata inside the lock directory (touch mtime\n * periodically; treat as stale only if heartbeat is older than `staleMs`).\n * See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Post-Review\n * Hardening H6.\n */\nexport const DATA_SYNC_LOCK_OPTIONS: Required<LockfileOptions> = {\n timeoutMs: 35 * 60_000,\n pollMs: 250,\n staleMs: 30 * 60_000,\n};\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/** Filesystem error codes that are transient on Windows and worth retrying. */\nconst TRANSIENT_RMDIR_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);\n\n/**\n * Remove a lock directory, tolerating transient Windows failures.\n *\n * `rmdir` can intermittently fail with EBUSY/EPERM on Windows (antivirus scanners\n * or lingering directory handles). A few short retries make release reliable; if it\n * still fails, we give up and let stale detection reclaim the directory rather than\n * throwing from a best-effort cleanup path.\n */\nasync function removeLockDir(lockPath: string, attempts = 5): Promise<void> {\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n await rmdir(lockPath);\n return;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') {\n return; // Already gone — nothing to do.\n }\n if (attempt < attempts - 1 && code && TRANSIENT_RMDIR_CODES.has(code)) {\n await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));\n continue;\n }\n return; // Non-transient or out of attempts: best-effort, leave for stale detection.\n }\n }\n}\n\n/**\n * Atomically break a stale lock.\n *\n * Renames the stale directory to a unique sidecar path and removes it. `rename` is\n * atomic, so when several waiters race to break the same stale lock only one wins the\n * rename; the losers see ENOENT and simply retry. This prevents the classic\n * non-atomic break race (rmdir + mkdir) where two waiters both break the lock and both\n * acquire it, defeating mutual exclusion.\n */\nasync function breakStaleLock(lockPath: string): Promise<void> {\n const sidecar = `${lockPath}.stale-${randomUUID()}`;\n try {\n await rename(lockPath, sidecar);\n } catch {\n // Another waiter already broke or released it, or the holder is no longer stale.\n return;\n }\n await removeLockDir(sidecar);\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 — inspect it.\n let lockStat;\n try {\n lockStat = await stat(lockPath);\n } catch {\n // Lock was released between our mkdir and stat — retry immediately\n continue;\n }\n\n // A non-directory at the lock path is unexpected filesystem state. Do not\n // rename it aside: that would move the user's file out of the way and let the\n // critical section run unprotected. Fail loudly instead (mirrors how an\n // unexpected mkdir error is surfaced rather than masked as contention).\n if (!lockStat.isDirectory()) {\n throw new Error(\n `Lock path exists but is not a directory: ${lockPath}. ` +\n `Refusing to break it; remove the conflicting file and retry.`,\n );\n }\n\n if (Date.now() - lockStat.mtimeMs > staleMs) {\n // Break atomically so concurrent waiters can't both acquire.\n await breakStaleLock(lockPath);\n continue; // Retry immediately after breaking stale lock\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 // Best-effort cleanup with retry; stale lock detection handles the rest.\n await removeLockDir(lockPath);\n }\n}\n","/**\n * ID generation and validation utilities.\n *\n * The system uses dual IDs for usability:\n * - Internal ID: is-{ulid} - ULID-based (26 lowercase chars), stored in files\n * - External ID: {prefix}-{short} - 4-5 base36 chars for CLI display/input\n *\n * For Beads compatibility, bd- prefix is accepted on input for external IDs.\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { monotonicFactory } from 'ulid';\nimport { randomBytes } from 'node:crypto';\n\n// Monotonic factory ensures ULIDs are strictly increasing even within the same\n// millisecond. This guarantees that lexicographic sort = creation order, which\n// is critical for deterministic list output (the tiebreaker sort is by ULID).\nconst ulid = monotonicFactory();\n\n// =============================================================================\n// Branded Types for Type-Safe ID Handling\n// =============================================================================\n\n/**\n * Branded type for internal issue IDs (is-{ulid} format).\n *\n * Internal IDs are stored in files and used as the canonical identifier.\n * Format: is-{26 lowercase alphanumeric chars}\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * Use this type when:\n * - Reading/writing issue files\n * - Storing parent_id, dependencies, child_order_hints\n * - Passing IDs between internal functions\n */\ndeclare const InternalIssueIdBrand: unique symbol;\nexport type InternalIssueId = string & { [InternalIssueIdBrand]: never };\n\n/**\n * Branded type for display issue IDs ({prefix}-{short} format).\n *\n * Display IDs are shown to users and accepted as CLI input.\n * Format: {prefix}-{short} where short is typically 4 base36 chars\n * Example: tbd-a7k2, bd-100\n *\n * Use this type when:\n * - Formatting output for users\n * - Accepting user input (before resolution)\n * - Building tree views for display\n */\ndeclare const DisplayIssueIdBrand: unique symbol;\nexport type DisplayIssueId = string & { [DisplayIssueIdBrand]: never };\n\n/**\n * Cast a string to InternalIssueId after validation.\n * Use this when you've validated that a string is a valid internal ID.\n */\nexport function asInternalId(id: string): InternalIssueId {\n return id as InternalIssueId;\n}\n\n/**\n * Cast a string to DisplayIssueId.\n * Use this when formatting an ID for display.\n */\nexport function asDisplayId(id: string): DisplayIssueId {\n return id as DisplayIssueId;\n}\n\n/**\n * Prefix for internal IDs (ULID-based).\n * All internal IDs are formatted as: {INTERNAL_ID_PREFIX}-{ulid}\n */\nexport const INTERNAL_ID_PREFIX = 'is';\n\n/**\n * Length of internal ID prefix including the hyphen (e.g., \"is-\" = 3).\n */\nexport const INTERNAL_ID_PREFIX_LENGTH = INTERNAL_ID_PREFIX.length + 1;\n\n/**\n * Construct an internal ID from a ULID.\n *\n * @param ulidValue - The ULID (26 chars)\n * @returns Internal ID in format {prefix}-{ulid}\n */\nexport function makeInternalId(ulidValue: string): InternalIssueId {\n return `${INTERNAL_ID_PREFIX}-${ulidValue.toLowerCase()}` as InternalIssueId;\n}\n\n/**\n * Generate a unique internal ID using ULID.\n * Format: is-{ulid} (26 lowercase alphanumeric chars)\n * Example: is-01hx5zzkbkactav9wevgemmvrz\n *\n * ULID provides:\n * - Time-ordered sorting (48-bit timestamp)\n * - 80-bit randomness (no collisions)\n * - Lexicographic sort = chronological order\n */\nexport function generateInternalId(): InternalIssueId {\n return makeInternalId(ulid());\n}\n\n/**\n * Generate a short ID for external display.\n * Format: base36 characters (a-z, 0-9)\n * Example: a7k2\n *\n * @param length - Number of characters (default 4)\n */\nexport function generateShortId(length = 4): string {\n const chars = '0123456789abcdefghijklmnopqrstuvwxyz';\n let result = '';\n const bytes = randomBytes(length);\n for (let i = 0; i < length; i++) {\n result += chars[bytes[i]! % 36];\n }\n return result;\n}\n\n// Regex pattern for validating internal IDs - built from prefix constant\nconst INTERNAL_ID_PATTERN = new RegExp(`^${INTERNAL_ID_PREFIX}-[0-9a-z]{26}$`);\n\n// Expected length of a full internal ID (prefix + hyphen + 26-char ULID)\nconst INTERNAL_ID_LENGTH = INTERNAL_ID_PREFIX_LENGTH + 26;\n\n/**\n * Validate an internal issue ID matches the ULID format.\n * Format: {prefix}-{26 lowercase alphanumeric chars}\n */\nexport function validateIssueId(id: string): boolean {\n return INTERNAL_ID_PATTERN.test(id);\n}\n\n/**\n * Validate a short/external ID format.\n * Format: 1+ base36 characters (typically 4 for new IDs, but imports may preserve longer IDs).\n */\nexport function validateShortId(id: string): boolean {\n return /^[0-9a-z]+$/.test(id);\n}\n\n/**\n * Check if an input looks like an internal ID (ULID-based).\n */\nexport function isInternalId(input: string): boolean {\n const lower = input.toLowerCase();\n // Check if it starts with the internal prefix and has correct length\n const prefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n if (lower.startsWith(prefixWithHyphen) && lower.length === INTERNAL_ID_LENGTH) {\n return INTERNAL_ID_PATTERN.test(lower);\n }\n return false;\n}\n\n/**\n * Check if an input looks like a short/external ID.\n * Returns true for IDs like \"a7k2\", \"bd-a7k2\", \"100\", \"tbd-100\".\n * Short IDs are 16 characters or less (ULIDs are 26 characters).\n */\nexport function isShortId(input: string): boolean {\n const lower = input.toLowerCase();\n // Strip prefix if present\n const stripped = lower.replace(/^[a-z]+-/, '');\n // Must be 1-16 alphanumeric chars (short IDs, not ULIDs which are 26 chars)\n return /^[0-9a-z]+$/.test(stripped) && stripped.length >= 1 && stripped.length <= 16;\n}\n\n/**\n * Extract the short ID portion from an external ID.\n * Examples:\n * \"tbd-100\" -> \"100\"\n * \"bd-a7k2\" -> \"a7k2\"\n * \"a7k2\" -> \"a7k2\"\n * \"100\" -> \"100\"\n */\nexport function extractShortId(externalId: string): string {\n return externalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/**\n * Extract the prefix portion from an external ID.\n * Returns the prefix (letters before the hyphen) or null if no prefix found.\n * Examples:\n * \"tbd-100\" -> \"tbd\"\n * \"bd-a7k2\" -> \"bd\"\n * \"TBD-100\" -> \"tbd\" (normalized to lowercase)\n * \"a7k2\" -> null (no prefix)\n * \"100\" -> null (no prefix)\n */\nexport function extractPrefix(externalId: string): string | null {\n const match = /^([a-zA-Z]+)-/.exec(externalId);\n return match?.[1]?.toLowerCase() ?? null;\n}\n\n/**\n * Extract the ULID portion from an internal ID.\n *\n * Internal IDs have the format: {prefix}-{ulid}\n * This function strips any prefix to return just the ULID.\n *\n * Examples:\n * \"is-01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\"\n * \"01hx5zzkbkactav9wevgemmvrz\" -> \"01hx5zzkbkactav9wevgemmvrz\" (no prefix)\n *\n * @param internalId - The internal ID (with or without prefix)\n * @returns The ULID portion without any prefix\n */\nexport function extractUlidFromInternalId(internalId: string): string {\n // Strip any prefix in format {letters}- (e.g., \"is-\", \"bd-\")\n return internalId.toLowerCase().replace(/^[a-z]+-/, '');\n}\n\n/** Prefix used in Beads for compatibility */\nconst BEADS_COMPAT_PREFIX = 'bd';\n\n/**\n * Normalize an internal issue ID.\n *\n * This function expects a full internal ID ({prefix}-{ulid}).\n * If given a short ID, it won't be able to resolve it without\n * access to the ID mapping.\n *\n * Handles:\n * - Uppercase (converts to lowercase)\n * - Ensures internal ID prefix\n * - Beads compatibility (bd- prefix)\n */\nexport function normalizeIssueId(input: string): string {\n const lower = input.toLowerCase();\n const internalPrefixWithHyphen = `${INTERNAL_ID_PREFIX}-`;\n const beadsPrefixWithHyphen = `${BEADS_COMPAT_PREFIX}-`;\n\n // If already a valid internal ID, return as-is\n if (validateIssueId(lower)) {\n return lower;\n }\n\n // If it starts with internal prefix but wrong length, might be corrupted\n if (lower.startsWith(internalPrefixWithHyphen)) {\n return lower; // Return as-is, let validation fail later\n }\n\n // If it starts with bd- (Beads compat), convert prefix\n if (lower.startsWith(beadsPrefixWithHyphen)) {\n const rest = lower.slice(beadsPrefixWithHyphen.length);\n if (rest.length === 26) {\n return makeInternalId(rest);\n }\n // Short ID - can't resolve without mapping\n return lower;\n }\n\n // Bare ID without prefix\n if (lower.length === 26 && /^[0-9a-z]{26}$/.test(lower)) {\n return makeInternalId(lower);\n }\n\n // Can't normalize - return as-is\n return lower;\n}\n\nimport type { IdMapping } from '../file/id-mapping.js';\n\n/**\n * Format an internal ID for display with the configured prefix.\n *\n * Uses the short ID (4 chars) from the mapping.\n * Throws an error if the mapping is missing or doesn't contain the ID.\n *\n * IMPORTANT: All user-facing output MUST use short IDs, never internal ULIDs.\n * If you see a ULID in user output, it's a bug.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup (required)\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n * @throws Error if mapping is missing or ID not found in mapping\n */\nexport function formatDisplayId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): DisplayIssueId {\n // Extract the ULID portion\n const ulidPart = extractUlidFromInternalId(internalId);\n\n // Get short ID from mapping\n const shortId = mapping.ulidToShort.get(ulidPart);\n if (!shortId) {\n throw new Error(\n `No short ID mapping found for internal ID: ${internalId}. ` +\n `This is a bug - all issues must have a short ID mapping.`,\n );\n }\n\n return `${prefix}-${shortId}` as DisplayIssueId;\n}\n\n/**\n * Format an ID for debug output, showing both public and internal IDs.\n *\n * @param internalId - The internal ID (is-{ulid})\n * @param mapping - ID mapping for short ID lookup\n * @param prefix - Display prefix (should come from config.display.id_prefix; defaults to 'tbd' as fallback)\n */\nexport function formatDebugId(\n internalId: InternalIssueId | string,\n mapping: IdMapping,\n prefix = 'tbd',\n): string {\n const displayId = formatDisplayId(internalId, mapping, prefix);\n return `${displayId} (${internalId})`;\n}\n","/**\n * Natural sort utilities.\n *\n * Provides alphanumeric sorting where numeric portions are sorted numerically.\n * Similar to `sort -V` (version sort) or `sort -n` for numbers.\n *\n * Examples:\n * [\"1\", \"2\", \"9\", \"10\", \"11\"] instead of [\"1\", \"10\", \"11\", \"2\", \"9\"]\n * [\"a1\", \"a2\", \"a10\"] instead of [\"a1\", \"a10\", \"a2\"]\n * [\"file1.txt\", \"file2.txt\", \"file10.txt\"] instead of [\"file1.txt\", \"file10.txt\", \"file2.txt\"]\n */\n\n/**\n * Split a string into alternating runs of digits and non-digits.\n * @param str - The string to split\n * @returns Array of [isNumeric, value] tuples\n */\nfunction splitIntoChunks(str: string): [boolean, string][] {\n const chunks: [boolean, string][] = [];\n let current = '';\n let currentIsNumeric: boolean | null = null;\n\n for (const char of str) {\n const isDigit = char >= '0' && char <= '9';\n\n if (currentIsNumeric === null) {\n // First character\n currentIsNumeric = isDigit;\n current = char;\n } else if (isDigit === currentIsNumeric) {\n // Same type, continue accumulating\n current += char;\n } else {\n // Type changed, push current chunk and start new one\n chunks.push([currentIsNumeric, current]);\n currentIsNumeric = isDigit;\n current = char;\n }\n }\n\n // Push final chunk\n if (current) {\n chunks.push([currentIsNumeric!, current]);\n }\n\n return chunks;\n}\n\n/**\n * Compare two strings using natural (alphanumeric) ordering.\n *\n * Numeric portions are compared numerically, non-numeric portions are\n * compared lexicographically (case-insensitive). Numbers sort before letters\n * when they appear at the same position in mixed comparisons, matching\n * the behavior of `sort -V` (version sort).\n *\n * @param a - First string\n * @param b - Second string\n * @returns Negative if a < b, positive if a > b, zero if equal\n */\nexport function naturalCompare(a: string, b: string): number {\n // Handle empty strings\n if (!a && !b) return 0;\n if (!a) return -1;\n if (!b) return 1;\n\n const chunksA = splitIntoChunks(a);\n const chunksB = splitIntoChunks(b);\n\n const minLen = Math.min(chunksA.length, chunksB.length);\n\n for (let i = 0; i < minLen; i++) {\n const [isNumericA, valueA] = chunksA[i]!;\n const [isNumericB, valueB] = chunksB[i]!;\n\n if (isNumericA && isNumericB) {\n // Both are numeric - compare as numbers\n const numA = parseInt(valueA, 10);\n const numB = parseInt(valueB, 10);\n if (numA !== numB) {\n return numA - numB;\n }\n // If numerically equal but different strings (e.g., \"01\" vs \"1\"),\n // prefer shorter (fewer leading zeros)\n if (valueA.length !== valueB.length) {\n return valueA.length - valueB.length;\n }\n } else if (!isNumericA && !isNumericB) {\n // Both are non-numeric - compare lexicographically (case-insensitive)\n const lowerA = valueA.toLowerCase();\n const lowerB = valueB.toLowerCase();\n if (lowerA !== lowerB) {\n return lowerA.localeCompare(lowerB);\n }\n // Same when lowercased - they're equal for sorting purposes\n } else {\n // Mixed: numeric comes before non-numeric\n // (so \"1\" comes before \"a\" at the same position)\n // This matches `sort -V` behavior\n return isNumericA ? -1 : 1;\n }\n }\n\n // All compared chunks are equal, shorter string comes first\n return chunksA.length - chunksB.length;\n}\n\n/**\n * Sort an array of strings using natural (alphanumeric) ordering.\n *\n * @param arr - Array to sort\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSort(arr: readonly string[]): string[] {\n return [...arr].sort(naturalCompare);\n}\n\n/**\n * Sort an array of objects by a string key using natural ordering.\n *\n * @param arr - Array to sort\n * @param keyFn - Function to extract the sort key from each element\n * @returns New sorted array (does not mutate original)\n */\nexport function naturalSortBy<T>(arr: readonly T[], keyFn: (item: T) => string): T[] {\n return [...arr].sort((a, b) => naturalCompare(keyFn(a), keyFn(b)));\n}\n","/**\n * ID mapping management for short public IDs.\n *\n * Maps 4-char base36 short IDs to 26-char ULIDs.\n * Stored in .tbd/data-sync/mappings/ids.yml\n *\n * See: tbd-design.md §2.5 ID Generation\n */\n\nimport { readFile, mkdir } from 'node:fs/promises';\nimport { join, dirname } from 'node:path';\nimport { writeFile } from 'atomically';\n\nimport {\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-CtfTfGIh.mjs";
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-687_UEsy.mjs";
2
2
 
3
3
  export { loadIdMapping, mergeIdMappings, parseIdMappingFromYaml, reconcileMappings, resolveIdMappingConflicts, saveIdMapping };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  import { A as Priority, C as IssueSchema, D as LocalStateSchema, E as LOCAL_STATE_FIELD_ORDER, F as Version, M as SyncStorage, N as Timestamp, O as META_FIELD_ORDER, P as Ulid, S as IssueKind, T as IssueTitle, _ as ISSUE_BODY_MAX_LENGTH, a as CONFIG_FIELD_ORDER, b as IdMappingYamlSchema, c as DATA_SYNC_SCHEMA_VERSION, d as DocCacheConfigSchema, f as DocsCacheSchema, g as GitRemoteName, h as GitBranchName, i as COMMON_DIR_LAYOUT_FIELD_ORDER, j as ShortId, k as MetaSchema, l as Dependency, m as ExternalIssueIdInput, n as AtticEntrySchema, o as CommonDirLayoutSchema, p as EntityType, r as BaseEntity, s as ConfigSchema, t as ATTIC_ENTRY_FIELD_ORDER, u as DependencyRelationType, v as ISSUE_FIELD_ORDER, w as IssueStatus, x as IssueId, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
2
- import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-rIE4xSVs.mjs";
2
+ import { c as noopLogger, i as serializeIssue, n as parseIssue, t as VERSION } from "./src-CtZIHxYM.mjs";
3
3
 
4
4
  export { ATTIC_ENTRY_FIELD_ORDER, AtticEntrySchema, BaseEntity, COMMON_DIR_LAYOUT_FIELD_ORDER, CONFIG_FIELD_ORDER, CommonDirLayoutSchema, ConfigSchema, DATA_SYNC_SCHEMA_VERSION, 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, SyncStorage, Timestamp, Ulid, VERSION, Version, noopLogger, parseIssue, serializeIssue };
@@ -183,8 +183,8 @@ function serializeIssue(issue) {
183
183
  * Package version, derived from git at build time.
184
184
  * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.
185
185
  */
186
- const VERSION = "0.2.0";
186
+ const VERSION = "0.2.1";
187
187
 
188
188
  //#endregion
189
189
  export { insertAfterFrontmatter as a, noopLogger as c, serializeIssue as i, parseIssue as n, parseMarkdown as o, parseMarkdownWithFrontmatter as r, stripFrontmatter as s, VERSION as t };
190
- //# sourceMappingURL=src-rIE4xSVs.mjs.map
190
+ //# sourceMappingURL=src-CtZIHxYM.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"src-rIE4xSVs.mjs","names":["parseYaml"],"sources":["../src/lib/types.ts","../src/utils/markdown-utils.ts","../src/file/parser.ts","../src/index.ts"],"sourcesContent":["/**\n * TypeScript types derived from Zod schemas.\n *\n * These types are the canonical TypeScript interface for tbd entities.\n */\n\nimport type { z } from 'zod';\n\nimport type {\n IssueSchema,\n IssueStatus,\n IssueKind,\n Priority,\n Dependency,\n ConfigSchema,\n CommonDirLayoutSchema,\n MetaSchema,\n LocalStateSchema,\n AtticEntrySchema,\n} from './schemas.js';\n\n// =============================================================================\n// Entity Types\n// =============================================================================\n\n/**\n * A tbd issue entity.\n */\nexport type Issue = z.infer<typeof IssueSchema>;\n\n/**\n * Issue status enum values.\n */\nexport type IssueStatusType = z.infer<typeof IssueStatus>;\n\n/**\n * Issue kind enum values.\n */\nexport type IssueKindType = z.infer<typeof IssueKind>;\n\n/**\n * Priority level (0-4).\n */\nexport type PriorityType = z.infer<typeof Priority>;\n\n/**\n * A dependency relationship.\n */\nexport type DependencyType = z.infer<typeof Dependency>;\n\n// =============================================================================\n// Configuration Types\n// =============================================================================\n\n/**\n * Project configuration.\n */\nexport type Config = z.infer<typeof ConfigSchema>;\n\n/**\n * Git common-dir local layout metadata.\n */\nexport type CommonDirLayout = z.infer<typeof CommonDirLayoutSchema>;\n\n/**\n * Shared metadata.\n */\nexport type Meta = z.infer<typeof MetaSchema>;\n\n/**\n * Per-node local state.\n */\nexport type LocalState = z.infer<typeof LocalStateSchema>;\n\n/**\n * Attic entry for conflict losers.\n */\nexport type AtticEntry = z.infer<typeof AtticEntrySchema>;\n\n// =============================================================================\n// Input Types for Commands\n// =============================================================================\n\n/**\n * Options for creating an issue.\n */\nexport interface CreateIssueOptions {\n title: string;\n description?: string;\n kind?: IssueKindType;\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent_id?: string;\n due_date?: string;\n deferred_until?: string;\n}\n\n/**\n * Options for updating an issue.\n */\nexport interface UpdateIssueOptions {\n title?: string;\n description?: string;\n notes?: string;\n kind?: IssueKindType;\n status?: IssueStatusType;\n priority?: PriorityType;\n assignee?: string | null;\n addLabels?: string[];\n removeLabels?: string[];\n parent_id?: string | null;\n due_date?: string | null;\n deferred_until?: string | null;\n}\n\n/**\n * Options for listing issues.\n */\nexport interface ListIssuesOptions {\n status?: IssueStatusType | IssueStatusType[];\n kind?: IssueKindType | IssueKindType[];\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent?: string;\n all?: boolean;\n sort?: 'priority' | 'created' | 'updated';\n limit?: number;\n}\n\n/**\n * Options for searching issues.\n */\nexport interface SearchIssuesOptions {\n query: string;\n status?: IssueStatusType | IssueStatusType[];\n limit?: number;\n}\n\n// =============================================================================\n// CLI Utility Types\n// =============================================================================\n\n/**\n * A documentation section with title and slug.\n * Used by docs and design commands.\n */\nexport interface DocSection {\n title: string;\n slug: string;\n}\n\n/**\n * Logger interface for long-running operations in non-CLI layers.\n *\n * Allows core logic (file/, lib/) to report progress without depending on\n * the CLI output layer. CLI commands create an OperationLogger via\n * `OutputManager.logger(spinner)` and pass it to core functions.\n *\n * All methods are required. Use `noopLogger` when no logging is needed.\n */\nexport interface OperationLogger {\n /** Key milestones — drives the spinner in CLI context */\n progress: (message: string) => void;\n /** Operational detail (shown with --verbose or --debug) */\n info: (message: string) => void;\n /** Non-fatal warnings */\n warn: (message: string) => void;\n /** Internal state for troubleshooting (shown with --debug only) */\n debug: (message: string) => void;\n}\n\n/**\n * No-op logger for when no logging is needed.\n * Analogous to noopSpinner in the CLI layer.\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst noop = () => {};\nexport const noopLogger: OperationLogger = {\n progress: noop,\n info: noop,\n warn: noop,\n debug: noop,\n};\n","/**\n * Markdown utilities for processing markdown content.\n *\n * Uses gray-matter for parsing and centralized yaml-utils for stringify to ensure\n * proper handling of special YAML characters (colons, quotes, etc.).\n */\n\nimport matter from 'gray-matter';\n\nimport { stringifyYamlCompact } from './yaml-utils.js';\n\nexport interface ParsedMarkdown {\n /** Raw frontmatter string (without --- delimiters), or null if no frontmatter */\n frontmatter: string | null;\n /** Body content after frontmatter, with leading newlines trimmed */\n body: string;\n}\n\n/**\n * Normalize line endings to LF.\n */\nexport function normalizeLineEndings(content: string): string {\n return content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n}\n\n/**\n * Parse markdown content into frontmatter and body.\n * Handles both LF and CRLF line endings.\n *\n * @returns Object with frontmatter (null if none) and body\n */\nexport function parseMarkdown(content: string): ParsedMarkdown {\n const normalized = normalizeLineEndings(content);\n\n if (!matter.test(normalized)) {\n return { frontmatter: null, body: content };\n }\n\n try {\n const parsed = matter(normalized);\n\n // Extract frontmatter from parsed.data by stringifying back to YAML\n // The matter property is unreliable, so we reconstruct from data\n const data = parsed.data;\n let frontmatter: string | null = null;\n\n if (data && Object.keys(data).length > 0) {\n // Use centralized yaml-utils for proper handling of special characters\n // (colons, quotes, multiline strings, etc.)\n frontmatter = stringifyYamlCompact(data).trimEnd();\n } else {\n // Empty frontmatter (just --- followed by ---)\n frontmatter = '';\n }\n\n // Body with leading newlines trimmed\n const body = parsed.content.replace(/^\\n+/, '');\n\n return { frontmatter, body };\n } catch {\n // Invalid/unclosed frontmatter - treat as no frontmatter\n return { frontmatter: null, body: content };\n }\n}\n\n/**\n * Parse YAML frontmatter from markdown content.\n * Returns the frontmatter content (without delimiters) or null if no valid frontmatter.\n * Handles both LF and CRLF line endings.\n */\nexport function parseFrontmatter(content: string): string | null {\n return parseMarkdown(content).frontmatter;\n}\n\n/**\n * Strip YAML frontmatter from markdown content.\n * Returns the body content without frontmatter, with leading newlines trimmed.\n * Handles both LF and CRLF line endings.\n */\nexport function stripFrontmatter(content: string): string {\n return parseMarkdown(content).body;\n}\n\n/**\n * Insert content after YAML frontmatter.\n * If no frontmatter exists, prepends the content.\n * Content is inserted directly after ---. Include leading newlines in toInsert if needed.\n */\nexport function insertAfterFrontmatter(content: string, toInsert: string): string {\n const { frontmatter, body } = parseMarkdown(content);\n\n if (frontmatter === null) {\n return toInsert + content;\n }\n\n const frontmatterBlock = frontmatter ? `---\\n${frontmatter}\\n---` : '---\\n---';\n return `${frontmatterBlock}\\n${toInsert}\\n\\n${body}`;\n}\n","/**\n * YAML front matter parser and serializer for issue files.\n *\n * Issues are stored as Markdown files with YAML front matter:\n * ---\n * type: is\n * id: is-a1b2c3\n * ...\n * ---\n *\n * Description body here.\n *\n * ## Notes\n *\n * Working notes here.\n *\n * See: tbd-design.md §2.1 Markdown + YAML Front Matter Format\n */\n\nimport matter from 'gray-matter';\nimport { parse as parseYaml } from 'yaml';\n\nimport { normalizeLineEndings } from '../utils/markdown-utils.js';\nimport { sortKeys, stringifyYaml } from '../utils/yaml-utils.js';\nimport type { Issue } from '../lib/types.js';\nimport { IssueSchema, ISSUE_FIELD_ORDER } from '../lib/schemas.js';\n\n/**\n * gray-matter options using the 'yaml' package as engine.\n * This preserves date strings instead of converting them to Date objects.\n */\nexport const matterOptions = {\n engines: {\n yaml: {\n parse: (str: string): object => parseYaml(str) as object,\n stringify: (obj: object): string => stringifyYaml(obj),\n },\n },\n};\n\n/**\n * Parsed issue file content.\n */\nexport interface ParsedIssueFile {\n frontmatter: Record<string, unknown>;\n description: string;\n notes: string;\n}\n\n/**\n * Parse a Markdown file with YAML front matter.\n * Uses gray-matter for consistent frontmatter parsing.\n * Handles both LF and CRLF line endings.\n */\nexport function parseMarkdownWithFrontmatter(content: string): ParsedIssueFile {\n // Normalize CRLF to LF before parsing\n const normalizedContent = normalizeLineEndings(content);\n\n // Check for valid frontmatter\n if (!matter.test(normalizedContent)) {\n throw new Error('Invalid format: missing front matter opening delimiter');\n }\n\n const parsed = matter(normalizedContent, matterOptions);\n\n // gray-matter returns empty object if no closing delimiter found\n // but the raw matter string will be empty if parsing failed\n if (parsed.matter === '' && !normalizedContent.includes('---\\n---')) {\n // Check if there's actually a closing delimiter\n const lines = normalizedContent.split('\\n');\n let hasClosing = false;\n for (let i = 1; i < lines.length; i++) {\n if (lines[i]?.trim() === '---') {\n hasClosing = true;\n break;\n }\n }\n if (!hasClosing) {\n throw new Error('Invalid format: missing front matter closing delimiter');\n }\n }\n\n const frontmatter = parsed.data as Record<string, unknown>;\n\n // Parse body - split into description and notes\n const body = parsed.content.trim();\n\n // Find notes section\n const notesMatch = /\\n## Notes\\n/i.exec(body);\n let description = body;\n let notes = '';\n\n if (notesMatch?.index !== undefined) {\n description = body.slice(0, notesMatch.index).trim();\n notes = body.slice(notesMatch.index + notesMatch[0].length).trim();\n }\n\n return { frontmatter, description, notes };\n}\n\n/**\n * Parse an issue from Markdown file content.\n */\nexport function parseIssue(content: string): Issue {\n const { frontmatter, description, notes } = parseMarkdownWithFrontmatter(content);\n\n // Merge body content into frontmatter\n const data = {\n ...frontmatter,\n description: description || undefined,\n notes: notes || undefined,\n };\n\n // Validate and parse with Zod\n return IssueSchema.parse(data);\n}\n\n/**\n * Serialize an issue to Markdown file content.\n * Uses canonical serialization for deterministic output.\n */\nexport function serializeIssue(issue: Issue): string {\n // Extract body fields\n const { description, notes, ...metadata } = issue;\n\n // Sort keys using canonical field order (not alphabetical)\n const sortedMetadata = sortKeys(metadata, ISSUE_FIELD_ORDER);\n\n // Serialize YAML with compact output for frontmatter.\n // sortMapEntries: false preserves our manual ordering.\n const yaml = stringifyYaml(sortedMetadata, {\n lineWidth: 0,\n nullStr: 'null',\n sortMapEntries: false,\n });\n\n // Build the file content\n // Note: No blank line between closing --- and body content\n const parts = ['---', yaml.trim(), '---'];\n\n if (description) {\n parts.push(description.trim());\n }\n\n if (notes) {\n parts.push('');\n parts.push('## Notes');\n parts.push('');\n parts.push(notes.trim());\n }\n\n // Single newline at end\n return parts.join('\\n') + '\\n';\n}\n","/**\n * tbd: Git-native issue tracking for AI agents and humans\n *\n * This is the library entry point. All exports here should be node-free\n * to support browser/edge runtime usage. CLI-specific code is in ./cli/.\n */\n\n// Version injected at build time\ndeclare const __TBD_VERSION__: string;\n\n/**\n * Package version, derived from git at build time.\n * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.\n */\nexport const VERSION: string =\n typeof __TBD_VERSION__ !== 'undefined' ? __TBD_VERSION__ : 'development';\n\n// Re-export schemas for library consumers\nexport * from './lib/schemas.js';\nexport * from './lib/types.js';\n\n// Re-export core operations (these should be node-free)\nexport { parseIssue, serializeIssue } from './file/parser.js';\n"],"mappings":"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"}
1
+ {"version":3,"file":"src-CtZIHxYM.mjs","names":["parseYaml"],"sources":["../src/lib/types.ts","../src/utils/markdown-utils.ts","../src/file/parser.ts","../src/index.ts"],"sourcesContent":["/**\n * TypeScript types derived from Zod schemas.\n *\n * These types are the canonical TypeScript interface for tbd entities.\n */\n\nimport type { z } from 'zod';\n\nimport type {\n IssueSchema,\n IssueStatus,\n IssueKind,\n Priority,\n Dependency,\n ConfigSchema,\n CommonDirLayoutSchema,\n MetaSchema,\n LocalStateSchema,\n AtticEntrySchema,\n} from './schemas.js';\n\n// =============================================================================\n// Entity Types\n// =============================================================================\n\n/**\n * A tbd issue entity.\n */\nexport type Issue = z.infer<typeof IssueSchema>;\n\n/**\n * Issue status enum values.\n */\nexport type IssueStatusType = z.infer<typeof IssueStatus>;\n\n/**\n * Issue kind enum values.\n */\nexport type IssueKindType = z.infer<typeof IssueKind>;\n\n/**\n * Priority level (0-4).\n */\nexport type PriorityType = z.infer<typeof Priority>;\n\n/**\n * A dependency relationship.\n */\nexport type DependencyType = z.infer<typeof Dependency>;\n\n// =============================================================================\n// Configuration Types\n// =============================================================================\n\n/**\n * Project configuration.\n */\nexport type Config = z.infer<typeof ConfigSchema>;\n\n/**\n * Git common-dir local layout metadata.\n */\nexport type CommonDirLayout = z.infer<typeof CommonDirLayoutSchema>;\n\n/**\n * Shared metadata.\n */\nexport type Meta = z.infer<typeof MetaSchema>;\n\n/**\n * Per-node local state.\n */\nexport type LocalState = z.infer<typeof LocalStateSchema>;\n\n/**\n * Attic entry for conflict losers.\n */\nexport type AtticEntry = z.infer<typeof AtticEntrySchema>;\n\n// =============================================================================\n// Input Types for Commands\n// =============================================================================\n\n/**\n * Options for creating an issue.\n */\nexport interface CreateIssueOptions {\n title: string;\n description?: string;\n kind?: IssueKindType;\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent_id?: string;\n due_date?: string;\n deferred_until?: string;\n}\n\n/**\n * Options for updating an issue.\n */\nexport interface UpdateIssueOptions {\n title?: string;\n description?: string;\n notes?: string;\n kind?: IssueKindType;\n status?: IssueStatusType;\n priority?: PriorityType;\n assignee?: string | null;\n addLabels?: string[];\n removeLabels?: string[];\n parent_id?: string | null;\n due_date?: string | null;\n deferred_until?: string | null;\n}\n\n/**\n * Options for listing issues.\n */\nexport interface ListIssuesOptions {\n status?: IssueStatusType | IssueStatusType[];\n kind?: IssueKindType | IssueKindType[];\n priority?: PriorityType;\n assignee?: string;\n labels?: string[];\n parent?: string;\n all?: boolean;\n sort?: 'priority' | 'created' | 'updated';\n limit?: number;\n}\n\n/**\n * Options for searching issues.\n */\nexport interface SearchIssuesOptions {\n query: string;\n status?: IssueStatusType | IssueStatusType[];\n limit?: number;\n}\n\n// =============================================================================\n// CLI Utility Types\n// =============================================================================\n\n/**\n * A documentation section with title and slug.\n * Used by docs and design commands.\n */\nexport interface DocSection {\n title: string;\n slug: string;\n}\n\n/**\n * Logger interface for long-running operations in non-CLI layers.\n *\n * Allows core logic (file/, lib/) to report progress without depending on\n * the CLI output layer. CLI commands create an OperationLogger via\n * `OutputManager.logger(spinner)` and pass it to core functions.\n *\n * All methods are required. Use `noopLogger` when no logging is needed.\n */\nexport interface OperationLogger {\n /** Key milestones — drives the spinner in CLI context */\n progress: (message: string) => void;\n /** Operational detail (shown with --verbose or --debug) */\n info: (message: string) => void;\n /** Non-fatal warnings */\n warn: (message: string) => void;\n /** Internal state for troubleshooting (shown with --debug only) */\n debug: (message: string) => void;\n}\n\n/**\n * No-op logger for when no logging is needed.\n * Analogous to noopSpinner in the CLI layer.\n */\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nconst noop = () => {};\nexport const noopLogger: OperationLogger = {\n progress: noop,\n info: noop,\n warn: noop,\n debug: noop,\n};\n","/**\n * Markdown utilities for processing markdown content.\n *\n * Uses gray-matter for parsing and centralized yaml-utils for stringify to ensure\n * proper handling of special YAML characters (colons, quotes, etc.).\n */\n\nimport matter from 'gray-matter';\n\nimport { stringifyYamlCompact } from './yaml-utils.js';\n\nexport interface ParsedMarkdown {\n /** Raw frontmatter string (without --- delimiters), or null if no frontmatter */\n frontmatter: string | null;\n /** Body content after frontmatter, with leading newlines trimmed */\n body: string;\n}\n\n/**\n * Normalize line endings to LF.\n */\nexport function normalizeLineEndings(content: string): string {\n return content.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n}\n\n/**\n * Parse markdown content into frontmatter and body.\n * Handles both LF and CRLF line endings.\n *\n * @returns Object with frontmatter (null if none) and body\n */\nexport function parseMarkdown(content: string): ParsedMarkdown {\n const normalized = normalizeLineEndings(content);\n\n if (!matter.test(normalized)) {\n return { frontmatter: null, body: content };\n }\n\n try {\n const parsed = matter(normalized);\n\n // Extract frontmatter from parsed.data by stringifying back to YAML\n // The matter property is unreliable, so we reconstruct from data\n const data = parsed.data;\n let frontmatter: string | null = null;\n\n if (data && Object.keys(data).length > 0) {\n // Use centralized yaml-utils for proper handling of special characters\n // (colons, quotes, multiline strings, etc.)\n frontmatter = stringifyYamlCompact(data).trimEnd();\n } else {\n // Empty frontmatter (just --- followed by ---)\n frontmatter = '';\n }\n\n // Body with leading newlines trimmed\n const body = parsed.content.replace(/^\\n+/, '');\n\n return { frontmatter, body };\n } catch {\n // Invalid/unclosed frontmatter - treat as no frontmatter\n return { frontmatter: null, body: content };\n }\n}\n\n/**\n * Parse YAML frontmatter from markdown content.\n * Returns the frontmatter content (without delimiters) or null if no valid frontmatter.\n * Handles both LF and CRLF line endings.\n */\nexport function parseFrontmatter(content: string): string | null {\n return parseMarkdown(content).frontmatter;\n}\n\n/**\n * Strip YAML frontmatter from markdown content.\n * Returns the body content without frontmatter, with leading newlines trimmed.\n * Handles both LF and CRLF line endings.\n */\nexport function stripFrontmatter(content: string): string {\n return parseMarkdown(content).body;\n}\n\n/**\n * Insert content after YAML frontmatter.\n * If no frontmatter exists, prepends the content.\n * Content is inserted directly after ---. Include leading newlines in toInsert if needed.\n */\nexport function insertAfterFrontmatter(content: string, toInsert: string): string {\n const { frontmatter, body } = parseMarkdown(content);\n\n if (frontmatter === null) {\n return toInsert + content;\n }\n\n const frontmatterBlock = frontmatter ? `---\\n${frontmatter}\\n---` : '---\\n---';\n return `${frontmatterBlock}\\n${toInsert}\\n\\n${body}`;\n}\n","/**\n * YAML front matter parser and serializer for issue files.\n *\n * Issues are stored as Markdown files with YAML front matter:\n * ---\n * type: is\n * id: is-a1b2c3\n * ...\n * ---\n *\n * Description body here.\n *\n * ## Notes\n *\n * Working notes here.\n *\n * See: tbd-design.md §2.1 Markdown + YAML Front Matter Format\n */\n\nimport matter from 'gray-matter';\nimport { parse as parseYaml } from 'yaml';\n\nimport { normalizeLineEndings } from '../utils/markdown-utils.js';\nimport { sortKeys, stringifyYaml } from '../utils/yaml-utils.js';\nimport type { Issue } from '../lib/types.js';\nimport { IssueSchema, ISSUE_FIELD_ORDER } from '../lib/schemas.js';\n\n/**\n * gray-matter options using the 'yaml' package as engine.\n * This preserves date strings instead of converting them to Date objects.\n */\nexport const matterOptions = {\n engines: {\n yaml: {\n parse: (str: string): object => parseYaml(str) as object,\n stringify: (obj: object): string => stringifyYaml(obj),\n },\n },\n};\n\n/**\n * Parsed issue file content.\n */\nexport interface ParsedIssueFile {\n frontmatter: Record<string, unknown>;\n description: string;\n notes: string;\n}\n\n/**\n * Parse a Markdown file with YAML front matter.\n * Uses gray-matter for consistent frontmatter parsing.\n * Handles both LF and CRLF line endings.\n */\nexport function parseMarkdownWithFrontmatter(content: string): ParsedIssueFile {\n // Normalize CRLF to LF before parsing\n const normalizedContent = normalizeLineEndings(content);\n\n // Check for valid frontmatter\n if (!matter.test(normalizedContent)) {\n throw new Error('Invalid format: missing front matter opening delimiter');\n }\n\n const parsed = matter(normalizedContent, matterOptions);\n\n // gray-matter returns empty object if no closing delimiter found\n // but the raw matter string will be empty if parsing failed\n if (parsed.matter === '' && !normalizedContent.includes('---\\n---')) {\n // Check if there's actually a closing delimiter\n const lines = normalizedContent.split('\\n');\n let hasClosing = false;\n for (let i = 1; i < lines.length; i++) {\n if (lines[i]?.trim() === '---') {\n hasClosing = true;\n break;\n }\n }\n if (!hasClosing) {\n throw new Error('Invalid format: missing front matter closing delimiter');\n }\n }\n\n const frontmatter = parsed.data as Record<string, unknown>;\n\n // Parse body - split into description and notes\n const body = parsed.content.trim();\n\n // Find notes section\n const notesMatch = /\\n## Notes\\n/i.exec(body);\n let description = body;\n let notes = '';\n\n if (notesMatch?.index !== undefined) {\n description = body.slice(0, notesMatch.index).trim();\n notes = body.slice(notesMatch.index + notesMatch[0].length).trim();\n }\n\n return { frontmatter, description, notes };\n}\n\n/**\n * Parse an issue from Markdown file content.\n */\nexport function parseIssue(content: string): Issue {\n const { frontmatter, description, notes } = parseMarkdownWithFrontmatter(content);\n\n // Merge body content into frontmatter\n const data = {\n ...frontmatter,\n description: description || undefined,\n notes: notes || undefined,\n };\n\n // Validate and parse with Zod\n return IssueSchema.parse(data);\n}\n\n/**\n * Serialize an issue to Markdown file content.\n * Uses canonical serialization for deterministic output.\n */\nexport function serializeIssue(issue: Issue): string {\n // Extract body fields\n const { description, notes, ...metadata } = issue;\n\n // Sort keys using canonical field order (not alphabetical)\n const sortedMetadata = sortKeys(metadata, ISSUE_FIELD_ORDER);\n\n // Serialize YAML with compact output for frontmatter.\n // sortMapEntries: false preserves our manual ordering.\n const yaml = stringifyYaml(sortedMetadata, {\n lineWidth: 0,\n nullStr: 'null',\n sortMapEntries: false,\n });\n\n // Build the file content\n // Note: No blank line between closing --- and body content\n const parts = ['---', yaml.trim(), '---'];\n\n if (description) {\n parts.push(description.trim());\n }\n\n if (notes) {\n parts.push('');\n parts.push('## Notes');\n parts.push('');\n parts.push(notes.trim());\n }\n\n // Single newline at end\n return parts.join('\\n') + '\\n';\n}\n","/**\n * tbd: Git-native issue tracking for AI agents and humans\n *\n * This is the library entry point. All exports here should be node-free\n * to support browser/edge runtime usage. CLI-specific code is in ./cli/.\n */\n\n// Version injected at build time\ndeclare const __TBD_VERSION__: string;\n\n/**\n * Package version, derived from git at build time.\n * Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.\n */\nexport const VERSION: string =\n typeof __TBD_VERSION__ !== 'undefined' ? __TBD_VERSION__ : 'development';\n\n// Re-export schemas for library consumers\nexport * from './lib/schemas.js';\nexport * from './lib/types.js';\n\n// Re-export core operations (these should be node-free)\nexport { parseIssue, serializeIssue } from './file/parser.js';\n"],"mappings":"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"}