get-tbd 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +38 -6
  2. package/dist/bin.mjs +3494 -949
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/cli.mjs +1827 -1312
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/{config-1ouUTKQr.mjs → config-B1w3pAkY.mjs} +142 -290
  7. package/dist/config-B1w3pAkY.mjs.map +1 -0
  8. package/dist/config-DXhifxOw.mjs +3 -0
  9. package/dist/doc-cache-CamtXfi4.mjs +1035 -0
  10. package/dist/doc-cache-CamtXfi4.mjs.map +1 -0
  11. package/dist/doc-cache-CiBwpDTf.mjs +3 -0
  12. package/dist/doc-fork-BMjqzfAs.mjs +3 -0
  13. package/dist/doc-fork-CoPi1G1N.mjs +304 -0
  14. package/dist/doc-fork-CoPi1G1N.mjs.map +1 -0
  15. package/dist/docref-C7g0sjvL.mjs +167 -0
  16. package/dist/docref-C7g0sjvL.mjs.map +1 -0
  17. package/dist/docs/README.md +38 -6
  18. package/dist/docs/SKILL.md +13 -4
  19. package/dist/docs/guidelines/backward-compatibility-rules.md +1 -0
  20. package/dist/docs/guidelines/bun-monorepo-patterns.md +1 -0
  21. package/dist/docs/guidelines/cli-agent-skill-patterns.md +124 -38
  22. package/dist/docs/guidelines/commit-conventions.md +1 -0
  23. package/dist/docs/guidelines/common-doc-guidelines.md +1 -0
  24. package/dist/docs/guidelines/convex-limits-best-practices.md +1 -0
  25. package/dist/docs/guidelines/convex-rules.md +1 -0
  26. package/dist/docs/guidelines/electron-app-development-patterns.md +1 -0
  27. package/dist/docs/guidelines/error-handling-rules.md +1 -0
  28. package/dist/docs/guidelines/general-coding-rules.md +1 -0
  29. package/dist/docs/guidelines/general-comment-rules.md +1 -0
  30. package/dist/docs/guidelines/general-eng-agent-principles.md +1 -0
  31. package/dist/docs/guidelines/general-tdd-guidelines.md +1 -0
  32. package/dist/docs/guidelines/general-testing-rules.md +1 -0
  33. package/dist/docs/guidelines/golden-testing-guidelines.md +1 -0
  34. package/dist/docs/guidelines/pnpm-monorepo-patterns.md +1 -0
  35. package/dist/docs/guidelines/python-cli-patterns.md +1 -0
  36. package/dist/docs/guidelines/python-modern-guidelines.md +1 -0
  37. package/dist/docs/guidelines/python-rules.md +1 -0
  38. package/dist/docs/guidelines/release-notes-guidelines.md +1 -0
  39. package/dist/docs/guidelines/supply-chain-hardening.md +3 -2
  40. package/dist/docs/guidelines/tbd-sync-troubleshooting.md +1 -0
  41. package/dist/docs/guidelines/typescript-cli-tool-rules.md +1 -0
  42. package/dist/docs/guidelines/typescript-code-coverage.md +1 -0
  43. package/dist/docs/guidelines/typescript-rules.md +1 -0
  44. package/dist/docs/guidelines/typescript-sorting-patterns.md +1 -0
  45. package/dist/docs/guidelines/typescript-yaml-handling-rules.md +1 -0
  46. package/dist/docs/references/docmap-format.md +64 -0
  47. package/dist/docs/references/docref-format.md +71 -0
  48. package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +17 -4
  49. package/dist/docs/shortcuts/standard/new-shortcut.md +5 -5
  50. package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +1 -1
  51. package/dist/docs/shortcuts/standard/suggest-upstream-improvements.md +69 -0
  52. package/dist/docs/shortcuts/standard/sync-failure-recovery.md +1 -1
  53. package/dist/docs/shortcuts/standard/welcome-user.md +28 -0
  54. package/dist/docs/shortcuts/system/skill-baseline.md +13 -4
  55. package/dist/docs/tbd-design.md +141 -0
  56. package/dist/docs/tbd-docs.md +169 -5
  57. package/dist/docs/tbd-prime.md +0 -1
  58. package/dist/docs/templates/qa-playbook.md +3 -1
  59. package/dist/docs/templates/research-brief.md +2 -2
  60. package/dist/fork-manifest-ByU7U2do.mjs +253 -0
  61. package/dist/fork-manifest-ByU7U2do.mjs.map +1 -0
  62. package/dist/fork-manifest-C7lGRq-6.mjs +3 -0
  63. package/dist/{id-mapping-mtoSP9Qt.mjs → id-mapping-D0iitY-F.mjs} +1 -1
  64. package/dist/{id-mapping-687_UEsy.mjs → id-mapping-DAIeLKzm.mjs} +8 -200
  65. package/dist/id-mapping-DAIeLKzm.mjs.map +1 -0
  66. package/dist/index.d.mts +90 -13
  67. package/dist/index.mjs +3 -3
  68. package/dist/lockfile-BR0laSDy.mjs +198 -0
  69. package/dist/lockfile-BR0laSDy.mjs.map +1 -0
  70. package/dist/paths-C1DpnZJW.mjs +405 -0
  71. package/dist/paths-C1DpnZJW.mjs.map +1 -0
  72. package/dist/{schemas-f0EcuAVu.mjs → schemas-lCwRk30L.mjs} +19 -6
  73. package/dist/schemas-lCwRk30L.mjs.map +1 -0
  74. package/dist/{src-DTyyuaG_.mjs → src-CxKOynr1.mjs} +3 -3
  75. package/dist/src-CxKOynr1.mjs.map +1 -0
  76. package/dist/tbd +3494 -949
  77. package/dist/yaml-utils-BPy991by.mjs.map +1 -1
  78. package/package.json +1 -1
  79. package/dist/config-1ouUTKQr.mjs.map +0 -1
  80. package/dist/config-YRRW9l89.mjs +0 -3
  81. package/dist/id-mapping-687_UEsy.mjs.map +0 -1
  82. package/dist/schemas-f0EcuAVu.mjs.map +0 -1
  83. package/dist/src-DTyyuaG_.mjs.map +0 -1
package/dist/cli.mjs CHANGED
@@ -1,25 +1,29 @@
1
- import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
2
- import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-DTyyuaG_.mjs";
1
+ import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-lCwRk30L.mjs";
2
+ import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-CxKOynr1.mjs";
3
3
  import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
4
- import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveAtticDir, I as resolveDataSyncDir, L as resolveSharedTbdPaths, M as getWorkspaceDir, N as isCommonDirOutsideProject, O as TBD_SHORTCUTS_SYSTEM, P as isValidWorkspaceName, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-1ouUTKQr.mjs";
5
- import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-687_UEsy.mjs";
4
+ import { C as parseDate, S as nowFilenameTimestamp, _ as isCompatibleFormat, a as isInitialized, b as now, c as readConfigWithMigration, d as updateLocalState, f as writeConfig, g as formatUpgradeMessage, h as FORMAT_HISTORY, i as initConfig, l as readLocalState, m as CURRENT_FORMAT, n as findTbdRoot, o as markWelcomeSeen, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as stampSetupVersion, v as isFormatCompatibleWithSupported, x as nowDate, y as normalizeTimestamp } from "./config-B1w3pAkY.mjs";
5
+ import { A as isCommonDirOutsideProject, C as TBD_GUIDELINES_DIR, D as WORKSPACES_DIR, E as TBD_TEMPLATES_DIR, F as gitSafeEnv, M as resolveAtticDir, N as resolveDataSyncDir, O as WORKTREE_DIR_NAME, P as resolveSharedTbdPaths, S as TBD_DOCS_DIR, T as TBD_SHORTCUTS_SYSTEM, _ as FORK_TEMPLATES_DIR, b as SYNC_BRANCH, c as DATA_SYNC_DIR_NAME, d as DEFAULT_SHORTCUT_PATHS, f as DEFAULT_TEMPLATE_PATHS, g as FORK_SHORTCUTS_DIR, h as FORK_REFERENCES_DIR, i as CACHE_TEMPLATE_PATHS, j as isValidWorkspaceName, k as getWorkspaceDir, l as DEFAULT_GUIDELINES_PATHS, m as FORK_GUIDELINES_DIR, n as CACHE_REFERENCE_PATHS, p as FORK_DIR, r as CACHE_SHORTCUT_PATHS, s as DATA_SYNC_DIR, t as CACHE_GUIDELINES_PATHS, u as DEFAULT_REFERENCE_PATHS, v as LEGACY_WORKTREE_DIR, w as TBD_SHORTCUTS_STANDARD, x as TBD_DIR } from "./paths-C1DpnZJW.mjs";
6
+ import { n as withLockfile, t as DATA_SYNC_LOCK_OPTIONS } from "./lockfile-BR0laSDy.mjs";
7
+ import { _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-DAIeLKzm.mjs";
8
+ import { a as tryParseDocRef, i as parseDocRef, r as normalizeDocRef, t as DocRefError } from "./docref-C7g0sjvL.mjs";
9
+ import { C as withForkManifestLock, S as upsertFork, T as writeForkManifest, _ as normalizeLineEndings, c as compareVersionsLoose, d as findFork, h as hashContent, m as hasUnresolvedConflict, n as CONFLICT_LABELS, w as writeBaseContent, y as readForkManifest } from "./fork-manifest-ByU7U2do.mjs";
10
+ import { c as formatDocSize, d as fetchWithGhFallback, f as gitDocRefToRawUrl, l as formatTimestampAgo, o as SCORE_PREFIX_MATCH, s as generateShortcutDirectory, t as DocCache, u as syncDocsWithDefaults } from "./doc-cache-CamtXfi4.mjs";
11
+ import { a as forkFilePath, c as listLocalForkFiles, d as regenerateForkDirReadme, f as unforkDoc, i as forkDoc, l as readForkBase, n as KIND_DIR, r as computeForkDriftSummary, s as forkStatusFor, t as ForkConflictError, u as readForkFile } from "./doc-fork-CoPi1G1N.mjs";
6
12
  import { createRequire } from "node:module";
7
- import { ZodError } from "zod";
8
- import matter from "gray-matter";
13
+ import { ZodError, z } from "zod";
9
14
  import { parse } from "yaml";
10
15
  import { Command } from "commander";
11
16
  import pc from "picocolors";
12
17
  import { marked } from "marked";
13
18
  import { markedTerminal } from "marked-terminal";
14
19
  import { execFile, execSync, spawn, spawnSync } from "node:child_process";
15
- import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
20
+ import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
16
21
  import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
17
22
  import { writeFile } from "atomically";
23
+ import { tmpdir } from "node:os";
18
24
  import { promisify } from "node:util";
19
25
  import { randomUUID } from "node:crypto";
20
26
  import { fileURLToPath } from "node:url";
21
- import prettyBytes from "pretty-bytes";
22
- import prettyMs from "pretty-ms";
23
27
  import GithubSlugger from "github-slugger";
24
28
 
25
29
  //#region src/cli/lib/version.ts
@@ -49,12 +53,12 @@ function getVersion() {
49
53
  /**
50
54
  * Clean published npm version for pinned `get-tbd@<version>` fallbacks (the
51
55
  * session script and any other generated install hint). Always the package.json
52
- * semver NOT the git-describe display version, which for a local/dirty build
56
+ * semver, NOT the git-describe display version, which for a local/dirty build
53
57
  * is an unpublished string like `0.2.3-dev.2.abc1234-dirty` that npm cannot
54
58
  * install and that would churn generated files on every build.
55
59
  */
56
60
  function getPinnedNpmVersion() {
57
- return "0.2.3";
61
+ return "0.3.0";
58
62
  }
59
63
  /**
60
64
  * CLI version - use this instead of importing VERSION directly from index.ts
@@ -214,7 +218,7 @@ function createHelpEpilog(colorOption = "auto") {
214
218
  "",
215
219
  " This initializes tbd and configures your coding agents automatically.",
216
220
  ` To refresh setup (idempotent, safe anytime): ${colors.green("`tbd setup --auto`")}`,
217
- ` For interactive setup: ${colors.dim("`tbd setup --interactive`")}`,
221
+ ` For managed docs: ${colors.dim("`tbd docs`")}`,
218
222
  "",
219
223
  colors.blue("For more on tbd, see: https://github.com/jlevy/tbd")
220
224
  ].join("\n");
@@ -647,7 +651,7 @@ var SyncError = class extends CLIError {
647
651
  */
648
652
  var UnrelatedHistoriesError = class extends SyncError {
649
653
  constructor(remote = "origin", syncBranch = "tbd-sync") {
650
- super(`${remote}/${syncBranch} has an unrelated history (no common ancestor) push cannot fast-forward and a merge would refuse.\nRun \`tbd doctor --fix\` to reconcile the unrelated histories (non-destructive; a backup branch is created first).`);
654
+ super(`${remote}/${syncBranch} has an unrelated history (no common ancestor); push cannot fast-forward and a merge would refuse.\nRun \`tbd doctor --fix\` to reconcile the unrelated histories (non-destructive; a backup branch is created first).`);
651
655
  this.name = "UnrelatedHistoriesError";
652
656
  }
653
657
  };
@@ -897,58 +901,6 @@ async function getBeadsPrefix(cwd) {
897
901
  }
898
902
  }
899
903
 
900
- //#endregion
901
- //#region src/utils/time-utils.ts
902
- /**
903
- * Time utilities for tbd.
904
- *
905
- * All timestamps should be ISO 8601 UTC format with Z suffix.
906
- * Use these functions instead of raw Date calls for consistency.
907
- */
908
- /**
909
- * Get current time as ISO 8601 UTC string.
910
- * Use this when recording timestamps for new/updated issues.
911
- */
912
- function now() {
913
- return (/* @__PURE__ */ new Date()).toISOString();
914
- }
915
- /**
916
- * Get current time as Date object.
917
- * Use this when you need date arithmetic or comparisons.
918
- */
919
- function nowDate() {
920
- return /* @__PURE__ */ new Date();
921
- }
922
- /**
923
- * Parse a timestamp string to Date object.
924
- * Returns null if the timestamp is invalid.
925
- */
926
- function parseDate(timestamp) {
927
- if (!timestamp) return null;
928
- try {
929
- const date = new Date(timestamp);
930
- if (isNaN(date.getTime())) return null;
931
- return date;
932
- } catch {
933
- return null;
934
- }
935
- }
936
- /**
937
- * Normalize any timestamp to ISO 8601 UTC format with Z suffix.
938
- * Handles various formats including timezone offsets like -08:00.
939
- * Returns null if the timestamp is invalid or missing.
940
- */
941
- function normalizeTimestamp(timestamp) {
942
- return parseDate(timestamp)?.toISOString() ?? null;
943
- }
944
- /**
945
- * Get current time as a filename-safe timestamp.
946
- * Replaces colons with dashes and removes milliseconds for filesystem compatibility.
947
- */
948
- function nowFilenameTimestamp() {
949
- return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
950
- }
951
-
952
904
  //#endregion
953
905
  //#region src/utils/zod-error-utils.ts
954
906
  /**
@@ -1079,7 +1031,7 @@ function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
1079
1031
  *
1080
1032
  * See: tbd-design.md §3.3 Sync Operations
1081
1033
  */
1082
- const execFileAsync$1 = promisify(execFile);
1034
+ const execFileAsync = promisify(execFile);
1083
1035
  /**
1084
1036
  * Error thrown by {@link git} when a git command exits non-zero.
1085
1037
  *
@@ -1145,7 +1097,10 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
1145
1097
  */
1146
1098
  async function git(...args) {
1147
1099
  try {
1148
- const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
1100
+ const { stdout } = await execFileAsync("git", args, {
1101
+ maxBuffer: GIT_MAX_BUFFER,
1102
+ env: gitSafeEnv()
1103
+ });
1149
1104
  return stdout.trim();
1150
1105
  } catch (err) {
1151
1106
  throw GitError.from(err, args);
@@ -1158,12 +1113,9 @@ async function git(...args) {
1158
1113
  */
1159
1114
  async function gitNoPrompt(...args) {
1160
1115
  try {
1161
- const { stdout } = await execFileAsync$1("git", args, {
1116
+ const { stdout } = await execFileAsync("git", args, {
1162
1117
  maxBuffer: GIT_MAX_BUFFER,
1163
- env: {
1164
- ...process.env,
1165
- GIT_TERMINAL_PROMPT: "0"
1166
- }
1118
+ env: gitSafeEnv({ GIT_TERMINAL_PROMPT: "0" })
1167
1119
  });
1168
1120
  return stdout.trim();
1169
1121
  } catch (err) {
@@ -1569,7 +1521,7 @@ async function branchExists(branch, baseDir) {
1569
1521
  * `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
1570
1522
  * matched; any other failure (auth/network/transient, or git not found) is a
1571
1523
  * check failure. Orphan-creating callers MUST branch on all three states and
1572
- * never treat `check-failed` as `absent` doing so risks creating a divergent
1524
+ * never treat `check-failed` as `absent`; doing so risks creating a divergent
1573
1525
  * local branch when the remote is merely unreachable.
1574
1526
  */
1575
1527
  async function probeRemoteBranch(remote, branch, baseDir) {
@@ -1861,7 +1813,7 @@ async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSy
1861
1813
  * Initialize the hidden worktree for the tbd-sync branch.
1862
1814
  * Follows the decision tree from tbd-design.md §2.3.
1863
1815
  *
1864
- * MUST be called while holding `withSharedDataSyncLock` it migrates legacy
1816
+ * MUST be called while holding `withSharedDataSyncLock`; it migrates legacy
1865
1817
  * per-checkout worktrees and creates the shared attached worktree on tbd-sync,
1866
1818
  * so concurrent callers can otherwise race branch ownership and migration.
1867
1819
  *
@@ -2081,7 +2033,7 @@ async function readBranchMapping(baseDir, ref) {
2081
2033
  * both sides instead of one side winning.
2082
2034
  *
2083
2035
  * Returns `null` when the bead does not exist on the `theirsRef` side (nothing to
2084
- * merge keep ours). Used by sync's conflict paths in place of git's line-based
2036
+ * merge, keep ours). Used by sync's conflict paths in place of git's line-based
2085
2037
  * text merge. See issue #155.
2086
2038
  *
2087
2039
  * @param repoDir - Directory to run git in (the data-sync worktree). `oursRef` and
@@ -2133,7 +2085,7 @@ async function preserveLosingVersion(dataSyncPath, loser) {
2133
2085
  * the rescue is restartable.
2134
2086
  *
2135
2087
  * MUST be called while holding `withSharedDataSyncLock`. A merge in progress
2136
- * aborts the rescue; a merely-dirty worktree does not its uncommitted
2088
+ * aborts the rescue; a merely-dirty worktree does not; its uncommitted
2137
2089
  * tbd-owned data-sync changes are committed first (so the backup branch captures
2138
2090
  * them), while any dirty path outside the data-sync tree aborts. See issue #158.
2139
2091
  */
@@ -2208,7 +2160,7 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, ba
2208
2160
  * - CORRUPTED: backup to .tbd/backups/, remove, then recreate
2209
2161
  * - MISSING: just create
2210
2162
  *
2211
- * MUST be called while holding `withSharedDataSyncLock` repair mutates
2163
+ * MUST be called while holding `withSharedDataSyncLock`; repair mutates
2212
2164
  * shared worktree and branch state and shares the same locking contract as
2213
2165
  * `initWorktree`.
2214
2166
  *
@@ -2371,6 +2323,17 @@ async function readCommonDirLayout(layoutPath) {
2371
2323
  }
2372
2324
  }
2373
2325
  /**
2326
+ * Whether an existing layout is simply from an older format than the checkout
2327
+ * config and can be re-stamped in place. This is the normal mid-migration state
2328
+ * (e.g. an f04 layout next to a config that just migrated to f05): the format
2329
+ * bump's only effect on the layout is the stamp, so rewriting it from the config
2330
+ * IS the layout migration. Anything else (future formats, storage mismatches)
2331
+ * stays a hard validation error.
2332
+ */
2333
+ function isLayoutUpgradeable(layout, config) {
2334
+ return layout.tbd_format !== config.tbd_format && layout.tbd_format in FORMAT_HISTORY && config.tbd_format in FORMAT_HISTORY && isFormatCompatibleWithSupported(layout.tbd_format, config.tbd_format) && layout.sync_storage === config.sync.storage;
2335
+ }
2336
+ /**
2374
2337
  * Validate that common-dir layout metadata matches the checkout config.
2375
2338
  */
2376
2339
  function validateCommonDirLayout(layout, config) {
@@ -2407,6 +2370,7 @@ async function writeCommonDirLayout(paths, config, existing) {
2407
2370
  async function ensureCommonDirLayout(paths, config) {
2408
2371
  const existing = await readCommonDirLayout(paths.sharedLayoutPath);
2409
2372
  if (existing) {
2373
+ if (isLayoutUpgradeable(existing, config)) return writeCommonDirLayout(paths, config, existing);
2410
2374
  validateCommonDirLayout(existing, config);
2411
2375
  return existing;
2412
2376
  }
@@ -2417,8 +2381,8 @@ async function ensureCommonDirLayout(paths, config) {
2417
2381
  * path is not writable by this process (EPERM/EACCES).
2418
2382
  *
2419
2383
  * The common trigger is an agent sandbox (e.g. Codex) where the *checkout* is
2420
- * writable but `$GIT_COMMON_DIR/tbd` which holds the shared sync worktree and
2421
- * the lock lives in the user's original repo directory, outside the sandbox's
2384
+ * writable but `$GIT_COMMON_DIR/tbd`, which holds the shared sync worktree and
2385
+ * the lock, lives in the user's original repo directory, outside the sandbox's
2422
2386
  * writable boundary. Read-only commands work, but any write needs the lock and
2423
2387
  * fails here. This is fatal for the command: without the lock we cannot safely
2424
2388
  * mutate shared state. See issue #164.
@@ -2427,7 +2391,7 @@ var SharedLockUnwritableError = class extends Error {
2427
2391
  constructor(code, paths, projectRoot) {
2428
2392
  const outside = isCommonDirOutsideProject(paths.gitCommonDir, projectRoot);
2429
2393
  const cause = outside ? `The checkout is writable, but the shared tbd state under ${paths.sharedTbdDir} is outside this process's writable area (a common agent-sandbox shape, e.g. Codex worktrees), so write commands cannot proceed.` : `The shared tbd state under ${paths.sharedTbdDir} is not writable by this process, so write commands cannot proceed.`;
2430
- const fix = outside ? `Fix: grant write access to ${paths.sharedTbdDir} in an agent sandbox such as Codex add it to the writable roots, or re-run with sandbox escalation.` : `Fix: ensure ${paths.sharedTbdDir} is writable by this user (check filesystem permissions).`;
2394
+ const fix = outside ? `Fix: grant write access to ${paths.sharedTbdDir}; in an agent sandbox such as Codex add it to the writable roots, or re-run with sandbox escalation.` : `Fix: ensure ${paths.sharedTbdDir} is writable by this user (check filesystem permissions).`;
2431
2395
  super(`Cannot acquire the shared tbd data-sync lock (${code}): ${paths.sharedLockPath}\n${cause}\n${fix} Run \`tbd doctor\` to confirm the diagnosis.`);
2432
2396
  this.code = code;
2433
2397
  this.name = "SharedLockUnwritableError";
@@ -2787,7 +2751,8 @@ async function probeDataSyncReadiness(tbdRoot) {
2787
2751
  const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
2788
2752
  const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
2789
2753
  const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
2790
- if (layout) validateCommonDirLayout(layout, config);
2754
+ const layoutNeedsUpgrade = layout !== null && isLayoutUpgradeable(layout, config);
2755
+ if (layout && !layoutNeedsUpgrade) validateCommonDirLayout(layout, config);
2791
2756
  const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
2792
2757
  return {
2793
2758
  config,
@@ -2796,7 +2761,7 @@ async function probeDataSyncReadiness(tbdRoot) {
2796
2761
  sharedPaths,
2797
2762
  layout,
2798
2763
  health,
2799
- ready: !migrated && layout !== null && health.valid
2764
+ ready: !migrated && layout !== null && !layoutNeedsUpgrade && health.valid
2800
2765
  };
2801
2766
  }
2802
2767
  /**
@@ -2840,12 +2805,12 @@ function notifyConfigMigrated(fromFormat, toFormat) {
2840
2805
  * The shared worktree can be absent in a fresh/ephemeral clone (or if it was
2841
2806
  * deleted). `withDataSyncContext` heals it transparently, but a silent heal on a
2842
2807
  * read/write command looks indistinguishable from "the tracker is empty" or "my
2843
- * issue was saved normally" the exact confusion reported in #135. A terse
2808
+ * issue was saved normally", the exact confusion reported in #135. A terse
2844
2809
  * stderr note keeps stdout (and JSON) clean while making the heal visible.
2845
2810
  */
2846
2811
  function notifyWorktreeRepaired(status) {
2847
2812
  if (status !== "missing" && status !== "prunable") return;
2848
- process.stderr.write(`• tbd-sync worktree was ${status} auto-materialized it (fresh clone, or the worktree was removed).\n`);
2813
+ process.stderr.write(`• tbd-sync worktree was ${status}; auto-materialized it (fresh clone, or the worktree was removed).\n`);
2849
2814
  }
2850
2815
  async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
2851
2816
  const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
@@ -4570,6 +4535,53 @@ const listDepsCommand = new Command("list").description("List dependencies for a
4570
4535
  });
4571
4536
  const depCommand = new Command("dep").description("Manage issue dependencies").addCommand(addCommand).addCommand(removeCommand).addCommand(listDepsCommand);
4572
4537
 
4538
+ //#endregion
4539
+ //#region src/cli/lib/docs-sync-output.ts
4540
+ /** Print the result of a docs-cache sync (writes applied). */
4541
+ function printDocSyncResult(output, result) {
4542
+ if (!(result.added.length > 0 || result.updated.length > 0 || result.removed.length > 0 || result.pruned.length > 0)) {
4543
+ output.success("Docs up to date");
4544
+ return;
4545
+ }
4546
+ const parts = [];
4547
+ if (result.added.length > 0) parts.push(`+${result.added.length}`);
4548
+ if (result.updated.length > 0) parts.push(`~${result.updated.length}`);
4549
+ if (result.removed.length > 0) parts.push(`-${result.removed.length}`);
4550
+ if (parts.length > 0) output.success(`Synced docs: ${parts.join(" ")} doc(s)`);
4551
+ if (result.pruned.length > 0) output.info(`Removed ${result.pruned.length} stale config entry/entries`);
4552
+ for (const err of result.errors) output.warn(`Doc sync error: ${err.path}: ${err.error}`);
4553
+ }
4554
+ /** Print what a docs-cache sync would change (dry-run / status view). */
4555
+ function printDocSyncStatus(output, result) {
4556
+ const colors = output.getColors();
4557
+ if (!(result.added.length > 0 || result.updated.length > 0 || result.removed.length > 0 || result.pruned.length > 0)) {
4558
+ output.success("Docs up to date");
4559
+ return;
4560
+ }
4561
+ console.log(colors.bold("Docs:"));
4562
+ if (result.added.length > 0) console.log(` ${colors.success(`+${result.added.length}`)} new doc(s) available`);
4563
+ if (result.updated.length > 0) console.log(` ${colors.warn(`~${result.updated.length}`)} doc(s) to update`);
4564
+ if (result.removed.length > 0) console.log(` ${colors.error(`-${result.removed.length}`)} doc(s) to remove`);
4565
+ if (result.pruned.length > 0) console.log(` ${colors.dim(`${result.pruned.length}`)} stale config entry/entries`);
4566
+ }
4567
+ /**
4568
+ * One-line awareness notice for forked docs: a cache refresh is exactly when
4569
+ * forks become stale, so drift is surfaced here, but never acted on (only the
4570
+ * explicit `tbd docs update` mutates tracked files). Best-effort: never fails
4571
+ * the surrounding sync.
4572
+ */
4573
+ async function printForkDriftNotice(output, tbdRoot) {
4574
+ try {
4575
+ const drift = await computeForkDriftSummary(tbdRoot, FORK_DIR, await readForkManifest(tbdRoot));
4576
+ if (drift.forks === 0) return;
4577
+ const parts = [];
4578
+ if (drift.stale > 0) parts.push(`${drift.stale} forked doc(s) have upstream updates; run 'tbd docs update'`);
4579
+ if (drift.conflicted > 0) parts.push(`${drift.conflicted} with unresolved conflict markers`);
4580
+ if (drift.missing > 0) parts.push(`${drift.missing} missing (deleted/renamed); see 'tbd docs status'`);
4581
+ if (parts.length > 0) output.notice(`Docs: ${parts.join("; ")}`);
4582
+ } catch {}
4583
+ }
4584
+
4573
4585
  //#endregion
4574
4586
  //#region src/lib/sync-summary.ts
4575
4587
  /**
@@ -4682,511 +4694,6 @@ function parseGitDiff(diffOutput) {
4682
4694
  return tallies;
4683
4695
  }
4684
4696
 
4685
- //#endregion
4686
- //#region src/file/github-fetch.ts
4687
- /**
4688
- * GitHub URL utilities and content fetching with `gh` CLI fallback.
4689
- *
4690
- * Consolidates all GitHub-specific URL handling and fetching logic:
4691
- * - GitHub blob URL → raw URL conversion
4692
- * - Direct HTTP fetch with timeout
4693
- * - Fallback to `gh api` on 403 errors (common in restricted environments)
4694
- *
4695
- * This module is reusable across doc-sync, doc-add, and any future code
4696
- * that needs to fetch content from GitHub.
4697
- */
4698
- const execFileAsync = promisify(execFile);
4699
- /**
4700
- * Regular expression to match GitHub blob URLs.
4701
- *
4702
- * Matches patterns like:
4703
- * https://github.com/{owner}/{repo}/blob/{ref}/{path}
4704
- */
4705
- const GITHUB_BLOB_RE = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/;
4706
- /**
4707
- * Regular expression to match raw.githubusercontent.com URLs.
4708
- *
4709
- * Matches patterns like:
4710
- * https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}
4711
- */
4712
- const RAW_GITHUB_RE = /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
4713
- /**
4714
- * Convert a GitHub blob URL to a raw.githubusercontent.com URL.
4715
- *
4716
- * If the URL is already a raw URL or not a GitHub blob URL, returns it unchanged.
4717
- *
4718
- * @example
4719
- * githubBlobToRawUrl('https://github.com/org/repo/blob/main/docs/file.md')
4720
- * // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md'
4721
- *
4722
- * @example
4723
- * githubBlobToRawUrl('https://raw.githubusercontent.com/org/repo/main/docs/file.md')
4724
- * // => 'https://raw.githubusercontent.com/org/repo/main/docs/file.md' (unchanged)
4725
- */
4726
- function githubBlobToRawUrl(url) {
4727
- const match = GITHUB_BLOB_RE.exec(url);
4728
- if (!match) return url;
4729
- const [, owner, repo, ref, path] = match;
4730
- return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`;
4731
- }
4732
- /**
4733
- * Parse a raw.githubusercontent.com URL into its components.
4734
- *
4735
- * @returns The parsed components, or null if not a raw GitHub URL
4736
- */
4737
- function parseRawGitHubUrl(url) {
4738
- const match = RAW_GITHUB_RE.exec(url);
4739
- if (!match) return null;
4740
- return {
4741
- owner: match[1],
4742
- repo: match[2],
4743
- ref: match[3],
4744
- path: match[4]
4745
- };
4746
- }
4747
- /** Default timeout for URL fetches in milliseconds */
4748
- const DEFAULT_FETCH_TIMEOUT = 3e4;
4749
- /**
4750
- * Fetch content from a URL via direct HTTP.
4751
- *
4752
- * @throws If the request fails or returns a non-OK status
4753
- */
4754
- async function directFetch(url, options) {
4755
- const timeout = options?.timeout ?? DEFAULT_FETCH_TIMEOUT;
4756
- const controller = new AbortController();
4757
- const timer = setTimeout(() => {
4758
- controller.abort();
4759
- }, timeout);
4760
- try {
4761
- const response = await fetch(url, {
4762
- signal: controller.signal,
4763
- headers: {
4764
- "User-Agent": "get-tbd/1.0",
4765
- Accept: "text/plain, text/markdown, */*"
4766
- }
4767
- });
4768
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
4769
- return await response.text();
4770
- } finally {
4771
- clearTimeout(timer);
4772
- }
4773
- }
4774
- /**
4775
- * Fetch content from a GitHub raw URL using `gh api`.
4776
- *
4777
- * Converts raw.githubusercontent.com URLs to GitHub API content endpoints
4778
- * and decodes the base64-encoded response.
4779
- *
4780
- * @throws If the URL is not a GitHub URL or gh CLI fails
4781
- */
4782
- async function ghCliFetch(rawUrl) {
4783
- const parsed = parseRawGitHubUrl(rawUrl);
4784
- if (parsed) try {
4785
- const { stdout } = await execFileAsync("gh", [
4786
- "api",
4787
- `/repos/${parsed.owner}/${parsed.repo}/contents/${parsed.path}?ref=${parsed.ref}`,
4788
- "--jq",
4789
- ".content",
4790
- "-H",
4791
- "Accept: application/vnd.github.v3+json"
4792
- ]);
4793
- const base64Content = stdout.trim();
4794
- return Buffer.from(base64Content, "base64").toString("utf-8");
4795
- } catch (error) {
4796
- throw new Error(`Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`);
4797
- }
4798
- try {
4799
- const { stdout } = await execFileAsync("gh", ["api", rawUrl]);
4800
- return stdout;
4801
- } catch (error) {
4802
- throw new Error(`Failed to fetch via gh CLI: ${error instanceof Error ? error.message : String(error)}`);
4803
- }
4804
- }
4805
- /**
4806
- * Fetch content from a URL, falling back to `gh` CLI on 403 errors.
4807
- *
4808
- * GitHub.com and raw.githubusercontent.com may block requests from
4809
- * certain environments (e.g., CI runners, corporate proxies). When
4810
- * a 403 is received, we retry using `gh api` which authenticates
4811
- * via the user's GitHub CLI token.
4812
- *
4813
- * This function also auto-converts GitHub blob URLs to raw URLs.
4814
- *
4815
- * @returns The fetched content and whether gh CLI was used
4816
- * @throws If both direct fetch and gh CLI fallback fail
4817
- */
4818
- async function fetchWithGhFallback(url, options) {
4819
- const rawUrl = githubBlobToRawUrl(url);
4820
- try {
4821
- return {
4822
- content: await directFetch(rawUrl, options),
4823
- usedGhCli: false
4824
- };
4825
- } catch (error) {
4826
- if (!(error instanceof Error && error.message.includes("HTTP 403"))) throw error;
4827
- }
4828
- return {
4829
- content: await ghCliFetch(rawUrl),
4830
- usedGhCli: true
4831
- };
4832
- }
4833
-
4834
- //#endregion
4835
- //#region src/file/doc-sync.ts
4836
- /**
4837
- * DocSync - Sync documentation files from configured sources.
4838
- *
4839
- * Syncs docs from internal bundled sources and external URLs to .tbd/docs/.
4840
- *
4841
- * See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md
4842
- */
4843
- /** Prefix for internal bundled doc sources */
4844
- const INTERNAL_SOURCE_PREFIX = "internal:";
4845
- /**
4846
- * Syncs documentation files from configured sources.
4847
- *
4848
- * Supports:
4849
- * - Internal bundled docs (using internal: prefix)
4850
- * - External URLs (raw.githubusercontent.com, etc.)
4851
- */
4852
- var DocSync = class {
4853
- docsDir;
4854
- /**
4855
- * Create a new DocSync instance.
4856
- *
4857
- * @param tbdRoot - The tbd project root directory (parent of .tbd/)
4858
- * @param config - The doc_cache configuration mapping dest paths to sources
4859
- */
4860
- constructor(tbdRoot, config) {
4861
- this.tbdRoot = tbdRoot;
4862
- this.config = config;
4863
- this.docsDir = join(tbdRoot, TBD_DOCS_DIR);
4864
- }
4865
- /**
4866
- * Parse a source string into a DocSource.
4867
- *
4868
- * @example
4869
- * parseSource('internal:shortcuts/standard/code-review-and-commit.md')
4870
- * // => { type: 'internal', location: 'shortcuts/standard/code-review-and-commit.md' }
4871
- *
4872
- * @example
4873
- * parseSource('https://raw.githubusercontent.com/org/repo/main/file.md')
4874
- * // => { type: 'url', location: 'https://...' }
4875
- */
4876
- parseSource(source) {
4877
- if (source.startsWith(INTERNAL_SOURCE_PREFIX)) return {
4878
- type: "internal",
4879
- location: source.slice(9)
4880
- };
4881
- return {
4882
- type: "url",
4883
- location: source
4884
- };
4885
- }
4886
- /**
4887
- * Fetch content from a source.
4888
- *
4889
- * @throws If the source cannot be fetched
4890
- */
4891
- async fetchContent(source) {
4892
- if (source.type === "internal") return this.fetchInternalContent(source.location);
4893
- return this.fetchUrlContent(source.location);
4894
- }
4895
- /**
4896
- * Fetch content from an internal bundled doc.
4897
- */
4898
- async fetchInternalContent(location) {
4899
- const basePaths = getDocsBasePath();
4900
- for (const basePath of basePaths) {
4901
- const fullPath = join(basePath, location);
4902
- try {
4903
- await access(fullPath);
4904
- return await readFile(fullPath, "utf-8");
4905
- } catch {}
4906
- }
4907
- throw new Error(`Internal doc not found: ${location}`);
4908
- }
4909
- /**
4910
- * Fetch content from a URL (with gh CLI fallback on 403).
4911
- */
4912
- async fetchUrlContent(url) {
4913
- const { content } = await fetchWithGhFallback(url);
4914
- return content;
4915
- }
4916
- /**
4917
- * Get the current state of the docs directory.
4918
- * Returns a set of relative paths that exist in .tbd/docs/.
4919
- */
4920
- async getCurrentState() {
4921
- const paths = /* @__PURE__ */ new Set();
4922
- try {
4923
- await access(this.docsDir);
4924
- } catch {
4925
- return paths;
4926
- }
4927
- await this.scanDirectory(this.docsDir, "", paths);
4928
- return paths;
4929
- }
4930
- /**
4931
- * Recursively scan a directory and add all .md file paths to the set.
4932
- */
4933
- async scanDirectory(baseDir, prefix, paths) {
4934
- try {
4935
- const dirEntries = await readdir(baseDir, { withFileTypes: true });
4936
- for (const entry of dirEntries) {
4937
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
4938
- if (entry.isDirectory()) await this.scanDirectory(join(baseDir, entry.name), relativePath, paths);
4939
- else if (entry.isFile() && entry.name.endsWith(".md")) paths.add(relativePath);
4940
- }
4941
- } catch {}
4942
- }
4943
- /**
4944
- * Sync all docs from config to .tbd/docs/.
4945
- *
4946
- * - Downloads/copies new docs
4947
- * - Updates docs whose source has changed
4948
- * - Removes docs no longer in config
4949
- *
4950
- * @param options - Sync options (dryRun, silent)
4951
- */
4952
- async sync(options = {}) {
4953
- const result = {
4954
- added: [],
4955
- updated: [],
4956
- removed: [],
4957
- errors: [],
4958
- success: true
4959
- };
4960
- const currentPaths = await this.getCurrentState();
4961
- const configPaths = new Set(Object.keys(this.config));
4962
- for (const [destPath, sourceStr] of Object.entries(this.config)) try {
4963
- const source = this.parseSource(sourceStr);
4964
- const content = await this.fetchContent(source);
4965
- const fullPath = join(this.docsDir, destPath);
4966
- let exists = false;
4967
- let existingContent = "";
4968
- try {
4969
- existingContent = await readFile(fullPath, "utf-8");
4970
- exists = true;
4971
- } catch {}
4972
- if (!exists) {
4973
- if (!options.dryRun) {
4974
- await mkdir(dirname(fullPath), { recursive: true });
4975
- await writeFile(fullPath, content);
4976
- }
4977
- result.added.push(destPath);
4978
- } else if (existingContent !== content) {
4979
- if (!options.dryRun) await writeFile(fullPath, content);
4980
- result.updated.push(destPath);
4981
- }
4982
- } catch (err) {
4983
- result.errors.push({
4984
- path: destPath,
4985
- error: err.message
4986
- });
4987
- result.success = false;
4988
- }
4989
- for (const existingPath of currentPaths) if (!configPaths.has(existingPath)) try {
4990
- if (!options.dryRun) await rm(join(this.docsDir, existingPath));
4991
- result.removed.push(existingPath);
4992
- } catch (err) {
4993
- result.errors.push({
4994
- path: existingPath,
4995
- error: `Failed to remove: ${err.message}`
4996
- });
4997
- }
4998
- return result;
4999
- }
5000
- /**
5001
- * Get a status of what would change without actually making changes.
5002
- */
5003
- async status() {
5004
- return this.sync({ dryRun: true });
5005
- }
5006
- };
5007
- /**
5008
- * Get base docs paths (with fallbacks for development).
5009
- * Matches the logic in setup.ts.
5010
- */
5011
- function getDocsBasePath() {
5012
- const __dirname = dirname(fileURLToPath(import.meta.url));
5013
- return [join(__dirname, "docs"), join(__dirname, "..", "..", "docs")];
5014
- }
5015
- /**
5016
- * Generate default doc_cache config by scanning bundled docs.
5017
- *
5018
- * This creates a config entry for each .md file found in the bundled
5019
- * docs directories (shortcuts, guidelines, templates).
5020
- *
5021
- * @returns A doc_cache config mapping destination paths to internal: sources
5022
- */
5023
- async function generateDefaultDocCacheConfig() {
5024
- const config = {};
5025
- const basePaths = getDocsBasePath();
5026
- let docsDir = null;
5027
- for (const path of basePaths) try {
5028
- await access(path);
5029
- docsDir = path;
5030
- break;
5031
- } catch {}
5032
- if (!docsDir) return config;
5033
- for (const { subdir, prefix } of [
5034
- {
5035
- subdir: "shortcuts/system",
5036
- prefix: "shortcuts/system"
5037
- },
5038
- {
5039
- subdir: "shortcuts/standard",
5040
- prefix: "shortcuts/standard"
5041
- },
5042
- {
5043
- subdir: "guidelines",
5044
- prefix: "guidelines"
5045
- },
5046
- {
5047
- subdir: "templates",
5048
- prefix: "templates"
5049
- }
5050
- ]) {
5051
- const fullDir = join(docsDir, subdir);
5052
- try {
5053
- const entries = await readdir(fullDir, { withFileTypes: true });
5054
- for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".md")) {
5055
- const relativePath = `${prefix}/${entry.name}`;
5056
- config[relativePath] = `${INTERNAL_SOURCE_PREFIX}${relativePath}`;
5057
- }
5058
- } catch {}
5059
- }
5060
- return config;
5061
- }
5062
- /**
5063
- * Merge user's doc_cache config with default bundled docs.
5064
- *
5065
- * This ensures:
5066
- * - New bundled docs from tbd updates are added to existing configs
5067
- * - User's custom sources (URLs, etc.) are preserved
5068
- * - User's overrides of bundled docs are respected
5069
- *
5070
- * @param userConfig - The user's existing doc_cache config (may be undefined/empty)
5071
- * @param defaults - The default config from generateDefaultDocCacheConfig()
5072
- * @returns Merged config with defaults as base, user config overlaid
5073
- */
5074
- function mergeDocCacheConfig(userConfig, defaults) {
5075
- return {
5076
- ...defaults,
5077
- ...userConfig
5078
- };
5079
- }
5080
- /**
5081
- * Check if docs are stale based on last sync time and configured hours.
5082
- *
5083
- * @param lastSyncAt - ISO timestamp of last sync (or undefined if never synced)
5084
- * @param autoSyncHours - Hours between auto-syncs (0 = disabled)
5085
- * @returns true if docs should be synced
5086
- */
5087
- function isDocsStale(lastSyncAt, autoSyncHours) {
5088
- if (autoSyncHours <= 0) return false;
5089
- if (!lastSyncAt) return true;
5090
- const lastSync = new Date(lastSyncAt).getTime();
5091
- return (Date.now() - lastSync) / (1e3 * 60 * 60) >= autoSyncHours;
5092
- }
5093
- /**
5094
- * Check if an internal bundled doc exists.
5095
- *
5096
- * @param location - The internal doc path (without 'internal:' prefix)
5097
- * @returns true if the doc exists in any of the bundled doc paths
5098
- */
5099
- async function internalDocExists(location) {
5100
- const basePaths = getDocsBasePath();
5101
- for (const basePath of basePaths) {
5102
- const fullPath = join(basePath, location);
5103
- try {
5104
- await access(fullPath);
5105
- return true;
5106
- } catch {}
5107
- }
5108
- return false;
5109
- }
5110
- /**
5111
- * Prune entries from config that point to non-existent internal sources.
5112
- *
5113
- * This handles the case where a bundled doc is removed in a tbd update -
5114
- * the stale config entry is automatically cleaned up.
5115
- *
5116
- * @param config - The doc_cache config to prune
5117
- * @returns Object with pruned config and list of removed entries
5118
- */
5119
- async function pruneStaleInternals(config) {
5120
- const result = {};
5121
- const pruned = [];
5122
- for (const [dest, source] of Object.entries(config)) {
5123
- if (source.startsWith(INTERNAL_SOURCE_PREFIX)) {
5124
- if (!await internalDocExists(source.slice(9))) {
5125
- pruned.push(dest);
5126
- continue;
5127
- }
5128
- }
5129
- result[dest] = source;
5130
- }
5131
- return {
5132
- config: result,
5133
- pruned
5134
- };
5135
- }
5136
- /**
5137
- * Deep equality check for config objects.
5138
- */
5139
- function configsEqual(a, b) {
5140
- const keysA = Object.keys(a).sort();
5141
- const keysB = Object.keys(b).sort();
5142
- if (keysA.length !== keysB.length) return false;
5143
- for (const key of keysA) if (!Object.hasOwn(b, key) || a[key] !== b[key]) return false;
5144
- return true;
5145
- }
5146
- /**
5147
- * Sync docs with merged defaults and auto-pruning.
5148
- *
5149
- * This is the single entry point for all doc sync operations that need
5150
- * to pick up new bundled docs from tbd upgrades.
5151
- *
5152
- * Steps:
5153
- * 1. Read current config
5154
- * 2. Generate defaults from bundled docs
5155
- * 3. Merge: defaults as base, user config overlays
5156
- * 4. Prune entries with missing internal sources
5157
- * 5. Sync files to .tbd/docs/
5158
- * 6. Write config if changed
5159
- * 7. Update last_doc_sync_at in state
5160
- *
5161
- * @param tbdRoot - The tbd project root directory
5162
- * @param options - Sync options (quiet, dryRun)
5163
- * @returns Sync result with added/updated/removed/pruned counts
5164
- */
5165
- async function syncDocsWithDefaults(tbdRoot, options = {}) {
5166
- const config = await readConfig(tbdRoot);
5167
- const originalFiles = config.docs_cache?.files ?? {};
5168
- const { config: prunedConfig, pruned } = await pruneStaleInternals(mergeDocCacheConfig(originalFiles, await generateDefaultDocCacheConfig()));
5169
- const syncResult = await new DocSync(tbdRoot, prunedConfig).sync({ dryRun: options.dryRun });
5170
- const configChanged = !configsEqual(prunedConfig, originalFiles);
5171
- if (configChanged && !options.dryRun) {
5172
- config.docs_cache = {
5173
- lookup_path: config.docs_cache?.lookup_path ?? [".tbd/docs/shortcuts/system", ".tbd/docs/shortcuts/standard"],
5174
- files: prunedConfig
5175
- };
5176
- await writeConfig(tbdRoot, config);
5177
- }
5178
- if (!options.dryRun) await updateLocalState(tbdRoot, { last_doc_sync_at: (/* @__PURE__ */ new Date()).toISOString() });
5179
- return {
5180
- added: syncResult.added,
5181
- updated: syncResult.updated,
5182
- removed: syncResult.removed,
5183
- pruned,
5184
- configChanged,
5185
- errors: syncResult.errors,
5186
- success: syncResult.success
5187
- };
5188
- }
5189
-
5190
4697
  //#endregion
5191
4698
  //#region src/file/workspace.ts
5192
4699
  /**
@@ -5631,31 +5138,17 @@ var SyncHandler = class extends BaseCommand {
5631
5138
  async syncDocs(statusOnly) {
5632
5139
  if (statusOnly) {
5633
5140
  const result = await syncDocsWithDefaults(this.tbdRoot, { dryRun: true });
5634
- this.showDocStatus(result);
5141
+ printDocSyncStatus(this.output, result);
5635
5142
  return result;
5636
5143
  }
5637
5144
  const spinner = this.output.spinner("Syncing docs...");
5638
5145
  const result = await syncDocsWithDefaults(this.tbdRoot);
5639
5146
  spinner.stop();
5640
- this.showDocSyncResult(result);
5147
+ printDocSyncResult(this.output, result);
5148
+ await printForkDriftNotice(this.output, this.tbdRoot);
5641
5149
  return result;
5642
5150
  }
5643
5151
  /**
5644
- * Show doc sync status (what would change).
5645
- */
5646
- showDocStatus(result) {
5647
- const colors = this.output.getColors();
5648
- if (!(result.added.length > 0 || result.updated.length > 0 || result.removed.length > 0 || result.pruned.length > 0)) {
5649
- this.output.success("Docs up to date");
5650
- return;
5651
- }
5652
- console.log(colors.bold("Docs:"));
5653
- if (result.added.length > 0) console.log(` ${colors.success(`+${result.added.length}`)} new doc(s) available`);
5654
- if (result.updated.length > 0) console.log(` ${colors.warn(`~${result.updated.length}`)} doc(s) to update`);
5655
- if (result.removed.length > 0) console.log(` ${colors.error(`-${result.removed.length}`)} doc(s) to remove`);
5656
- if (result.pruned.length > 0) console.log(` ${colors.dim(`${result.pruned.length}`)} stale config entry/entries`);
5657
- }
5658
- /**
5659
5152
  * Show doc sync result after sync.
5660
5153
  */
5661
5154
  showDocSyncResult(result) {
@@ -5846,7 +5339,7 @@ var SyncHandler = class extends BaseCommand {
5846
5339
  * result, and asserting no bead was left corrupt.
5847
5340
  *
5848
5341
  * Shared by the pull/full-sync path and the push-retry path so both integrate
5849
- * the remote identically and so a rejected push actually advances local
5342
+ * the remote identically, and so a rejected push actually advances local
5850
5343
  * `tbd-sync` to include the remote before retrying, instead of looping on the
5851
5344
  * same non-fast-forward commit. See issue #155.
5852
5345
  *
@@ -6193,61 +5686,11 @@ const syncCommand = new Command("sync").description("Synchronize issues and docs
6193
5686
  });
6194
5687
 
6195
5688
  //#endregion
6196
- //#region src/lib/format-utils.ts
5689
+ //#region src/cli/commands/search.ts
6197
5690
  /**
6198
- * Human-readable formatting utilities for tbd CLI.
5691
+ * `tbd search` - Search issues.
6199
5692
  *
6200
- * Uses sindresorhus libraries for consistent, well-tested formatting:
6201
- * - pretty-bytes: Byte sizes (1337 -> "1.34 kB")
6202
- * - pretty-ms: Durations (3600000 -> "1h")
6203
- */
6204
- /**
6205
- * Estimate token count from text content.
6206
- * Uses CHARS_PER_TOKEN (~3.5) for markdown/code content.
6207
- */
6208
- function estimateTokens(text) {
6209
- return Math.ceil(text.length / CHARS_PER_TOKEN);
6210
- }
6211
- /**
6212
- * Format token count: "~1.2k tok" or "~450 tok"
6213
- */
6214
- function formatTokens(tokens) {
6215
- if (tokens >= 1e3) return `~${(tokens / 1e3).toFixed(1)}k tok`;
6216
- return `~${tokens} tok`;
6217
- }
6218
- /**
6219
- * Format doc size for list display: "(1.8 kB, ~450 tok)"
6220
- */
6221
- function formatDocSize(sizeBytes, approxTokens) {
6222
- return `(${prettyBytes(sizeBytes)}, ${formatTokens(approxTokens)})`;
6223
- }
6224
- /**
6225
- * Format relative time from Date: "2d ago", "3h ago", "5m ago"
6226
- * Uses compact format for concise display.
6227
- */
6228
- function formatTimeAgo(date) {
6229
- const ms = Date.now() - date.getTime();
6230
- if (ms < 0) return "just now";
6231
- if (ms < 6e4) return "just now";
6232
- return `${prettyMs(ms, { compact: true })} ago`;
6233
- }
6234
- /**
6235
- * Format relative time from ISO timestamp string.
6236
- * Returns null if timestamp is invalid.
6237
- */
6238
- function formatTimestampAgo(timestamp) {
6239
- if (!timestamp) return null;
6240
- const date = new Date(timestamp);
6241
- if (isNaN(date.getTime())) return null;
6242
- return formatTimeAgo(date);
6243
- }
6244
-
6245
- //#endregion
6246
- //#region src/cli/commands/search.ts
6247
- /**
6248
- * `tbd search` - Search issues.
6249
- *
6250
- * See: tbd-design.md §4.8 Search Commands
5693
+ * See: tbd-design.md §4.8 Search Commands
6251
5694
  */
6252
5695
  const STALE_THRESHOLD_MS = 300 * 1e3;
6253
5696
  var SearchHandler = class extends BaseCommand {
@@ -6556,8 +5999,8 @@ function renderFooter(suggestions, colors) {
6556
5999
  *
6557
6000
  * UNIFIED with the `.tbd/` directory format (`tbd_format`): there is one format
6558
6001
  * code for all tbd-managed surfaces, sourced from `tbd-format.ts` (the single
6559
- * source of truth). Bump `CURRENT_FORMAT` there when any managed surface config
6560
- * schema OR a generated agent surface changes shape. A marked AGENTS.md block
6002
+ * source of truth). Bump `CURRENT_FORMAT` there when any managed surface (config
6003
+ * schema OR a generated agent surface) changes shape. A marked AGENTS.md block
6561
6004
  * with no `format=` field predates this and is treated as `f01`; a running tbd
6562
6005
  * that finds a HIGHER format than it knows refuses to overwrite it and tells the
6563
6006
  * user to upgrade tbd.
@@ -6728,6 +6171,7 @@ var StatusHandler = class extends BaseCommand {
6728
6171
  worktree_healthy: null,
6729
6172
  worktree_status: null,
6730
6173
  workspaces: [],
6174
+ docs_drift: null,
6731
6175
  integrations: {
6732
6176
  portable_skill: false,
6733
6177
  portable_skill_path: AGENTS_SKILL_DISPLAY,
@@ -6751,7 +6195,12 @@ var StatusHandler = class extends BaseCommand {
6751
6195
  statusData.beads_detected = beadsInfo.detected;
6752
6196
  statusData.beads_issue_count = beadsInfo.issueCount;
6753
6197
  statusData.integrations = await this.checkIntegrations(projectRoot);
6754
- if (statusData.initialized && tbdRoot) await this.loadPostInitInfo(tbdRoot, statusData);
6198
+ if (statusData.initialized && tbdRoot) {
6199
+ await this.loadPostInitInfo(tbdRoot, statusData);
6200
+ try {
6201
+ statusData.docs_drift = await computeForkDriftSummary(tbdRoot, FORK_DIR, await readForkManifest(tbdRoot));
6202
+ } catch {}
6203
+ }
6755
6204
  this.output.data(statusData, () => {
6756
6205
  this.renderText(statusData);
6757
6206
  });
@@ -6896,6 +6345,15 @@ var StatusHandler = class extends BaseCommand {
6896
6345
  console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
6897
6346
  }
6898
6347
  if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
6348
+ if (data.docs_drift && data.docs_drift.forks > 0) {
6349
+ const d = data.docs_drift;
6350
+ const parts = [`${d.customized} customized`];
6351
+ if (d.stale > 0) parts.push(`${d.stale} with upstream updates; run 'tbd docs update'`);
6352
+ if (d.conflicted > 0) parts.push(`${d.conflicted} conflict pending`);
6353
+ if (d.missing > 0) parts.push(`${d.missing} missing; see 'tbd docs status'`);
6354
+ console.log("");
6355
+ console.log(`${colors.bold("Docs:")} ${d.forks} forked (${parts.join(", ")})`);
6356
+ }
6899
6357
  if (data.workspaces.length > 0) {
6900
6358
  console.log("");
6901
6359
  console.log(colors.bold("WORKSPACES"));
@@ -7182,7 +6640,7 @@ function renderDiagnostics(results, colors) {
7182
6640
  *
7183
6641
  * Returns null when the remote branch does not exist (the caller then falls
7184
6642
  * through to local-branch / new-repo handling). Unrelated histories are a hard
7185
- * ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` push
6643
+ * ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync`; push
7186
6644
  * can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
7187
6645
  */
7188
6646
  function classifyRemoteSyncHealth(health, remote, syncBranch) {
@@ -7191,7 +6649,7 @@ function classifyRemoteSyncHealth(health, remote, syncBranch) {
7191
6649
  name: "Remote sync branch",
7192
6650
  status: "error",
7193
6651
  fixable: true,
7194
- message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) push cannot succeed`,
6652
+ message: `${remote}/${syncBranch} histories are unrelated (no common ancestor); push cannot succeed`,
7195
6653
  suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
7196
6654
  };
7197
6655
  if (health.diverged) return {
@@ -7210,7 +6668,7 @@ function classifyRemoteSyncHealth(health, remote, syncBranch) {
7210
6668
  * The "Sync consistency" finding for a branch that is both ahead and behind.
7211
6669
  *
7212
6670
  * When the histories are unrelated, the remediation is the rescue
7213
- * (`tbd doctor --fix`), NOT `tbd sync` otherwise the unrelated-history error
6671
+ * (`tbd doctor --fix`), NOT `tbd sync`; otherwise the unrelated-history error
7214
6672
  * (which says "run doctor --fix") and this warning (which would say "run sync")
7215
6673
  * point at each other in a loop with no terminating instruction. See #158.
7216
6674
  */
@@ -7218,7 +6676,7 @@ function divergenceFinding(ahead, behind, unrelated) {
7218
6676
  if (unrelated) return {
7219
6677
  name: "Sync consistency",
7220
6678
  status: "warn",
7221
- message: `diverged (${ahead} ahead, ${behind} behind) unrelated histories`,
6679
+ message: `diverged (${ahead} ahead, ${behind} behind): unrelated histories`,
7222
6680
  suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
7223
6681
  };
7224
6682
  return {
@@ -7234,7 +6692,7 @@ function divergenceFinding(ahead, behind, unrelated) {
7234
6692
  * `code` is the errno from attempting to create a directory under the shared
7235
6693
  * locks dir, or undefined on success. EPERM/EACCES is a hard error: every write
7236
6694
  * command must acquire this lock, so an unwritable lock path breaks all writes
7237
- * (the #164 Codex-sandbox case) and a lock tbd needs but cannot take is a
6695
+ * (the #164 Codex-sandbox case), and a lock tbd needs but cannot take is a
7238
6696
  * fatal condition, not a soft warning. Any other probe failure is reported as a
7239
6697
  * warning since it cannot be positively interpreted. Never `fixable`: tbd cannot
7240
6698
  * widen a sandbox or change filesystem permissions itself.
@@ -7327,6 +6785,15 @@ var DoctorHandler = class extends BaseCommand {
7327
6785
  healthChecks.push(await this.safeCheck("Sync status", () => this.checkLocalVsRemoteData()));
7328
6786
  healthChecks.push(await this.safeCheck("Clone status", () => this.checkCloneScenarios()));
7329
6787
  healthChecks.push(await this.safeCheck("Sync consistency", () => this.checkSyncConsistency()));
6788
+ try {
6789
+ healthChecks.push(...await this.checkForkedDocs(options.fix));
6790
+ } catch (error) {
6791
+ healthChecks.push({
6792
+ name: "Forked docs",
6793
+ status: "error",
6794
+ message: `check could not complete: ${error instanceof Error ? error.message : String(error)}`
6795
+ });
6796
+ }
7330
6797
  const integrationChecks = [];
7331
6798
  integrationChecks.push(await this.safeCheck("Portable Agent Skill", () => this.checkPortableSkill()));
7332
6799
  integrationChecks.push(await this.safeCheck("Claude Code skill", () => this.checkClaudeSkill()));
@@ -7558,7 +7025,7 @@ var DoctorHandler = class extends BaseCommand {
7558
7025
  status: "ok"
7559
7026
  };
7560
7027
  if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
7561
- const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
7028
+ const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-D0iitY-F.mjs");
7562
7029
  const resolved = resolveIdMappingConflicts(content);
7563
7030
  await saveIdMapping(this.dataSyncDir, resolved);
7564
7031
  return {
@@ -7607,7 +7074,7 @@ var DoctorHandler = class extends BaseCommand {
7607
7074
  status: "ok"
7608
7075
  };
7609
7076
  if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
7610
- const { loadIdMapping, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
7077
+ const { loadIdMapping, saveIdMapping } = await import("./id-mapping-D0iitY-F.mjs");
7611
7078
  const mapping = await loadIdMapping(this.dataSyncDir);
7612
7079
  await saveIdMapping(this.dataSyncDir, mapping);
7613
7080
  return {
@@ -7746,7 +7213,7 @@ var DoctorHandler = class extends BaseCommand {
7746
7213
  name: "ID mapping coverage",
7747
7214
  status: "ok"
7748
7215
  };
7749
- const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
7216
+ const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-D0iitY-F.mjs");
7750
7217
  const mapping = await loadIdMapping(this.dataSyncDir);
7751
7218
  const missingIds = [];
7752
7219
  for (const issue of this.issues) {
@@ -7758,10 +7225,10 @@ var DoctorHandler = class extends BaseCommand {
7758
7225
  status: "ok"
7759
7226
  };
7760
7227
  if (fix && !this.checkDryRun("Create missing ID mappings")) {
7761
- const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
7228
+ const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-D0iitY-F.mjs");
7762
7229
  let historicalMapping;
7763
7230
  try {
7764
- const syncBranch = (await import("./config-YRRW9l89.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
7231
+ const syncBranch = (await import("./config-DXhifxOw.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
7765
7232
  const logArgs = ["log", "--format=%H"];
7766
7233
  if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
7767
7234
  logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
@@ -7999,11 +7466,23 @@ var DoctorHandler = class extends BaseCommand {
7999
7466
  try {
8000
7467
  layout = await readCommonDirLayout(layoutPath);
8001
7468
  } catch (error) {
7469
+ if (fix && !this.checkDryRun("Rewrite corrupt common-dir layout from config")) {
7470
+ const configRef = this.config;
7471
+ await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef));
7472
+ return {
7473
+ name: "Common-dir layout",
7474
+ status: "ok",
7475
+ message: "rewritten from config (was unreadable)",
7476
+ path: layoutPath
7477
+ };
7478
+ }
8002
7479
  return {
8003
7480
  name: "Common-dir layout",
8004
7481
  status: "error",
8005
- message: error instanceof Error ? error.message : String(error),
8006
- path: layoutPath
7482
+ message: `${error instanceof Error ? error.message : String(error)}`,
7483
+ path: layoutPath,
7484
+ fixable: true,
7485
+ suggestion: `Run: tbd doctor --fix (rewrites it from config), or delete ${layoutPath}`
8007
7486
  };
8008
7487
  }
8009
7488
  if (!layout) return {
@@ -8019,6 +7498,25 @@ var DoctorHandler = class extends BaseCommand {
8019
7498
  path: layoutPath,
8020
7499
  suggestion: "Upgrade: npm install -g get-tbd@latest"
8021
7500
  };
7501
+ if (isLayoutUpgradeable(layout, this.config)) {
7502
+ if (fix && !this.checkDryRun("Apply pending format migration")) {
7503
+ await prepareDataSyncContext(this.cwd);
7504
+ return {
7505
+ name: "Common-dir layout",
7506
+ status: "ok",
7507
+ message: `format migration applied (${layout.tbd_format} → ${this.config.tbd_format})`,
7508
+ path: layoutPath
7509
+ };
7510
+ }
7511
+ return {
7512
+ name: "Common-dir layout",
7513
+ status: "warn",
7514
+ message: `format migration pending (${layout.tbd_format} → ${this.config.tbd_format}); applies on next write or 'tbd doctor --fix'`,
7515
+ path: layoutPath,
7516
+ fixable: true,
7517
+ suggestion: "Run: tbd doctor --fix (or any write command) to apply"
7518
+ };
7519
+ }
8022
7520
  try {
8023
7521
  validateCommonDirLayout(layout, this.config);
8024
7522
  return {
@@ -8355,12 +7853,12 @@ var DoctorHandler = class extends BaseCommand {
8355
7853
  if (consistency.localAhead > 0) return {
8356
7854
  name: "Sync consistency",
8357
7855
  status: "ok",
8358
- message: `${consistency.localAhead} local commit(s) not yet pushed run \`tbd sync\` to push`
7856
+ message: `${consistency.localAhead} local commit(s) not yet pushed; run \`tbd sync\` to push`
8359
7857
  };
8360
7858
  if (consistency.localBehind > 0) return {
8361
7859
  name: "Sync consistency",
8362
7860
  status: "ok",
8363
- message: `${consistency.localBehind} remote commit(s) not yet pulled run \`tbd sync\` to pull`
7861
+ message: `${consistency.localBehind} remote commit(s) not yet pulled; run \`tbd sync\` to pull`
8364
7862
  };
8365
7863
  return {
8366
7864
  name: "Sync consistency",
@@ -8380,6 +7878,198 @@ var DoctorHandler = class extends BaseCommand {
8380
7878
  };
8381
7879
  }
8382
7880
  }
7881
+ /**
7882
+ * Check 16 group: Forked docs (`.tbd/doc-forks/` ↔ base snapshots ↔ fork dir).
7883
+ *
7884
+ * Validates the forkable-docs state per the f05 spec (Phase 2 doctor checks):
7885
+ * manifest readability, missing forked files (--fix finalizes the unfork),
7886
+ * orphaned entries whose upstream doc is gone (--fix removes the entry),
7887
+ * base snapshot presence/hash integrity (no auto-fix; re-fork vs unfork is
7888
+ * the user's call), unresolved tbd conflict markers, user docs claiming the
7889
+ * reserved `tbd-` name prefix, and a gitignored fork dir.
7890
+ *
7891
+ * Returns zero findings when nothing is forked and no fork dir exists, so
7892
+ * doctor output is byte-identical for repos that never touched forking.
7893
+ * See: docs/project/specs/done/plan-2026-06-11-forkable-docs.md §`tbd doctor`.
7894
+ */
7895
+ async checkForkedDocs(fix) {
7896
+ const name = "Forked docs";
7897
+ const { DOC_FORKS_DIR, FORKS_FILE, findFork, hashContent, hasUnresolvedConflict, readForkManifest, removeBaseContent, removeFork, withForkManifestLock, writeForkManifest } = await import("./fork-manifest-C7lGRq-6.mjs");
7898
+ const manifestPath = `${DOC_FORKS_DIR}/${FORKS_FILE}`;
7899
+ let manifest;
7900
+ try {
7901
+ manifest = await readForkManifest(this.cwd);
7902
+ } catch (error) {
7903
+ return [{
7904
+ name,
7905
+ status: "warn",
7906
+ message: `fork manifest unreadable: ${(error instanceof Error ? error.message : String(error)).split("\n")[0]}`,
7907
+ path: manifestPath,
7908
+ suggestion: `Fix or delete ${manifestPath} (forked files stay in place), then re-run tbd doctor`
7909
+ }];
7910
+ }
7911
+ let forkDirExists = true;
7912
+ try {
7913
+ await access(join(this.cwd, FORK_DIR));
7914
+ } catch {
7915
+ forkDirExists = false;
7916
+ }
7917
+ if (manifest.forks.length === 0 && !forkDirExists) return [];
7918
+ const { forkStatusFor, listLocalForkFiles, readForkBase, readForkFile, regenerateForkDirReadme, unforkDoc } = await import("./doc-fork-BMjqzfAs.mjs");
7919
+ const { DocCache } = await import("./doc-cache-CiBwpDTf.mjs");
7920
+ const kindCachePaths = {
7921
+ guideline: CACHE_GUIDELINES_PATHS,
7922
+ shortcut: CACHE_SHORTCUT_PATHS,
7923
+ template: CACHE_TEMPLATE_PATHS,
7924
+ reference: CACHE_REFERENCE_PATHS
7925
+ };
7926
+ const caches = /* @__PURE__ */ new Map();
7927
+ const missing = [];
7928
+ const orphaned = [];
7929
+ const baseProblems = [];
7930
+ const conflicted = [];
7931
+ for (const entry of manifest.forks) {
7932
+ let cache = caches.get(entry.kind);
7933
+ if (!cache) {
7934
+ cache = new DocCache(kindCachePaths[entry.kind] ?? [], this.cwd);
7935
+ await cache.load({ quiet: true });
7936
+ caches.set(entry.kind, cache);
7937
+ }
7938
+ const cacheContent = cache.get(entry.name)?.doc.content ?? null;
7939
+ const status = await forkStatusFor(this.cwd, FORK_DIR, entry, cacheContent);
7940
+ if (status.state === "missing") {
7941
+ missing.push(entry);
7942
+ continue;
7943
+ }
7944
+ if (status.orphaned) {
7945
+ orphaned.push(entry);
7946
+ continue;
7947
+ }
7948
+ const base = await readForkBase(this.cwd, entry);
7949
+ if (base === null) baseProblems.push(`${entry.name}: missing`);
7950
+ else if (hashContent(base) !== entry.base_hash) baseProblems.push(`${entry.name}: hash mismatch`);
7951
+ const content = await readForkFile(this.cwd, FORK_DIR, entry);
7952
+ if (content !== null && hasUnresolvedConflict(content)) conflicted.push(entry.name);
7953
+ }
7954
+ const results = [];
7955
+ if (missing.length > 0) {
7956
+ const message = `${missing.length} missing (${missing.map((e) => e.name).join(", ")}: forked file deleted)`;
7957
+ if (fix && !this.checkDryRun("Finalize unfork of deleted forked docs")) try {
7958
+ await withForkManifestLock(this.cwd, async () => {
7959
+ let current = await readForkManifest(this.cwd);
7960
+ for (const entry of missing) {
7961
+ if (!findFork(current, entry.name, entry.kind)) continue;
7962
+ current = (await unforkDoc({
7963
+ tbdRoot: this.cwd,
7964
+ forkDir: FORK_DIR,
7965
+ manifest: current,
7966
+ name: entry.name,
7967
+ kind: entry.kind
7968
+ })).manifest;
7969
+ }
7970
+ await writeForkManifest(this.cwd, current);
7971
+ await regenerateForkDirReadme(this.cwd, FORK_DIR, current);
7972
+ });
7973
+ results.push({
7974
+ name,
7975
+ status: "warn",
7976
+ message,
7977
+ details: ["Fixed: finalized unfork (removed manifest entry + base); now served from upstream"]
7978
+ });
7979
+ } catch (error) {
7980
+ results.push({
7981
+ name,
7982
+ status: "error",
7983
+ message: `failed to finalize unfork: ${error instanceof Error ? error.message : String(error)}`
7984
+ });
7985
+ }
7986
+ else results.push({
7987
+ name,
7988
+ status: "warn",
7989
+ message,
7990
+ fixable: true,
7991
+ suggestion: "Run: tbd doctor --fix to finalize the unfork, or tbd docs fork <name> --force to restore"
7992
+ });
7993
+ }
7994
+ if (orphaned.length > 0) {
7995
+ const message = `${orphaned.length} orphaned (${orphaned.map((e) => e.name).join(", ")}: upstream doc no longer exists)`;
7996
+ if (fix && !this.checkDryRun("Remove orphaned fork manifest entries")) try {
7997
+ await withForkManifestLock(this.cwd, async () => {
7998
+ let current = await readForkManifest(this.cwd);
7999
+ for (const entry of orphaned) {
8000
+ current = removeFork(current, entry.name, entry.kind);
8001
+ await removeBaseContent(this.cwd, entry.kind, entry.name);
8002
+ }
8003
+ await writeForkManifest(this.cwd, current);
8004
+ await regenerateForkDirReadme(this.cwd, FORK_DIR, current);
8005
+ });
8006
+ results.push({
8007
+ name,
8008
+ status: "warn",
8009
+ message,
8010
+ details: [`Fixed: removed orphaned manifest entr${orphaned.length === 1 ? "y" : "ies"} + base; file kept as a local doc`]
8011
+ });
8012
+ } catch (error) {
8013
+ results.push({
8014
+ name,
8015
+ status: "error",
8016
+ message: `failed to remove orphaned entries: ${error instanceof Error ? error.message : String(error)}`
8017
+ });
8018
+ }
8019
+ else results.push({
8020
+ name,
8021
+ status: "warn",
8022
+ message,
8023
+ fixable: true,
8024
+ suggestion: "Run: tbd doctor --fix to remove the entry (your file is kept as a local doc)"
8025
+ });
8026
+ }
8027
+ if (baseProblems.length > 0) results.push({
8028
+ name,
8029
+ status: "warn",
8030
+ message: `${baseProblems.length} base snapshot problem${baseProblems.length === 1 ? "" : "s"} (${baseProblems.join(", ")})`,
8031
+ suggestion: "Run: tbd docs fork <name> --force to re-fork, or tbd docs unfork <name>"
8032
+ });
8033
+ if (conflicted.length > 0) results.push({
8034
+ name,
8035
+ status: "warn",
8036
+ message: `${conflicted.length} unresolved merge conflict${conflicted.length === 1 ? "" : "s"} (${conflicted.join(", ")})`,
8037
+ suggestion: "Run: resolve the conflict markers, then re-run tbd docs update"
8038
+ });
8039
+ if (manifest.forks.length > 0 && results.length === 0) results.push({
8040
+ name,
8041
+ status: "ok",
8042
+ message: `${manifest.forks.length} forked, base snapshots intact`
8043
+ });
8044
+ const reserved = (await listLocalForkFiles(this.cwd, FORK_DIR, manifest)).filter((l) => l.name.startsWith("tbd-"));
8045
+ if (reserved.length > 0) results.push({
8046
+ name: "Reserved tbd- names",
8047
+ status: "warn",
8048
+ message: `${reserved.length} user doc${reserved.length === 1 ? " claims" : "s claim"} the reserved tbd- prefix`,
8049
+ details: reserved.map((l) => l.relPath),
8050
+ suggestion: "Rename the file(s): the tbd- prefix is reserved for tbd self-docs"
8051
+ });
8052
+ if (manifest.forks.length > 0) {
8053
+ let ignored = false;
8054
+ try {
8055
+ await git("-C", this.cwd, "check-ignore", "-q", FORK_DIR);
8056
+ ignored = true;
8057
+ } catch {
8058
+ ignored = false;
8059
+ }
8060
+ results.push(ignored ? {
8061
+ name: "Fork dir",
8062
+ status: "warn",
8063
+ message: `${FORK_DIR}/ is gitignored; forked docs will not be committed`,
8064
+ suggestion: `Remove the .gitignore rule covering ${FORK_DIR}/ so forks are tracked in git`
8065
+ } : {
8066
+ name: "Fork dir",
8067
+ status: "ok",
8068
+ message: `${FORK_DIR}/ tracked in git (not gitignored)`
8069
+ });
8070
+ }
8071
+ return results;
8072
+ }
8383
8073
  };
8384
8074
  const doctorCommand = new Command("doctor").description("Diagnose and repair repository").option("--fix", "Attempt to fix issues").option("--max-history <n>", "Max git commits to scan for ID mapping recovery (0 = full history)", "50").action(async (options, command) => {
8385
8075
  await new DoctorHandler(command).run(options);
@@ -8404,6 +8094,13 @@ var ConfigShowHandler = class extends BaseCommand {
8404
8094
  this.output.data(config, () => {
8405
8095
  const colors = this.output.getColors();
8406
8096
  console.log(`${colors.dim("tbd_version:")} ${config.tbd_version}`);
8097
+ if (config.tbd_upgrades && config.tbd_upgrades.length > 0) {
8098
+ console.log(`${colors.dim("tbd_upgrades:")}`);
8099
+ for (const entry of config.tbd_upgrades) {
8100
+ const when = entry.at ? ` ${colors.dim(`(${entry.at})`)}` : "";
8101
+ console.log(` ${colors.dim("-")} ${entry.version}${when}`);
8102
+ }
8103
+ }
8407
8104
  console.log(`${colors.dim("sync:")}`);
8408
8105
  console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
8409
8106
  console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
@@ -9079,157 +8776,1355 @@ const importCommand = new Command("import").description("Import issues from JSON
9079
8776
  });
9080
8777
 
9081
8778
  //#endregion
9082
- //#region src/cli/commands/docs.ts
8779
+ //#region src/file/fork-update.ts
9083
8780
  /**
9084
- * `tbd docs` - Display CLI documentation.
9085
- *
9086
- * Shows the bundled documentation for tbd CLI.
9087
- * Documentation can be filtered by section.
8781
+ * Updating forked docs after upstream moves: three-way merge plus the per-state
8782
+ * decision logic from the spec's update table.
9088
8783
  *
9089
- * Note: Doc cache sync functionality has moved to `tbd sync --docs`.
9090
- * See: docs/project/specs/active/plan-2026-01-29-unified-sync-command.md
8784
+ * The merge itself is outsourced to git (`git merge-file`), which works on plain
8785
+ * files, reports the conflict count via exit code, and uses standard conflict
8786
+ * markers. Nothing in the git repo state is touched.
9091
8787
  */
8788
+ const MERGE_MAX_BUFFER = 16 * 1024 * 1024;
9092
8789
  /**
9093
- * Get the path to the bundled docs file.
9094
- * The docs file is copied to dist/docs/ during build.
8790
+ * Three-way merge of `current` and `other` against their common `base`, via
8791
+ * `git merge-file -p`. Returns the merged text (with standard conflict markers
8792
+ * when conflicts arise) and the conflict count. Pure with respect to repo state:
8793
+ * it only uses temporary files.
9095
8794
  */
9096
- function getDocsPath() {
9097
- return join(dirname(fileURLToPath(import.meta.url)), "docs", "tbd-docs.md");
9098
- }
9099
- var DocsHandler = class extends BaseCommand {
9100
- async run(topic, options) {
9101
- let content;
9102
- try {
9103
- content = await readFile(getDocsPath(), "utf-8");
9104
- } catch {
9105
- try {
9106
- content = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "docs", "tbd-docs.md"), "utf-8");
9107
- } catch {
9108
- throw new CLIError("Documentation file not found. Please rebuild the CLI.");
9109
- }
9110
- }
9111
- const sections = this.extractSections(content);
9112
- if (options.all) {
9113
- await this.showComprehensiveListing();
9114
- return;
9115
- }
9116
- if (options.list) {
9117
- this.output.data(sections, () => {
9118
- const colors = this.output.getColors();
9119
- console.log(colors.bold("Available documentation sections:"));
9120
- console.log("");
9121
- const maxSlugLen = Math.max(...sections.map((s) => s.slug.length));
9122
- for (const section of sections) {
9123
- const paddedSlug = section.slug.padEnd(maxSlugLen);
9124
- console.log(` ${colors.id(paddedSlug)} ${section.title}`);
8795
+ async function mergeContents(current, base, other, labels = {}) {
8796
+ const dir = await mkdtemp(join(tmpdir(), "tbd-merge-"));
8797
+ const currentPath = join(dir, "current");
8798
+ const basePath = join(dir, "base");
8799
+ const otherPath = join(dir, "other");
8800
+ try {
8801
+ await Promise.all([
8802
+ writeFile(currentPath, normalizeLineEndings(current)),
8803
+ writeFile(basePath, normalizeLineEndings(base)),
8804
+ writeFile(otherPath, normalizeLineEndings(other))
8805
+ ]);
8806
+ const args = [
8807
+ "merge-file",
8808
+ "-p",
8809
+ "-L",
8810
+ labels.current ?? CONFLICT_LABELS.ours,
8811
+ "-L",
8812
+ labels.base ?? CONFLICT_LABELS.base,
8813
+ "-L",
8814
+ labels.other ?? CONFLICT_LABELS.theirs,
8815
+ currentPath,
8816
+ basePath,
8817
+ otherPath
8818
+ ];
8819
+ return await new Promise((resolve, reject) => {
8820
+ execFile("git", args, {
8821
+ maxBuffer: MERGE_MAX_BUFFER,
8822
+ env: gitSafeEnv()
8823
+ }, (error, stdout, stderr) => {
8824
+ if (error) {
8825
+ const code = error.code;
8826
+ if (typeof code === "number" && code > 0 && code <= 127) {
8827
+ resolve({
8828
+ merged: stdout,
8829
+ conflicts: code
8830
+ });
8831
+ return;
8832
+ }
8833
+ const detail = String(stderr ?? "").trim();
8834
+ reject(new Error(`git merge-file failed${detail ? `: ${detail}` : ""}`, { cause: error }));
8835
+ return;
9125
8836
  }
9126
- console.log("");
9127
- console.log(`Use ${colors.dim("tbd docs <topic>")} to view a specific section.`);
8837
+ resolve({
8838
+ merged: stdout,
8839
+ conflicts: 0
8840
+ });
9128
8841
  });
9129
- return;
9130
- }
9131
- const sectionQuery = topic ?? options.section;
9132
- if (sectionQuery) {
9133
- const sectionContent = this.extractSection(content, sections, sectionQuery);
9134
- if (!sectionContent) throw new NotFoundError("Section", `"${sectionQuery}" (use --list to see available sections)`);
9135
- content = sectionContent;
9136
- }
9137
- if (shouldUseInteractiveOutput(this.ctx)) await paginateOutput(renderMarkdown(content, this.ctx.color), true);
9138
- else console.log(content);
8842
+ });
8843
+ } finally {
8844
+ await rm(dir, {
8845
+ recursive: true,
8846
+ force: true
8847
+ });
9139
8848
  }
9140
- /**
9141
- * Extract section metadata from the documentation.
9142
- * Sections are top-level headers (## ).
9143
- * Returns title and slugified ID for each section.
9144
- */
9145
- extractSections(content) {
9146
- const sections = [];
9147
- const lines = content.split("\n");
9148
- const slugger = new GithubSlugger();
9149
- for (const line of lines) if (line.startsWith("## ")) {
9150
- const title = line.slice(3).trim();
9151
- const slug = slugger.slug(title);
9152
- sections.push({
9153
- title,
9154
- slug
8849
+ }
8850
+ /**
8851
+ * Unified diff of two contents via `git diff --no-index`. Returns the diff text
8852
+ * (empty string when identical). Uses temporary files and touches no repo state.
8853
+ */
8854
+ async function diffContents(left, right, labels = {}) {
8855
+ const dir = await mkdtemp(join(tmpdir(), "tbd-diff-"));
8856
+ const leftName = labels.left ?? "a";
8857
+ const rightName = labels.right ?? "b";
8858
+ try {
8859
+ await Promise.all([writeFile(join(dir, leftName), left), writeFile(join(dir, rightName), right)]);
8860
+ return await new Promise((resolve, reject) => {
8861
+ execFile("git", [
8862
+ "diff",
8863
+ "--no-index",
8864
+ "--no-color",
8865
+ "--no-prefix",
8866
+ leftName,
8867
+ rightName
8868
+ ], {
8869
+ cwd: dir,
8870
+ maxBuffer: MERGE_MAX_BUFFER,
8871
+ env: gitSafeEnv()
8872
+ }, (error, stdout) => {
8873
+ if (error) {
8874
+ if (error.code === 1) {
8875
+ resolve(stdout);
8876
+ return;
8877
+ }
8878
+ reject(error instanceof Error ? error : /* @__PURE__ */ new Error("git diff failed"));
8879
+ return;
8880
+ }
8881
+ resolve(stdout);
9155
8882
  });
9156
- }
9157
- return sections;
8883
+ });
8884
+ } finally {
8885
+ await rm(dir, {
8886
+ recursive: true,
8887
+ force: true
8888
+ });
9158
8889
  }
9159
- /**
9160
- * Extract a specific section from the documentation.
9161
- * Matches by slug or partial title match.
9162
- * Returns content from the section header to the next section header.
9163
- */
9164
- extractSection(content, sections, query) {
9165
- const lowerQuery = query.toLowerCase();
9166
- const matchedSection = sections.find((s) => s.slug === lowerQuery) ?? sections.find((s) => s.title.toLowerCase().includes(lowerQuery));
9167
- if (!matchedSection) return null;
9168
- const lines = content.split("\n");
9169
- let inSection = false;
9170
- const sectionLines = [];
9171
- for (const line of lines) if (line.startsWith("## ")) {
9172
- if (inSection) break;
9173
- if (line.slice(3).trim() === matchedSection.title) {
9174
- inSection = true;
9175
- sectionLines.push(line);
9176
- }
9177
- } else if (inSection) sectionLines.push(line);
9178
- if (sectionLines.length === 0) return null;
9179
- while (sectionLines.length > 0) if (sectionLines[sectionLines.length - 1]?.trim() === "") sectionLines.pop();
9180
- else break;
9181
- return sectionLines.join("\n");
8890
+ }
8891
+ /**
8892
+ * Decide and (when needed) perform the three-way merge for a single forked doc,
8893
+ * implementing the spec's update decision table across the default / --merge /
8894
+ * --keep-ours strategies. Pure aside from the git merge-file call.
8895
+ */
8896
+ async function updateOne(input) {
8897
+ const { entry, forkContent, baseContent, upstreamContent, strategy } = input;
8898
+ const name = entry.name;
8899
+ if (forkContent === null) return {
8900
+ action: "skip-missing",
8901
+ message: `${name}: forked file is missing (doctor's job)`
8902
+ };
8903
+ if (upstreamContent === null) return {
8904
+ action: "skip-orphaned",
8905
+ message: `${name}: upstream removed this doc; keep your copy or 'tbd docs unfork ${name}'`
8906
+ };
8907
+ if (entry.conflicted && hasUnresolvedConflict(forkContent)) return {
8908
+ action: "skip-unresolved",
8909
+ message: `${name}: unresolved conflict markers; resolve them first`
8910
+ };
8911
+ if (input.runningVersion !== void 0 && entry.tbd_version !== void 0 && compareVersionsLoose(input.runningVersion, entry.tbd_version) === -1) return {
8912
+ action: "skip-newer-base",
8913
+ message: `${name}: fork point was set by tbd ${entry.tbd_version} (you have ${input.runningVersion}); upgrade tbd before updating this doc`
8914
+ };
8915
+ if (baseContent === null) {
8916
+ if (strategy === "keep-ours") return {
8917
+ action: "repaired",
8918
+ newBaseContent: upstreamContent,
8919
+ message: `${name}: re-established missing base from upstream (file kept)`
8920
+ };
8921
+ return {
8922
+ action: "skip-no-base",
8923
+ needsDecision: true,
8924
+ message: `${name}: base snapshot missing: cannot merge; re-run with --keep-ours to repair`
8925
+ };
9182
8926
  }
9183
- /**
9184
- * Show a comprehensive listing of all documentation resources organized by purpose.
9185
- */
9186
- async showComprehensiveListing() {
9187
- const colors = this.output.getColors();
9188
- console.log(colors.bold("=== tbd Documentation Resources ==="));
9189
- console.log("");
9190
- console.log(colors.bold("Getting Started:"));
9191
- console.log(" tbd Full orientation and project status");
9192
- console.log(" tbd prime Workflow context and guidance");
9193
- console.log(" tbd prime --brief Quick reference (~35 lines)");
9194
- console.log(" tbd --help CLI command reference");
9195
- console.log("");
9196
- console.log(colors.bold("Workflows (Shortcuts):"));
9197
- console.log(" tbd shortcut --list List all available shortcuts");
9198
- console.log(" tbd shortcut new-plan-spec Plan a new feature");
9199
- console.log(" tbd shortcut code-review-and-commit Commit code properly");
9200
- console.log(" tbd shortcut create-or-update-pr-simple Create a pull request");
9201
- console.log("");
9202
- console.log(colors.bold("Guidelines (Coding Standards):"));
9203
- console.log(" tbd guidelines --list List all available guidelines");
9204
- console.log(" tbd guidelines typescript-rules TypeScript best practices");
9205
- console.log(" tbd guidelines general-tdd-guidelines Test-driven development");
9206
- console.log(" tbd guidelines golden-testing-guidelines Snapshot/golden testing");
9207
- console.log("");
9208
- console.log(colors.bold("Templates:"));
9209
- console.log(" tbd template --list List all available templates");
9210
- console.log(" tbd template plan-spec Feature planning template");
9211
- console.log(" tbd template architecture-doc Architecture document template");
9212
- console.log("");
9213
- console.log(colors.bold("Design & Reference:"));
9214
- console.log(" tbd docs --list List documentation sections");
9215
- console.log(" tbd design tbd design document");
9216
- console.log(" tbd closing Session closing protocol");
9217
- console.log("");
9218
- console.log(colors.bold("Quick Tips:"));
9219
- console.log(" - Run tbd ready to see what issues are available to work on");
9220
- console.log(" - Run tbd shortcut <name> to get step-by-step instructions");
9221
- console.log(" - Run tbd guidelines <name> to get coding standards");
9222
- console.log(" - Always run tbd sync at the end of a session");
8927
+ const customized = hashContent(forkContent) !== hashContent(baseContent);
8928
+ if (!(hashContent(upstreamContent) !== hashContent(baseContent))) return {
8929
+ action: "skip-not-stale",
8930
+ message: `${name}: already up to date`
8931
+ };
8932
+ if (!customized) {
8933
+ if (strategy === "keep-ours") return {
8934
+ action: "kept",
8935
+ newBaseContent: upstreamContent,
8936
+ message: `${name}: kept your version; fork point advanced`
8937
+ };
8938
+ return {
8939
+ action: "replaced",
8940
+ newFileContent: upstreamContent,
8941
+ newBaseContent: upstreamContent,
8942
+ message: `${name}: refreshed to upstream (was unmodified)`
8943
+ };
9223
8944
  }
9224
- };
9225
- const docsCommand = new Command("docs").description("Display CLI documentation (use tbd sync --docs for doc cache sync)").argument("[topic]", "Topic to display (e.g., \"commands\", \"id-system\")").option("--section <name>", "Show specific section (e.g., \"commands\", \"workflows\")").option("--list", "List available sections").option("--all", "Show comprehensive listing of all documentation resources").action(async (topic, options, command) => {
9226
- await new DocsHandler(command).run(topic, options);
9227
- });
8945
+ if (strategy === "keep-ours") return {
8946
+ action: "kept",
8947
+ newBaseContent: upstreamContent,
8948
+ message: `${name}: kept your version; fork point advanced`
8949
+ };
8950
+ const merge = await mergeContents(forkContent, baseContent, upstreamContent);
8951
+ if (merge.conflicts === 0) return {
8952
+ action: "merged-clean",
8953
+ newFileContent: merge.merged,
8954
+ newBaseContent: upstreamContent,
8955
+ message: `${name}: merged upstream cleanly (review with: git diff)`
8956
+ };
8957
+ if (strategy === "merge") return {
8958
+ action: "merged-conflict",
8959
+ newFileContent: merge.merged,
8960
+ newBaseContent: upstreamContent,
8961
+ setConflicted: true,
8962
+ message: `${name}: wrote merged content with conflict markers; resolve them, then it returns to 'customized'`
8963
+ };
8964
+ return {
8965
+ action: "skip-conflict-listed",
8966
+ needsDecision: true,
8967
+ message: `${name}: your changes conflict with upstream`
8968
+ };
8969
+ }
9228
8970
 
9229
8971
  //#endregion
9230
- //#region src/cli/commands/closing.ts
9231
- /**
9232
- * `tbd closing` - Display the session closing protocol reminder.
8972
+ //#region src/docmap/docmap.ts
8973
+ /**
8974
+ * docmap: a minimal, machine-readable inventory of a collection of documents.
8975
+ *
8976
+ * A docmap is a "sitemap for docs": one entry per document, each with an identity
8977
+ * (`type` + `name`, unique within the map), a location (`path` and/or a provenance
8978
+ * `source` docref; at least one is required), and presentation metadata (`title`,
8979
+ * `description`). It describes a collection; it says nothing about how the
8980
+ * collection is assembled, fetched, or kept fresh; a docmap is a generated VIEW
8981
+ * of a collection, never an input to resolution.
8982
+ *
8983
+ * Path convention: for a docmap committed as a file, `path` is relative to the
8984
+ * docmap file's own directory (the sitemap convention); generated docmaps state
8985
+ * their collection root out of band.
8986
+ *
8987
+ * This is the docmap/0.1 format. The module is standalone and dependency-free (no
8988
+ * tbd-internal imports) so it can move to its own package later. Consumers MUST
8989
+ * ignore unknown fields, so producers (such as tbd) may attach extension fields:
8990
+ * for example tbd's `state`/`stale`, or size metrics like `word_count` /
8991
+ * `size_bytes`, without breaking other readers; core fields stay minimal.
8992
+ */
8993
+ /** Current docmap format version tag. */
8994
+ const DOCMAP_VERSION = "docmap/0.1";
8995
+ /**
8996
+ * One document in a docmap. Unknown fields are preserved (extension fields).
8997
+ * Every entry must carry a location: `path` and/or `source`; an inventory whose
8998
+ * entries cannot be located is not an inventory.
8999
+ */
9000
+ const DocMapEntrySchema = z.object({
9001
+ name: z.string().min(1),
9002
+ type: z.string().min(1),
9003
+ path: z.string().optional(),
9004
+ source: z.string().optional(),
9005
+ title: z.string().optional(),
9006
+ description: z.string().optional()
9007
+ }).passthrough().refine((entry) => entry.path !== void 0 || entry.source !== void 0, { message: "docmap entry must have a location: path and/or source" });
9008
+ /**
9009
+ * A document inventory. `documents` entries are unique by (`type`, `name`).
9010
+ */
9011
+ const DocMapSchema = z.object({
9012
+ docmap: z.string(),
9013
+ name: z.string().optional(),
9014
+ documents: z.array(DocMapEntrySchema)
9015
+ }).passthrough();
9016
+ /** Error thrown when an object is not a valid docmap. */
9017
+ var DocMapError = class extends Error {
9018
+ constructor(detail) {
9019
+ super(`Invalid docmap: ${detail}`);
9020
+ this.name = "DocMapError";
9021
+ }
9022
+ };
9023
+ /** Stable key for an entry's identity. */
9024
+ function entryKey(entry) {
9025
+ return `${entry.type}:${entry.name}`;
9026
+ }
9027
+ /** Assert that entries are unique by (type, name); throws {@link DocMapError}. */
9028
+ function assertUniqueIdentities(documents) {
9029
+ const seen = /* @__PURE__ */ new Set();
9030
+ for (const entry of documents) {
9031
+ const key = entryKey(entry);
9032
+ if (seen.has(key)) throw new DocMapError(`duplicate entry identity ${JSON.stringify(key)}`);
9033
+ seen.add(key);
9034
+ }
9035
+ }
9036
+ /**
9037
+ * Build a docmap from entries. Validates each entry and identity uniqueness.
9038
+ */
9039
+ function createDocMap(documents, options = {}) {
9040
+ const parsed = documents.map((d) => DocMapEntrySchema.parse(d));
9041
+ assertUniqueIdentities(parsed);
9042
+ const map = {
9043
+ docmap: DOCMAP_VERSION,
9044
+ documents: parsed
9045
+ };
9046
+ if (options.name !== void 0) map.name = options.name;
9047
+ return map;
9048
+ }
9049
+
9050
+ //#endregion
9051
+ //#region src/cli/lib/doc-serve.ts
9052
+ /**
9053
+ * Shared construction of docmap entries for served docs: the one-model,
9054
+ * one-renderer contract (spec Decisions 21/22): `tbd docs list`/`show`, the
9055
+ * bare overview, and the per-kind readers all build entries here, so their
9056
+ * JSON output cannot drift.
9057
+ */
9058
+ const KIND_FORK_DIRS = {
9059
+ guideline: FORK_GUIDELINES_DIR,
9060
+ shortcut: FORK_SHORTCUTS_DIR,
9061
+ template: FORK_TEMPLATES_DIR,
9062
+ reference: FORK_REFERENCES_DIR
9063
+ };
9064
+ const KIND_CACHE_DIRS = {
9065
+ guideline: CACHE_GUIDELINES_PATHS,
9066
+ shortcut: CACHE_SHORTCUT_PATHS,
9067
+ template: CACHE_TEMPLATE_PATHS,
9068
+ reference: CACHE_REFERENCE_PATHS
9069
+ };
9070
+ /**
9071
+ * Validated repo-relative dirs from docs_cache.local_dirs (./-prefixed local
9072
+ * docrefs). Invalid entries are skipped; doctor surfaces them.
9073
+ */
9074
+ function sanitizeLocalDirs(localDirs) {
9075
+ const out = [];
9076
+ for (const dir of localDirs ?? []) {
9077
+ const parsed = tryParseDocRef(dir);
9078
+ if (parsed?.kind === "local" && dir.startsWith("./")) out.push(parsed.path.replace(/^\.\//, "").replace(/\/+$/, ""));
9079
+ }
9080
+ return out;
9081
+ }
9082
+ /**
9083
+ * Effective lookup order for serving one kind: fork dir → local_dirs → cache
9084
+ * (the resolution precedence the design fixes). local_dirs entries serve for
9085
+ * every kind so reads find them by name; inventory surfaces dedupe so each
9086
+ * local doc appears once.
9087
+ */
9088
+ function effectiveServePaths(kind, localDirs) {
9089
+ return [
9090
+ KIND_FORK_DIRS[kind],
9091
+ ...localDirs,
9092
+ ...KIND_CACHE_DIRS[kind]
9093
+ ];
9094
+ }
9095
+ /**
9096
+ * Build the docmap entry (+ state marker) for one served doc. Single point of
9097
+ * construction for every docs inventory and read surface (Decision 21/22).
9098
+ * Every entry carries a location: forked docs have path+source, local files a
9099
+ * path, upstream docs their provenance docref.
9100
+ */
9101
+ function servedEntryFor(tbdRoot, kind, doc, manifest, files, localDirs = []) {
9102
+ const fork = findFork(manifest, doc.name, kind);
9103
+ const isLocalDir = !fork && localDirs.some((d) => doc.sourceDir === d);
9104
+ const isLocal = !fork && (doc.sourceDir.startsWith(FORK_DIR) || isLocalDir);
9105
+ let state = "upstream";
9106
+ let marker = "";
9107
+ if (fork) {
9108
+ const customized = hashContent(doc.content) !== fork.base_hash;
9109
+ state = customized ? "customized" : "forked";
9110
+ marker = customized ? "[forked, customized]" : "[forked]";
9111
+ } else if (isLocal) {
9112
+ state = "local";
9113
+ marker = "[local]";
9114
+ }
9115
+ const localPath = isLocalDir ? `${doc.sourceDir}/${doc.name}.md` : `${FORK_DIR}/${KIND_DIR[kind]}/${doc.name}.md`;
9116
+ return {
9117
+ entry: {
9118
+ name: doc.name,
9119
+ type: kind,
9120
+ ...fork ? {
9121
+ path: fork.path,
9122
+ source: fork.source
9123
+ } : isLocal ? { path: localPath } : { source: sourceDocRefFor(tbdRoot, files, doc.path) },
9124
+ title: doc.frontmatter?.title,
9125
+ description: doc.frontmatter?.description,
9126
+ state
9127
+ },
9128
+ state,
9129
+ marker
9130
+ };
9131
+ }
9132
+ /** Load the shared context servedEntryFor needs (manifest + config maps). */
9133
+ async function loadServeContext(tbdRoot) {
9134
+ const manifest = await readForkManifest(tbdRoot);
9135
+ const config = await readConfig(tbdRoot);
9136
+ return {
9137
+ manifest,
9138
+ files: config.docs_cache?.files,
9139
+ localDirs: sanitizeLocalDirs(config.docs_cache?.local_dirs)
9140
+ };
9141
+ }
9142
+ /** Derive the provenance docref for a cached doc from config, defaulting to internal:. */
9143
+ function sourceDocRefFor(tbdRoot, files, docPath) {
9144
+ const rel = relative(join(tbdRoot, TBD_DOCS_DIR), docPath).split(sep).join("/");
9145
+ return files?.[rel] ?? `internal:${rel}`;
9146
+ }
9147
+
9148
+ //#endregion
9149
+ //#region src/lib/doc-categories.ts
9150
+ /**
9151
+ * The fixed category vocabulary for bundled docs. Every bundled guideline
9152
+ * declares exactly one in frontmatter (enforced by doc-categories.test.ts);
9153
+ * the old name-based inference is retired in favor of the declared field.
9154
+ */
9155
+ const DOC_CATEGORIES = [
9156
+ "general",
9157
+ "typescript",
9158
+ "python",
9159
+ "convex",
9160
+ "electron"
9161
+ ];
9162
+ /**
9163
+ * A doc's declared category, or `undefined` if it does not declare one of the
9164
+ * known categories. Categories are guideline-oriented; non-guideline docs
9165
+ * (shortcuts, templates, references) generally declare other categories or none,
9166
+ * so they return `undefined` and are not matched by `--category` selection.
9167
+ * (Previously this defaulted unknown/undeclared categories to `general`, which
9168
+ * made `--category=general` a catch-all that over-forked every such doc.)
9169
+ */
9170
+ function docCategory(frontmatter) {
9171
+ const declared = frontmatter?.category;
9172
+ return DOC_CATEGORIES.includes(declared ?? "") ? declared : void 0;
9173
+ }
9174
+ /** Validate a user-supplied --category value. */
9175
+ function parseCategoryOption(value) {
9176
+ if (!DOC_CATEGORIES.includes(value)) throw new Error(`Unknown category "${value}". Valid categories: ${DOC_CATEGORIES.join(", ")}.`);
9177
+ return value;
9178
+ }
9179
+
9180
+ //#endregion
9181
+ //#region src/cli/commands/docs-fork.ts
9182
+ /** Kinds that can be resolved from the cache and forked today. */
9183
+ const RESOLVABLE_KINDS = [
9184
+ "guideline",
9185
+ "shortcut",
9186
+ "template",
9187
+ "reference"
9188
+ ];
9189
+ /**
9190
+ * Validate a user-supplied --kind value. Without this, an unknown kind silently
9191
+ * produces an empty cache and misleading "no docs" output.
9192
+ */
9193
+ function parseKindOption(kind) {
9194
+ if (kind === void 0) return void 0;
9195
+ if (!RESOLVABLE_KINDS.includes(kind)) throw new CLIError(`Unknown kind "${kind}". Valid kinds: ${RESOLVABLE_KINDS.join(", ")}.`);
9196
+ return kind;
9197
+ }
9198
+ const KIND_CACHE_PATHS = {
9199
+ guideline: CACHE_GUIDELINES_PATHS,
9200
+ shortcut: CACHE_SHORTCUT_PATHS,
9201
+ template: CACHE_TEMPLATE_PATHS,
9202
+ reference: CACHE_REFERENCE_PATHS
9203
+ };
9204
+ /** Build a cache over a kind's cache-only paths (pristine upstream). */
9205
+ async function buildKindCache(kind, tbdRoot) {
9206
+ const cache = new DocCache(KIND_CACHE_PATHS[kind] ?? [], tbdRoot);
9207
+ await cache.load({ quiet: true });
9208
+ return cache;
9209
+ }
9210
+ /** Derive the provenance docref for a cached doc from config, defaulting to internal:. */
9211
+ function sourceDocRef(tbdRoot, files, docPath) {
9212
+ const rel = relative(join(tbdRoot, TBD_DOCS_DIR), docPath).split(sep).join("/");
9213
+ return files?.[rel] ?? `internal:${rel}`;
9214
+ }
9215
+ var DocsForkHandler = class extends BaseCommand {
9216
+ async run(names, options) {
9217
+ await this.execute(async () => {
9218
+ const tbdRoot = await requireInit();
9219
+ const files = (await readConfig(tbdRoot)).docs_cache?.files;
9220
+ const targets = await this.resolveTargets(tbdRoot, files, names, options);
9221
+ if (targets.length === 0) throw new CLIError("No matching docs to fork. Run `tbd docs status` or `tbd guidelines --list`.");
9222
+ if (options.dryRun) {
9223
+ const catSuffix = options.category && options.category.length > 0 ? ` (categories: ${options.category.join(", ")})` : "";
9224
+ this.output.dryRun(`Would fork ${targets.length} doc(s) into ${FORK_DIR}/${catSuffix}`, { docs: targets.map((t) => `${t.kind}/${t.name}`) });
9225
+ if (!this.ctx.json) {
9226
+ for (const t of targets) console.log(` ${t.kind.padEnd(11)} ${t.name}`);
9227
+ console.log("No files written. Re-run without --dry-run to apply.");
9228
+ }
9229
+ return;
9230
+ }
9231
+ const forked = [];
9232
+ await withForkManifestLock(tbdRoot, async () => {
9233
+ let manifest = await readForkManifest(tbdRoot);
9234
+ for (const t of targets) {
9235
+ let result;
9236
+ try {
9237
+ result = await forkDoc({
9238
+ tbdRoot,
9239
+ forkDir: FORK_DIR,
9240
+ manifest,
9241
+ kind: t.kind,
9242
+ name: t.name,
9243
+ source: t.source,
9244
+ content: t.content,
9245
+ tbdVersion: VERSION,
9246
+ force: options.force
9247
+ });
9248
+ } catch (err) {
9249
+ if (err instanceof ForkConflictError && err.code === "overwrite") throw new CLIError(`${err.message}. Refusing to overwrite it. Options:\n tbd docs diff ${t.name} # see how it differs\n tbd docs fork ${t.name} --force # overwrite with upstream`);
9250
+ throw err;
9251
+ }
9252
+ manifest = result.manifest;
9253
+ forked.push(result.relPath);
9254
+ if (!this.ctx.json) this.output.success(`Forked ${t.name} → ${result.relPath}`);
9255
+ }
9256
+ await writeForkManifest(tbdRoot, manifest);
9257
+ await regenerateForkDirReadme(tbdRoot, FORK_DIR, manifest);
9258
+ });
9259
+ if (this.ctx.json) this.output.data({ forked });
9260
+ else {
9261
+ const colors = this.output.getColors();
9262
+ console.log(colors.dim(` Regenerated ${FORK_DIR}/README.md`));
9263
+ console.log("");
9264
+ console.log("Edit in place; tbd now serves your copy wherever it served upstream.");
9265
+ }
9266
+ }, "Failed to fork");
9267
+ }
9268
+ async resolveTargets(tbdRoot, files, names, options) {
9269
+ const parsedKind = parseKindOption(options.kind);
9270
+ const kinds = parsedKind ? [parsedKind] : RESOLVABLE_KINDS;
9271
+ const categories = (options.category ?? []).map(parseCategoryOption);
9272
+ const byCategory = categories.length > 0;
9273
+ if (options.all || byCategory) {
9274
+ const targets = [];
9275
+ for (const kind of kinds) {
9276
+ if (byCategory && kind !== "guideline") continue;
9277
+ const cache = await buildKindCache(kind, tbdRoot);
9278
+ for (const doc of cache.list()) {
9279
+ if (kind === "shortcut" && doc.sourceDir.endsWith("system")) continue;
9280
+ if (byCategory) {
9281
+ const cat = docCategory(doc.frontmatter);
9282
+ if (cat === void 0 || !categories.includes(cat)) continue;
9283
+ }
9284
+ targets.push({
9285
+ kind,
9286
+ name: doc.name,
9287
+ source: sourceDocRef(tbdRoot, files, doc.path),
9288
+ content: doc.content
9289
+ });
9290
+ }
9291
+ }
9292
+ return targets;
9293
+ }
9294
+ const caches = /* @__PURE__ */ new Map();
9295
+ for (const kind of kinds) caches.set(kind, await buildKindCache(kind, tbdRoot));
9296
+ const targets = [];
9297
+ for (const name of names) {
9298
+ const matches = [];
9299
+ for (const kind of kinds) {
9300
+ const hit = caches.get(kind).get(name);
9301
+ if (hit) matches.push({
9302
+ kind,
9303
+ name: hit.doc.name,
9304
+ source: sourceDocRef(tbdRoot, files, hit.doc.path),
9305
+ content: hit.doc.content
9306
+ });
9307
+ }
9308
+ if (matches.length === 0) throw new CLIError(`No doc found named "${name}". Run \`tbd guidelines --list\` to see names.`);
9309
+ if (matches.length > 1) throw new CLIError(`"${name}" exists in multiple kinds (${matches.map((m) => m.kind).join(", ")}). Use --kind to disambiguate.`);
9310
+ targets.push(matches[0]);
9311
+ }
9312
+ return targets;
9313
+ }
9314
+ };
9315
+ var DocsUnforkHandler = class extends BaseCommand {
9316
+ async run(names, options) {
9317
+ await this.execute(async () => {
9318
+ const tbdRoot = await requireInit();
9319
+ const removed = [];
9320
+ await withForkManifestLock(tbdRoot, async () => {
9321
+ let manifest = await readForkManifest(tbdRoot);
9322
+ const targetNames = options.all ? manifest.forks.map((f) => f.name) : names;
9323
+ if (targetNames.length === 0) throw new CLIError("Specify a doc name to unfork, or use --all.");
9324
+ for (const name of targetNames) try {
9325
+ manifest = (await unforkDoc({
9326
+ tbdRoot,
9327
+ forkDir: FORK_DIR,
9328
+ manifest,
9329
+ name,
9330
+ kind: parseKindOption(options.kind),
9331
+ force: options.force
9332
+ })).manifest;
9333
+ removed.push(name);
9334
+ if (!this.ctx.json) this.output.success(`Unforked ${name}, served from upstream again.`);
9335
+ } catch (err) {
9336
+ if (err instanceof ForkConflictError && err.code === "customized") throw new CLIError(`${name} has local customizations (differs from its base). Refusing to discard them. Options:\n tbd docs diff ${name} # review your changes\n tbd docs unfork ${name} --force # discard and fall back to upstream`);
9337
+ throw err;
9338
+ }
9339
+ await writeForkManifest(tbdRoot, manifest);
9340
+ await regenerateForkDirReadme(tbdRoot, FORK_DIR, manifest);
9341
+ });
9342
+ if (this.ctx.json) this.output.data({ unforked: removed });
9343
+ }, "Failed to unfork");
9344
+ }
9345
+ };
9346
+ /** Compose the display label for a doc's state, combining customized + stale. */
9347
+ function stateLabel(state, stale) {
9348
+ if ((state === "customized" || state === "orphaned" || state === "conflicted") && stale) return `${state}, stale`;
9349
+ return state;
9350
+ }
9351
+ var DocsStatusHandler = class extends BaseCommand {
9352
+ async run(options) {
9353
+ await this.execute(async () => {
9354
+ const tbdRoot = await requireInit();
9355
+ const manifest = await readForkManifest(tbdRoot);
9356
+ const colors = this.output.getColors();
9357
+ const caches = /* @__PURE__ */ new Map();
9358
+ const rows = [];
9359
+ for (const entry of manifest.forks) {
9360
+ const kind = entry.kind;
9361
+ if (!caches.has(kind)) caches.set(kind, await buildKindCache(kind, tbdRoot));
9362
+ const status = await forkStatusFor(tbdRoot, FORK_DIR, entry, caches.get(kind).get(entry.name)?.doc.content ?? null);
9363
+ rows.push({
9364
+ name: entry.name,
9365
+ kind: entry.kind,
9366
+ label: stateLabel(status.state, status.stale),
9367
+ path: entry.path,
9368
+ source: entry.source,
9369
+ customized: status.customized,
9370
+ stale: status.stale,
9371
+ conflicted: status.conflicted,
9372
+ missing: status.state === "missing"
9373
+ });
9374
+ }
9375
+ const locals = await listLocalForkFiles(tbdRoot, FORK_DIR, manifest);
9376
+ for (const l of locals) rows.push({
9377
+ name: l.name,
9378
+ kind: l.kind,
9379
+ label: "local",
9380
+ path: l.relPath,
9381
+ source: "—",
9382
+ customized: false,
9383
+ stale: false,
9384
+ conflicted: false,
9385
+ missing: false
9386
+ });
9387
+ rows.sort((a, b) => a.name.localeCompare(b.name));
9388
+ if (this.ctx.json) {
9389
+ const docs = rows.map((r) => ({
9390
+ name: r.name,
9391
+ type: r.kind,
9392
+ path: r.path,
9393
+ ...r.source !== "—" ? { source: r.source } : {},
9394
+ state: r.label,
9395
+ stale: r.stale
9396
+ }));
9397
+ this.output.data(createDocMap(docs, { name: "tbd-forks" }));
9398
+ return;
9399
+ }
9400
+ if (rows.length === 0) {
9401
+ console.log("No docs forked into the repo.");
9402
+ console.log(`Make some visible: ${colors.bold("tbd docs fork <name>")} or ${colors.bold("tbd docs fork --all")}`);
9403
+ return;
9404
+ }
9405
+ const nameW = Math.max(4, ...rows.map((r) => r.name.length));
9406
+ const kindW = Math.max(4, ...rows.map((r) => r.kind.length));
9407
+ const stateW = Math.max(5, ...rows.map((r) => r.label.length));
9408
+ const header = `${"NAME".padEnd(nameW)} ${"KIND".padEnd(kindW)} ${"STATE".padEnd(stateW)} SOURCE`;
9409
+ console.log(colors.dim(header));
9410
+ for (const r of rows) {
9411
+ const line = `${r.name.padEnd(nameW)} ${r.kind.padEnd(kindW)} ${r.label.padEnd(stateW)} ${r.source}`;
9412
+ console.log(r.label === "local" ? colors.dim(line) : line);
9413
+ }
9414
+ const forkedRows = rows.filter((r) => r.label !== "local");
9415
+ const customizedCount = forkedRows.filter((r) => r.customized).length;
9416
+ const staleCount = forkedRows.filter((r) => r.stale).length;
9417
+ const conflictCount = forkedRows.filter((r) => r.conflicted).length;
9418
+ const missingRows = forkedRows.filter((r) => r.missing);
9419
+ const parts = [`${customizedCount} customized`];
9420
+ if (staleCount > 0) parts.push(`${staleCount} with upstream updates; run 'tbd docs update'`);
9421
+ if (conflictCount > 0) parts.push(`${conflictCount} conflict pending`);
9422
+ if (locals.length > 0) parts.push(`${locals.length} local`);
9423
+ console.log("");
9424
+ console.log(`${forkedRows.length} forked: ${parts.join(", ")}`);
9425
+ if (missingRows.length > 0) {
9426
+ console.log("");
9427
+ console.log(`${missingRows.length} doc(s) missing (forked file deleted or renamed):`);
9428
+ for (const r of missingRows) console.log(` ${r.name} restore with 'tbd docs fork ${r.name} --force', or finalize with 'tbd docs unfork ${r.name}'`);
9429
+ }
9430
+ }, "Failed to read docs status");
9431
+ }
9432
+ };
9433
+ var DocsUpdateHandler = class extends BaseCommand {
9434
+ async run(names, options) {
9435
+ await this.execute(async () => {
9436
+ if (options.merge && options.keepOurs) throw new CLIError("--merge and --keep-ours are mutually exclusive.");
9437
+ const strategy = options.merge ? "merge" : options.keepOurs ? "keep-ours" : "default";
9438
+ const tbdRoot = await requireInit();
9439
+ const applied = [];
9440
+ const decisions = [];
9441
+ const skipped = [];
9442
+ await withForkManifestLock(tbdRoot, async () => {
9443
+ let manifest = await readForkManifest(tbdRoot);
9444
+ if (names.length > 0) {
9445
+ const known = new Set(manifest.forks.map((f) => f.name));
9446
+ const unknown = names.filter((n) => !known.has(n));
9447
+ if (unknown.length > 0) throw new CLIError(`Not forked: ${unknown.join(", ")}. Run \`tbd docs status\` to see forked docs.`);
9448
+ }
9449
+ const selected = names.length > 0 ? manifest.forks.filter((f) => names.includes(f.name)) : manifest.forks;
9450
+ const caches = /* @__PURE__ */ new Map();
9451
+ const upstreamFor = async (entry) => {
9452
+ const kind = entry.kind;
9453
+ if (!caches.has(kind)) caches.set(kind, await buildKindCache(kind, tbdRoot));
9454
+ return caches.get(kind).get(entry.name)?.doc.content ?? null;
9455
+ };
9456
+ for (const entry of selected) {
9457
+ const forkContent = await readForkFile(tbdRoot, FORK_DIR, entry);
9458
+ const result = await updateOne({
9459
+ entry,
9460
+ forkContent,
9461
+ baseContent: await readForkBase(tbdRoot, entry),
9462
+ upstreamContent: await upstreamFor(entry),
9463
+ strategy,
9464
+ runningVersion: VERSION
9465
+ });
9466
+ const { newFileContent, newBaseContent } = result;
9467
+ if (newFileContent === void 0 && newBaseContent === void 0) {
9468
+ if (!options.dryRun && entry.conflicted && forkContent !== null && !hasUnresolvedConflict(forkContent)) {
9469
+ const cleared = { ...entry };
9470
+ delete cleared.conflicted;
9471
+ manifest = upsertFork(manifest, cleared);
9472
+ }
9473
+ if (result.needsDecision) decisions.push({
9474
+ name: entry.name,
9475
+ message: result.message
9476
+ });
9477
+ else if (result.action !== "skip-not-stale") skipped.push({
9478
+ name: entry.name,
9479
+ message: result.message
9480
+ });
9481
+ continue;
9482
+ }
9483
+ if (!options.dryRun) {
9484
+ if (newFileContent !== void 0) {
9485
+ const abs = forkFilePath(tbdRoot, FORK_DIR, entry.kind, entry.name);
9486
+ await mkdir(dirname(abs), { recursive: true });
9487
+ await writeFile(abs, newFileContent);
9488
+ }
9489
+ const updated = { ...entry };
9490
+ if (newBaseContent !== void 0) {
9491
+ await writeBaseContent(tbdRoot, entry.kind, entry.name, newBaseContent);
9492
+ updated.base_hash = hashContent(newBaseContent);
9493
+ updated.tbd_version = VERSION;
9494
+ }
9495
+ if (result.setConflicted) updated.conflicted = true;
9496
+ else delete updated.conflicted;
9497
+ manifest = upsertFork(manifest, updated);
9498
+ }
9499
+ applied.push({
9500
+ entry,
9501
+ message: result.message
9502
+ });
9503
+ }
9504
+ if (!options.dryRun) {
9505
+ await writeForkManifest(tbdRoot, manifest);
9506
+ await regenerateForkDirReadme(tbdRoot, FORK_DIR, manifest);
9507
+ }
9508
+ });
9509
+ if (this.ctx.json) {
9510
+ this.output.data({
9511
+ dryRun: Boolean(options.dryRun),
9512
+ updated: applied.map((a) => a.entry.name),
9513
+ needsDecision: decisions,
9514
+ skipped
9515
+ });
9516
+ return;
9517
+ }
9518
+ const colors = this.output.getColors();
9519
+ if (applied.length === 0 && decisions.length === 0 && skipped.length === 0) {
9520
+ console.log("All forked docs are up to date.");
9521
+ return;
9522
+ }
9523
+ if (applied.length > 0) {
9524
+ const verb = options.dryRun ? "Would update" : "Updated";
9525
+ console.log(`${verb} ${applied.length} forked doc(s):`);
9526
+ for (const a of applied) console.log(` ${colors.success("✓")} ${a.message}`);
9527
+ }
9528
+ if (skipped.length > 0) {
9529
+ console.log("");
9530
+ console.log(`${skipped.length} doc(s) skipped:`);
9531
+ for (const sk of skipped) console.log(` ${colors.warn("⚠")} ${sk.message}`);
9532
+ }
9533
+ if (decisions.length > 0) {
9534
+ console.log("");
9535
+ console.log(`${decisions.length} doc(s) need a decision:`);
9536
+ for (const d of decisions) console.log(` ${colors.warn("⚠")} ${d.message}`);
9537
+ console.log(" re-run with one of:");
9538
+ console.log(" tbd docs update <name> --merge # combine, then resolve conflict markers");
9539
+ console.log(" tbd docs update <name> --keep-ours # keep your version, advance the fork point");
9540
+ }
9541
+ }, "Failed to update forked docs");
9542
+ }
9543
+ };
9544
+ var DocsListHandler = class extends BaseCommand {
9545
+ async run(options) {
9546
+ await this.execute(async () => {
9547
+ const tbdRoot = await requireInit();
9548
+ const { manifest, files, localDirs } = await loadServeContext(tbdRoot);
9549
+ const parsedKind = parseKindOption(options.kind);
9550
+ const kinds = parsedKind ? [parsedKind] : RESOLVABLE_KINDS;
9551
+ const colors = this.output.getColors();
9552
+ const seenLocal = /* @__PURE__ */ new Set();
9553
+ const grouped = [];
9554
+ const docmapEntries = [];
9555
+ for (const kind of kinds) {
9556
+ const cache = new DocCache(effectiveServePaths(kind, localDirs), tbdRoot);
9557
+ await cache.load({ quiet: true });
9558
+ const rows = [];
9559
+ for (const doc of cache.list()) {
9560
+ if (localDirs.includes(doc.sourceDir)) {
9561
+ if (seenLocal.has(doc.path)) continue;
9562
+ seenLocal.add(doc.path);
9563
+ }
9564
+ const { entry, state, marker } = servedEntryFor(tbdRoot, kind, doc, manifest, files, localDirs);
9565
+ rows.push({
9566
+ name: doc.name,
9567
+ title: doc.frontmatter?.title,
9568
+ description: doc.frontmatter?.description,
9569
+ sizeInfo: formatDocSize(doc.sizeBytes, doc.approxTokens),
9570
+ marker,
9571
+ state,
9572
+ path: entry.path ?? doc.sourceDir + "/" + doc.name + ".md"
9573
+ });
9574
+ docmapEntries.push(entry);
9575
+ }
9576
+ grouped.push({
9577
+ kind,
9578
+ rows
9579
+ });
9580
+ }
9581
+ if (this.ctx.json) {
9582
+ this.output.data(createDocMap(docmapEntries, { name: "tbd-docs" }));
9583
+ return;
9584
+ }
9585
+ for (const { kind, rows } of grouped) {
9586
+ if (rows.length === 0) continue;
9587
+ if (!options.kind) console.log(colors.bold(kind));
9588
+ for (const r of rows) {
9589
+ const indent = options.kind ? "" : " ";
9590
+ const markerStr = r.marker ? ` ${colors.dim(r.marker)}` : "";
9591
+ console.log(`${indent}${colors.bold(r.name)} ${colors.dim(r.sizeInfo)}${markerStr}`);
9592
+ const desc = r.title && r.description ? `${r.title}: ${r.description}` : r.title ?? r.description;
9593
+ if (desc) console.log(`${indent} ${desc}`);
9594
+ }
9595
+ }
9596
+ }, "Failed to list docs");
9597
+ }
9598
+ };
9599
+ var DocsDiffHandler = class extends BaseCommand {
9600
+ async run(name, options) {
9601
+ await this.execute(async () => {
9602
+ const tbdRoot = await requireInit();
9603
+ const entry = findFork(await readForkManifest(tbdRoot), name, parseKindOption(options.kind));
9604
+ if (!entry) throw new CLIError(`${name} is not a forked doc. Run \`tbd docs status\` to see forks.`);
9605
+ const forkContent = await readForkFile(tbdRoot, FORK_DIR, entry);
9606
+ const baseContent = await readForkBase(tbdRoot, entry);
9607
+ const upstreamContent = (await buildKindCache(entry.kind, tbdRoot)).get(entry.name)?.doc.content ?? null;
9608
+ let left = upstreamContent;
9609
+ let right = forkContent;
9610
+ let labels = {
9611
+ left: "upstream",
9612
+ right: "ours"
9613
+ };
9614
+ if (options.base) {
9615
+ left = baseContent;
9616
+ right = forkContent;
9617
+ labels = {
9618
+ left: "base",
9619
+ right: "ours"
9620
+ };
9621
+ } else if (options.upstream) {
9622
+ left = baseContent;
9623
+ right = upstreamContent;
9624
+ labels = {
9625
+ left: "base",
9626
+ right: "upstream"
9627
+ };
9628
+ }
9629
+ if (left === null || right === null) throw new CLIError(`Cannot diff ${name}: one side is unavailable (forked file missing, base missing, or upstream gone).`);
9630
+ const diff = await diffContents(left, right, labels);
9631
+ if (this.ctx.json) {
9632
+ this.output.data({
9633
+ name: entry.name,
9634
+ kind: entry.kind,
9635
+ diff
9636
+ });
9637
+ return;
9638
+ }
9639
+ if (!diff.trim()) {
9640
+ console.log(`No differences (${labels.left} vs ${labels.right}).`);
9641
+ return;
9642
+ }
9643
+ console.log(diff.trimEnd());
9644
+ }, "Failed to diff");
9645
+ }
9646
+ };
9647
+ /**
9648
+ * Merge a subcommand's local options with globals/ancestors (e.g. the global
9649
+ * --dry-run and --json), preferring the subcommand's own values.
9650
+ */
9651
+ function mergedForkOptions(local, command) {
9652
+ const g = command.optsWithGlobals();
9653
+ return {
9654
+ all: local.all ?? g.all,
9655
+ category: local.category,
9656
+ kind: local.kind ?? g.kind,
9657
+ force: local.force ?? g.force,
9658
+ dryRun: local.dryRun ?? g.dryRun,
9659
+ json: g.json
9660
+ };
9661
+ }
9662
+ /** Register fork/unfork/status subcommands onto the `docs` command. */
9663
+ function registerForkSubcommands(docs) {
9664
+ docs.command("fork").description("Fork managed docs into the repo (default docs/tbd/) so they are visible and editable").argument("[names...]", "doc name(s) to fork").option("--kind <kind>", "restrict to a kind (guideline|shortcut|template|reference)").option("--all", "fork all available docs").option("--category <name>", "fork all docs in a category (repeatable: general|typescript|python|convex|electron)", (value, previous = []) => [...previous, value]).option("--force", "overwrite an existing non-fork file").action(async (names, options, command) => {
9665
+ await new DocsForkHandler(command).run(names, mergedForkOptions(options, command));
9666
+ });
9667
+ docs.command("unfork").description("Remove a fork and fall back to upstream (refuses to discard edits without --force)").argument("[names...]", "doc name(s) to unfork").option("--kind <kind>", "restrict to a kind").option("--all", "unfork all forked docs").option("--force", "discard local customizations").action(async (names, options, command) => {
9668
+ const m = mergedForkOptions(options, command);
9669
+ await new DocsUnforkHandler(command).run(names, {
9670
+ all: m.all,
9671
+ kind: m.kind,
9672
+ force: m.force,
9673
+ json: m.json
9674
+ });
9675
+ });
9676
+ docs.command("list").description("List all docs across kinds, with [forked]/[customized]/[local] markers").option("--kind <kind>", "restrict to a kind (guideline|shortcut|template)").action(async (options, command) => {
9677
+ await new DocsListHandler(command).run({
9678
+ kind: options.kind,
9679
+ json: command.optsWithGlobals().json === true
9680
+ });
9681
+ });
9682
+ docs.command("status").description("Show forked docs and their states").action(async (_options, command) => {
9683
+ await new DocsStatusHandler(command).run({ json: command.optsWithGlobals().json === true });
9684
+ });
9685
+ docs.command("update").description("Reconcile forked docs with upstream after an upgrade (--merge / --keep-ours)").argument("[names...]", "doc name(s) to update (default: all)").option("--merge", "on conflict: combine and write conflict markers to resolve").option("--keep-ours", "on conflict: keep your version and advance the fork point").action(async (names, options, command) => {
9686
+ const g = command.optsWithGlobals();
9687
+ await new DocsUpdateHandler(command).run(names, {
9688
+ merge: options.merge,
9689
+ keepOurs: options.keepOurs,
9690
+ dryRun: g.dryRun === true,
9691
+ json: g.json === true
9692
+ });
9693
+ });
9694
+ docs.command("diff").description("Diff a forked doc against upstream (default), its base (--base), or incoming (--upstream)").argument("<name>", "forked doc name").option("--base", "diff your file against its base (what you changed)").option("--upstream", "diff the base against current upstream (incoming changes)").option("--kind <kind>", "restrict to a kind").action(async (name, options, command) => {
9695
+ await new DocsDiffHandler(command).run(name, options);
9696
+ });
9697
+ }
9698
+
9699
+ //#endregion
9700
+ //#region src/file/doc-add.ts
9701
+ /**
9702
+ * Add external documentation to the tbd doc cache.
9703
+ *
9704
+ * Uses the shared github-fetch utility for URL conversion and fetching.
9705
+ * Handles doc-specific concerns: content validation, file writing, and
9706
+ * atomic config updates.
9707
+ */
9708
+ /**
9709
+ * Validate that fetched content looks like a reasonable markdown document.
9710
+ *
9711
+ * @throws If content fails sanity checks
9712
+ */
9713
+ function validateDocContent(content, name) {
9714
+ if (!content || content.trim().length === 0) throw new Error(`Fetched content for "${name}" is empty`);
9715
+ if (content.length < 10) throw new Error(`Fetched content for "${name}" is too short (${content.length} chars)`);
9716
+ if (content.trimStart().startsWith("<!DOCTYPE") || content.trimStart().startsWith("<html")) throw new Error(`Fetched content for "${name}" appears to be an HTML page, not a markdown document`);
9717
+ if (content.split("").filter((c) => {
9718
+ const code = c.charCodeAt(0);
9719
+ return code < 32 && code !== 9 && code !== 10 && code !== 13;
9720
+ }).length / content.length > .1) throw new Error(`Fetched content for "${name}" appears to be binary, not a text document`);
9721
+ }
9722
+ /**
9723
+ * Get the destination subdirectory for a doc type.
9724
+ */
9725
+ function getDocTypeSubdir(docType) {
9726
+ switch (docType) {
9727
+ case "guideline": return "guidelines";
9728
+ case "shortcut": return "shortcuts/custom";
9729
+ case "template": return "templates";
9730
+ case "reference": return "references";
9731
+ }
9732
+ }
9733
+ /**
9734
+ * Add an external document to the tbd doc cache.
9735
+ *
9736
+ * This function:
9737
+ * 1. Converts GitHub blob URLs to raw URLs
9738
+ * 2. Fetches the content (with gh CLI fallback on 403)
9739
+ * 3. Validates the content looks like markdown
9740
+ * 4. Writes the file to .tbd/docs/{type}/{name}.md
9741
+ * 5. Atomically updates the config to include the new source
9742
+ *
9743
+ * @throws If fetching, validation, or config update fails
9744
+ */
9745
+ async function addDoc(tbdRoot, options) {
9746
+ const { url, name, docType } = options;
9747
+ const cleanName = name.endsWith(".md") ? name.slice(0, -3) : name;
9748
+ const filename = `${cleanName}.md`;
9749
+ const subdir = getDocTypeSubdir(docType);
9750
+ const destPath = `${subdir}/${filename}`;
9751
+ const source = normalizeDocRef(url);
9752
+ const parsed = parseDocRef(source);
9753
+ if (parsed.kind === "internal") throw new DocRefError(url, "bundled internal: sources cannot be added");
9754
+ if (parsed.kind === "git" && !parsed.ref) throw new DocRefError(url, `git docrefs need an explicit ref for deterministic sync; use ${parsed.host}:${parsed.owner}/${parsed.repo}@<ref>//${parsed.path}`);
9755
+ let content;
9756
+ let usedGhCli = false;
9757
+ let rawUrl = source;
9758
+ if (parsed.kind === "local") {
9759
+ rawUrl = parsed.path;
9760
+ content = await readFile(join(tbdRoot, parsed.path), "utf-8");
9761
+ } else {
9762
+ rawUrl = parsed.kind === "git" ? gitDocRefToRawUrl(parsed) : parsed.url;
9763
+ const fetched = await fetchWithGhFallback(rawUrl);
9764
+ content = fetched.content;
9765
+ usedGhCli = fetched.usedGhCli;
9766
+ }
9767
+ validateDocContent(content, cleanName);
9768
+ const fullPath = join(tbdRoot, TBD_DOCS_DIR, destPath);
9769
+ await mkdir(dirname(fullPath), { recursive: true });
9770
+ await writeFile(fullPath, content);
9771
+ const config = await readConfig(tbdRoot);
9772
+ config.docs_cache ??= {
9773
+ files: {},
9774
+ lookup_path: []
9775
+ };
9776
+ config.docs_cache.files ??= {};
9777
+ config.docs_cache.files[destPath] = source;
9778
+ const lookupDir = `.tbd/docs/${subdir}`;
9779
+ if (!config.docs_cache.lookup_path.includes(lookupDir)) config.docs_cache.lookup_path.push(lookupDir);
9780
+ await writeConfig(tbdRoot, config);
9781
+ return {
9782
+ destPath,
9783
+ source,
9784
+ rawUrl,
9785
+ usedGhCli
9786
+ };
9787
+ }
9788
+
9789
+ //#endregion
9790
+ //#region src/cli/lib/docs-menu.ts
9791
+ /**
9792
+ * Shared docs-posture menu for the zero-forks state.
9793
+ *
9794
+ * The same "three postures" menu is shown by `tbd setup --auto` (the Docs
9795
+ * summary) and by the bare `tbd docs` overview, so the wording lives in one
9796
+ * place and the two surfaces cannot drift. Lines are returned unindented and
9797
+ * uncolored; callers add their own indentation and formatting.
9798
+ *
9799
+ * Contract note (forkable-docs spec): the menu must only name selectors that
9800
+ * exist; `--category` appears here because the flag ships with it.
9801
+ */
9802
+ /**
9803
+ * Menu body lines for the zero-forks state: the three serving postures plus
9804
+ * the browse/read pointer. Shown under a lead line reporting the count of
9805
+ * docs available in the cache.
9806
+ */
9807
+ function docsPostureMenuLines() {
9808
+ return [
9809
+ "Guidelines are active from the cache. Three postures, all serving the same docs:",
9810
+ "Hidden (default): keep the cache as-is; zero repo footprint",
9811
+ `Curated: tbd docs fork <name> [...] fork chosen docs into ${FORK_DIR}/`,
9812
+ ` tbd docs fork --category=<name> (${DOC_CATEGORIES.join(", ")})`,
9813
+ "Everything: tbd docs fork --all all docs, visible and editable",
9814
+ "Browse / read: tbd docs list / tbd docs show <name>"
9815
+ ];
9816
+ }
9817
+
9818
+ //#endregion
9819
+ //#region src/cli/commands/docs.ts
9820
+ /**
9821
+ * `tbd docs`, manage tbd-served docs: browse, fork into the repo, sync the
9822
+ * cache, and pull upstream updates into forks.
9823
+ *
9824
+ * Surface (the f05 reorganization of the old manual viewer):
9825
+ * tbd docs status overview of managed docs (landing page)
9826
+ * tbd docs show <name> read any doc by name (kind-agnostic; --section)
9827
+ * tbd docs show tbd-docs the CLI manual (old bare `tbd docs`)
9828
+ * tbd docs manual [topic] alias for `tbd docs show tbd-docs`
9829
+ * tbd docs sync refresh the gitignored cache (canonical form of
9830
+ * the deprecated `tbd sync --docs` alias)
9831
+ * tbd docs list/status/fork/unfork/update/diff (see docs-fork.ts)
9832
+ */
9833
+ /** Reserved name that serves the CLI manual (`tbd-docs.md`). */
9834
+ const MANUAL_DOC_NAME = "tbd-docs";
9835
+ /**
9836
+ * Self-docs: served as `reference` docs from the cache like everything else
9837
+ * (so forks shadow them), but with a bundled fallback so they stay readable
9838
+ * before init or before the first cache sync.
9839
+ */
9840
+ const BUNDLED_ROOT_DOCS = {
9841
+ [MANUAL_DOC_NAME]: "tbd-docs.md",
9842
+ "tbd-design": "tbd-design.md"
9843
+ };
9844
+ /**
9845
+ * Read a doc bundled at the docs root. Copied to dist/docs/ during build; in
9846
+ * development read from the package docs/ directory.
9847
+ */
9848
+ async function readBundledRootDoc(filename) {
9849
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9850
+ try {
9851
+ return await readFile(join(__dirname, "docs", filename), "utf-8");
9852
+ } catch {
9853
+ try {
9854
+ return await readFile(join(__dirname, "..", "..", "..", "docs", filename), "utf-8");
9855
+ } catch {
9856
+ throw new CLIError("Documentation file not found. Please rebuild the CLI.");
9857
+ }
9858
+ }
9859
+ }
9860
+ /** Extract `## ` section metadata (title + GitHub slug) from a markdown doc. */
9861
+ function extractSections(content) {
9862
+ const sections = [];
9863
+ const slugger = new GithubSlugger();
9864
+ for (const line of content.split("\n")) if (line.startsWith("## ")) {
9865
+ const title = line.slice(3).trim();
9866
+ sections.push({
9867
+ title,
9868
+ slug: slugger.slug(title)
9869
+ });
9870
+ }
9871
+ return sections;
9872
+ }
9873
+ /**
9874
+ * Extract one `## ` section (header through the next header), matching by slug
9875
+ * or partial title. Returns null when no section matches.
9876
+ */
9877
+ function extractSection(content, sections, query) {
9878
+ const lowerQuery = query.toLowerCase();
9879
+ const matched = sections.find((s) => s.slug === lowerQuery) ?? sections.find((s) => s.title.toLowerCase().includes(lowerQuery));
9880
+ if (!matched) return null;
9881
+ const lines = content.split("\n");
9882
+ let inSection = false;
9883
+ const sectionLines = [];
9884
+ for (const line of lines) if (line.startsWith("## ")) {
9885
+ if (inSection) break;
9886
+ if (line.slice(3).trim() === matched.title) {
9887
+ inSection = true;
9888
+ sectionLines.push(line);
9889
+ }
9890
+ } else if (inSection) sectionLines.push(line);
9891
+ while (sectionLines.length > 0 && sectionLines[sectionLines.length - 1]?.trim() === "") sectionLines.pop();
9892
+ return sectionLines.length > 0 ? sectionLines.join("\n") : null;
9893
+ }
9894
+ /**
9895
+ * Bare `tbd docs`: the status overview / landing page for managed docs.
9896
+ * Mirrors `tbd status`: a summary plus pointers, never the full table.
9897
+ */
9898
+ var DocsOverviewHandler = class extends BaseCommand {
9899
+ async run() {
9900
+ await this.execute(async () => {
9901
+ let tbdRoot;
9902
+ try {
9903
+ tbdRoot = await requireInit();
9904
+ } catch (err) {
9905
+ if (!(err instanceof NotInitializedError)) throw err;
9906
+ const colors = this.output.getColors();
9907
+ console.log(`${colors.bold("tbd docs")}: managed documentation`);
9908
+ console.log("");
9909
+ console.log(" tbd is not initialized in this repo (run: tbd setup --auto).");
9910
+ console.log(" The CLI manual is bundled and always available:");
9911
+ console.log("");
9912
+ console.log(" Learn more: tbd docs show tbd-docs (alias: tbd docs manual)");
9913
+ return;
9914
+ }
9915
+ const manifest = await readForkManifest(tbdRoot);
9916
+ const drift = await computeForkDriftSummary(tbdRoot, FORK_DIR, manifest);
9917
+ const { files, localDirs } = await loadServeContext(tbdRoot);
9918
+ let total = 0;
9919
+ const entries = [];
9920
+ const seenLocal = /* @__PURE__ */ new Set();
9921
+ for (const kind of RESOLVABLE_KINDS) {
9922
+ const cache = new DocCache(effectiveServePaths(kind, localDirs), tbdRoot);
9923
+ await cache.load({ quiet: true });
9924
+ for (const doc of cache.list()) {
9925
+ if (localDirs.includes(doc.sourceDir)) {
9926
+ if (seenLocal.has(doc.path)) continue;
9927
+ seenLocal.add(doc.path);
9928
+ }
9929
+ total += 1;
9930
+ if (this.ctx.json) entries.push(servedEntryFor(tbdRoot, kind, doc, manifest, files, localDirs).entry);
9931
+ }
9932
+ }
9933
+ if (this.ctx.json) {
9934
+ this.output.data(createDocMap(entries, { name: "tbd-docs" }));
9935
+ return;
9936
+ }
9937
+ const colors = this.output.getColors();
9938
+ console.log(`${colors.bold("tbd docs")}: managed documentation`);
9939
+ console.log("");
9940
+ if (drift.forks === 0) {
9941
+ console.log(` ${total} docs available in the cache (.tbd/docs/, gitignored); none forked into the repo.`);
9942
+ const [intro, ...postures] = docsPostureMenuLines();
9943
+ console.log(` ${intro}`);
9944
+ console.log("");
9945
+ for (const line of postures.slice(0, -1)) console.log(` ${line}`);
9946
+ console.log("");
9947
+ console.log(` ${postures[postures.length - 1]}`);
9948
+ console.log(` Learn more: tbd docs show tbd-docs (the manual; alias: tbd docs manual)`);
9949
+ return;
9950
+ }
9951
+ const upstream = total - drift.forks;
9952
+ console.log(` ${total} available (${upstream} upstream, ${drift.forks} forked into ${FORK_DIR}/)`);
9953
+ const parts = [`${drift.customized} customized`];
9954
+ if (drift.stale > 0) parts.push(`${drift.stale} with upstream updates; run 'tbd docs update'`);
9955
+ if (drift.conflicted > 0) parts.push(`${drift.conflicted} conflict pending`);
9956
+ if (drift.missing > 0) parts.push(`${drift.missing} missing; see 'tbd docs status'`);
9957
+ if (drift.local > 0) parts.push(`${drift.local} local`);
9958
+ console.log(` ${drift.forks} forked: ${parts.join(", ")}`);
9959
+ console.log("");
9960
+ console.log(" Inspect: tbd docs status");
9961
+ console.log(" Browse: tbd docs list");
9962
+ console.log(" Update: tbd docs update");
9963
+ console.log(" Learn more: tbd docs show tbd-docs");
9964
+ }, "Failed to read docs overview");
9965
+ }
9966
+ };
9967
+ /**
9968
+ * `tbd docs show <name>`: kind-agnostic read of any managed doc. The reserved
9969
+ * `tbd-docs` name serves the bundled CLI manual (with `--section` navigation,
9970
+ * relocated here from the old bare `tbd docs` viewer).
9971
+ */
9972
+ var DocsShowHandler = class extends BaseCommand {
9973
+ async run(name, options) {
9974
+ await this.execute(async () => {
9975
+ let content;
9976
+ let provenance = null;
9977
+ const bundledFallback = BUNDLED_ROOT_DOCS[name];
9978
+ let tbdRoot = null;
9979
+ if (bundledFallback) try {
9980
+ tbdRoot = await requireInit();
9981
+ } catch (err) {
9982
+ if (!(err instanceof NotInitializedError)) throw err;
9983
+ }
9984
+ else tbdRoot = await requireInit();
9985
+ if (tbdRoot === null) content = await readBundledRootDoc(bundledFallback);
9986
+ else {
9987
+ const requestedKind = parseKindOption(options.kind);
9988
+ const kinds = requestedKind ? [requestedKind] : RESOLVABLE_KINDS;
9989
+ const { manifest: showManifest, files: showFiles, localDirs } = await loadServeContext(tbdRoot);
9990
+ const matches = [];
9991
+ for (const kind of kinds) {
9992
+ const cache = new DocCache(effectiveServePaths(kind, localDirs), tbdRoot);
9993
+ await cache.load({ quiet: true });
9994
+ const hit = cache.get(name);
9995
+ if (hit) matches.push({
9996
+ kind,
9997
+ content: hit.doc.content,
9998
+ sourceDir: hit.doc.sourceDir,
9999
+ path: hit.doc.path
10000
+ });
10001
+ }
10002
+ if (new Set(matches.map((m) => m.path)).size === 1 && matches.length > 1) matches.length = 1;
10003
+ if (matches.length === 0) if (bundledFallback) content = await readBundledRootDoc(bundledFallback);
10004
+ else throw new NotFoundError("Doc", `"${name}" (run \`tbd docs list\` to see names)`);
10005
+ else if (matches.length > 1) throw new CLIError(`"${name}" exists in multiple kinds (${matches.map((m) => m.kind).join(", ")}). Use --kind to disambiguate.`);
10006
+ else {
10007
+ const match = matches[0];
10008
+ content = match.content;
10009
+ if (this.ctx.json && !options.sections) {
10010
+ const { entry } = servedEntryFor(tbdRoot, match.kind, {
10011
+ name,
10012
+ content,
10013
+ sourceDir: match.sourceDir,
10014
+ path: match.path
10015
+ }, showManifest, showFiles, localDirs);
10016
+ this.output.data({
10017
+ ...entry,
10018
+ content
10019
+ });
10020
+ return;
10021
+ }
10022
+ if (match.sourceDir.startsWith(FORK_DIR)) provenance = `(serving forked copy: ${relative(tbdRoot, match.path).split("\\").join("/")})`;
10023
+ else if (localDirs.includes(match.sourceDir)) provenance = `(serving local doc: ${relative(tbdRoot, match.path).split("\\").join("/")})`;
10024
+ }
10025
+ }
10026
+ const sections = extractSections(content);
10027
+ if (options.sections) {
10028
+ this.output.data(sections, () => {
10029
+ const colors = this.output.getColors();
10030
+ console.log(colors.bold(`Sections in ${name}:`));
10031
+ console.log("");
10032
+ const maxSlugLen = Math.max(...sections.map((s) => s.slug.length));
10033
+ for (const section of sections) console.log(` ${colors.id(section.slug.padEnd(maxSlugLen))} ${section.title}`);
10034
+ console.log("");
10035
+ console.log(`Use ${colors.dim(`tbd docs show ${name} --section <name>`)} to view a section.`);
10036
+ });
10037
+ return;
10038
+ }
10039
+ if (options.section) {
10040
+ const sectionContent = extractSection(content, sections, options.section);
10041
+ if (!sectionContent) throw new NotFoundError("Section", `"${options.section}" (use --sections to see available sections)`);
10042
+ content = sectionContent;
10043
+ }
10044
+ if (provenance && !this.ctx.quiet && !this.ctx.json) process.stderr.write(`${provenance}\n`);
10045
+ if (shouldUseInteractiveOutput(this.ctx)) await paginateOutput(renderMarkdown(content, this.ctx.color), true);
10046
+ else console.log(content);
10047
+ }, "Failed to show doc");
10048
+ }
10049
+ };
10050
+ /**
10051
+ * `tbd docs add <docref>`: register an external doc in the cache. The unified
10052
+ * form of the per-kind --add flags (which remain as aliases); the canonical
10053
+ * docref is what config records.
10054
+ */
10055
+ var DocsAddHandler = class extends BaseCommand {
10056
+ async run(docref, options) {
10057
+ await this.execute(async () => {
10058
+ const tbdRoot = await requireInit();
10059
+ const kind = parseKindOption(options.kind);
10060
+ if (!kind) throw new CLIError("--kind is required (guideline|shortcut|template|reference)");
10061
+ const inferredName = (() => {
10062
+ const parsed = parseDocRef(docref);
10063
+ const base = (parsed.kind === "url" ? new URL(parsed.url).pathname : "path" in parsed ? parsed.path : "").split("/").filter(Boolean).pop() ?? "";
10064
+ return base.endsWith(".md") ? base.slice(0, -3) : base;
10065
+ })();
10066
+ const finalName = options.name ?? inferredName;
10067
+ if (!finalName) throw new CLIError("--name is required when it cannot be inferred from the docref");
10068
+ console.log(`Adding ${kind}: ${finalName}`);
10069
+ const result = await addDoc(tbdRoot, {
10070
+ url: docref,
10071
+ name: finalName,
10072
+ docType: kind
10073
+ });
10074
+ console.log(` Source: ${result.source}`);
10075
+ if (result.usedGhCli) {
10076
+ const colors = this.output.getColors();
10077
+ console.log(colors.dim(" (fetched via gh CLI due to direct access restriction)"));
10078
+ }
10079
+ this.output.success(`Added to .tbd/docs/${result.destPath}`);
10080
+ console.log(` Config updated (docs_cache.files): ${result.source}`);
10081
+ console.log("");
10082
+ console.log(`Run 'tbd docs list' to verify, or 'tbd docs fork ${finalName}' to make it visible.`);
10083
+ }, "Failed to add doc");
10084
+ }
10085
+ };
10086
+ /** `tbd docs sync`: refresh the gitignored docs cache (canonical command). */
10087
+ var DocsSyncHandler = class extends BaseCommand {
10088
+ async run() {
10089
+ await this.execute(async () => {
10090
+ const tbdRoot = await requireInit();
10091
+ if (this.ctx.dryRun) {
10092
+ const result = await syncDocsWithDefaults(tbdRoot, { dryRun: true });
10093
+ printDocSyncStatus(this.output, result);
10094
+ return;
10095
+ }
10096
+ const spinner = this.output.spinner("Syncing docs...");
10097
+ const result = await syncDocsWithDefaults(tbdRoot);
10098
+ spinner.stop();
10099
+ printDocSyncResult(this.output, result);
10100
+ await printForkDriftNotice(this.output, tbdRoot);
10101
+ }, "Failed to sync docs");
10102
+ }
10103
+ };
10104
+ const docsCommand = new Command("docs").description("Manage tbd-served docs: browse, fork into your repo, and pull upstream updates").action(async (_options, command) => {
10105
+ await new DocsOverviewHandler(command).run();
10106
+ });
10107
+ docsCommand.command("show").description("Read any managed doc by name (tbd-docs is the CLI manual)").argument("<name>", "doc name (e.g. python-rules, tbd-docs)").option("--section <name>", "show one section of the doc").option("--sections", "list the doc’s sections").option("--kind <kind>", "restrict to a kind (guideline|shortcut|template)").action(async (name, options, command) => {
10108
+ await new DocsShowHandler(command).run(name, options);
10109
+ });
10110
+ docsCommand.command("manual").description("Show the tbd CLI manual (alias for: tbd docs show tbd-docs)").argument("[topic]", "section to display (e.g. \"commands\", \"id-system\")").option("--section <name>", "show one section of the manual").option("--sections", "list the manual’s sections").action(async (topic, options, command) => {
10111
+ await new DocsShowHandler(command).run(MANUAL_DOC_NAME, {
10112
+ ...options,
10113
+ section: options.section ?? topic
10114
+ });
10115
+ });
10116
+ docsCommand.command("add").description("Register an external doc in the cache by docref (URL, github:o/r@ref//path, ./local)").argument("<docref>", "source docref").option("--kind <kind>", "doc kind (guideline|shortcut|template|reference)").option("--name <name>", "doc name (defaults to the source filename)").action(async (docref, options, command) => {
10117
+ await new DocsAddHandler(command).run(docref, options);
10118
+ });
10119
+ docsCommand.command("sync").description("Refresh the gitignored docs cache (.tbd/docs/) from bundled and URL sources").action(async (_options, command) => {
10120
+ await new DocsSyncHandler(command).run();
10121
+ });
10122
+ registerForkSubcommands(docsCommand);
10123
+
10124
+ //#endregion
10125
+ //#region src/cli/commands/closing.ts
10126
+ /**
10127
+ * `tbd closing` - Display the session closing protocol reminder.
9233
10128
  *
9234
10129
  * Shows the close protocol checklist for completing work.
9235
10130
  * Used by the Claude Code PostToolUse hook after git push.
@@ -9447,6 +10342,7 @@ var UninstallHandler = class extends BaseCommand {
9447
10342
  let localBranchExists = false;
9448
10343
  try {
9449
10344
  execSync(`git rev-parse --verify ${syncBranch}`, {
10345
+ env: gitSafeEnv(),
9450
10346
  encoding: "utf-8",
9451
10347
  stdio: [
9452
10348
  "ignore",
@@ -9460,6 +10356,7 @@ var UninstallHandler = class extends BaseCommand {
9460
10356
  let remoteBranchExists = false;
9461
10357
  if (options.removeRemote) try {
9462
10358
  execSync(`git rev-parse --verify ${remote}/${syncBranch}`, {
10359
+ env: gitSafeEnv(),
9463
10360
  encoding: "utf-8",
9464
10361
  stdio: [
9465
10362
  "ignore",
@@ -9488,6 +10385,7 @@ var UninstallHandler = class extends BaseCommand {
9488
10385
  this.output.info("Uninstalling tbd...");
9489
10386
  if (worktreeExists) try {
9490
10387
  execSync(`git worktree remove --force "${worktreePath}"`, {
10388
+ env: gitSafeEnv(),
9491
10389
  encoding: "utf-8",
9492
10390
  stdio: [
9493
10391
  "ignore",
@@ -9509,6 +10407,7 @@ var UninstallHandler = class extends BaseCommand {
9509
10407
  }
9510
10408
  if (legacyWorktreeExists) try {
9511
10409
  execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
10410
+ env: gitSafeEnv(),
9512
10411
  encoding: "utf-8",
9513
10412
  stdio: [
9514
10413
  "ignore",
@@ -9526,6 +10425,7 @@ var UninstallHandler = class extends BaseCommand {
9526
10425
  }
9527
10426
  if (localBranchExists && !options.keepBranch) try {
9528
10427
  execSync(`git branch -D ${syncBranch}`, {
10428
+ env: gitSafeEnv(),
9529
10429
  encoding: "utf-8",
9530
10430
  stdio: [
9531
10431
  "ignore",
@@ -9539,6 +10439,7 @@ var UninstallHandler = class extends BaseCommand {
9539
10439
  }
9540
10440
  if (remoteBranchExists && options.removeRemote) try {
9541
10441
  execSync(`git push ${remote} --delete ${syncBranch}`, {
10442
+ env: gitSafeEnv(),
9542
10443
  encoding: "utf-8",
9543
10444
  stdio: [
9544
10445
  "ignore",
@@ -9624,361 +10525,6 @@ const uninstallCommand = new Command("uninstall").description("Remove tbd from t
9624
10525
  await new UninstallHandler(command).run(options);
9625
10526
  });
9626
10527
 
9627
- //#endregion
9628
- //#region src/file/doc-cache.ts
9629
- /**
9630
- * DocCache - Path-ordered markdown document cache with lookup.
9631
- *
9632
- * Provides document lookups for the `tbd shortcut` command, supporting
9633
- * both exact matching by filename and fuzzy matching against metadata.
9634
- *
9635
- * Also provides auto-sync functionality when docs are stale (per spec).
9636
- *
9637
- * See: docs/project/specs/active/plan-2026-01-22-doc-cache-abstraction.md
9638
- * See: docs/project/specs/active/plan-2026-01-26-configurable-doc-cache-sync.md
9639
- */
9640
- /** Score for exact filename match (with or without .md extension) */
9641
- const SCORE_EXACT_MATCH = 1;
9642
- /** Score when query is a prefix of the filename */
9643
- const SCORE_PREFIX_MATCH = .9;
9644
- /** Score when filename contains all query words */
9645
- const SCORE_CONTAINS_ALL = .8;
9646
- /** Base score for partial word matches (multiplied by matched/total ratio) */
9647
- const SCORE_PARTIAL_BASE = .7;
9648
- /** Minimum score threshold to return a fuzzy match result */
9649
- const SCORE_MIN_THRESHOLD = .5;
9650
- /**
9651
- * Path-ordered markdown document cache.
9652
- *
9653
- * Loads all .md files from configured paths in order, with earlier paths
9654
- * taking precedence (like shell $PATH). Supports exact lookup by filename
9655
- * and fuzzy search across filename + frontmatter metadata.
9656
- */
9657
- var DocCache = class {
9658
- /** Active docs (first occurrence of each name) */
9659
- docs = [];
9660
- /** All docs including shadowed ones */
9661
- allDocs = [];
9662
- /** Track names we've seen for shadow detection */
9663
- seenNames = /* @__PURE__ */ new Set();
9664
- /** Whether the cache has been loaded */
9665
- loaded = false;
9666
- /**
9667
- * Create a new DocCache.
9668
- *
9669
- * @param paths - Ordered array of directory paths to search (relative to baseDir)
9670
- * @param baseDir - Base directory for resolving relative paths (default: cwd)
9671
- */
9672
- constructor(paths, baseDir = process.cwd()) {
9673
- this.paths = paths;
9674
- this.baseDir = baseDir;
9675
- }
9676
- /**
9677
- * Load all documents from configured paths.
9678
- *
9679
- * Reads all .md files from each path in order. Documents with the same
9680
- * name in later paths are shadowed (tracked but not returned by default).
9681
- *
9682
- * If auto-sync is enabled and docs are stale, triggers a sync first.
9683
- *
9684
- * @param options - Load options (quiet: suppress auto-sync output)
9685
- */
9686
- async load(options) {
9687
- if (this.loaded) return;
9688
- await this.checkAutoSync(options?.quiet ?? false);
9689
- for (const relativePath of this.paths) {
9690
- const dirPath = join(this.baseDir, relativePath);
9691
- await this.loadDirectory(dirPath, relativePath);
9692
- }
9693
- this.loaded = true;
9694
- }
9695
- /**
9696
- * Check if docs are stale and auto-sync if needed.
9697
- * Respects the quiet option - only silent when explicitly requested.
9698
- *
9699
- * Uses syncDocsWithDefaults() to ensure auto-sync also picks up new bundled
9700
- * docs from tbd upgrades, not just existing config entries.
9701
- *
9702
- * @param quiet - If true, suppress sync output
9703
- */
9704
- async checkAutoSync(quiet) {
9705
- try {
9706
- const tbdRoot = await findTbdRoot(this.baseDir);
9707
- if (!tbdRoot) return;
9708
- const config = await readConfig(tbdRoot);
9709
- const state = await readLocalState(tbdRoot);
9710
- const autoSyncHours = config.settings?.doc_auto_sync_hours ?? 24;
9711
- if (!isDocsStale(state.last_doc_sync_at, autoSyncHours)) return;
9712
- await syncDocsWithDefaults(tbdRoot, { quiet });
9713
- } catch {}
9714
- }
9715
- /**
9716
- * Load documents from a single directory.
9717
- */
9718
- async loadDirectory(dirPath, sourceDir) {
9719
- let entries;
9720
- try {
9721
- entries = await readdir(dirPath);
9722
- } catch {
9723
- return;
9724
- }
9725
- for (const entry of entries) {
9726
- if (!entry.endsWith(".md")) continue;
9727
- const filePath = join(dirPath, entry);
9728
- const name = basename(entry, ".md");
9729
- try {
9730
- const content = await readFile(filePath, "utf-8");
9731
- const doc = {
9732
- path: filePath,
9733
- name,
9734
- frontmatter: this.parseFrontmatterData(content),
9735
- content,
9736
- sourceDir,
9737
- sizeBytes: Buffer.byteLength(content, "utf-8"),
9738
- approxTokens: estimateTokens(content)
9739
- };
9740
- this.allDocs.push(doc);
9741
- if (!this.seenNames.has(name)) {
9742
- this.docs.push(doc);
9743
- this.seenNames.add(name);
9744
- }
9745
- } catch (error) {
9746
- console.warn(`Failed to load shortcut ${filePath}: ${error.message}`);
9747
- }
9748
- }
9749
- }
9750
- /**
9751
- * Parse YAML frontmatter from content and return typed data.
9752
- * Uses gray-matter for consistent frontmatter parsing.
9753
- */
9754
- parseFrontmatterData(content) {
9755
- if (!matter.test(content)) return;
9756
- try {
9757
- const parsed = matter(content).data;
9758
- return {
9759
- title: typeof parsed.title === "string" ? parsed.title : void 0,
9760
- description: typeof parsed.description === "string" ? parsed.description : void 0,
9761
- category: typeof parsed.category === "string" ? parsed.category : void 0,
9762
- tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === "string") : void 0
9763
- };
9764
- } catch {
9765
- return;
9766
- }
9767
- }
9768
- /**
9769
- * Get a document by exact name match.
9770
- *
9771
- * @param name - Filename to match (with or without .md extension)
9772
- * @returns Match with score SCORE_EXACT_MATCH, or null if not found
9773
- */
9774
- get(name) {
9775
- const lookupName = name.endsWith(".md") ? name.slice(0, -3) : name;
9776
- const doc = this.docs.find((d) => d.name === lookupName);
9777
- if (!doc) return null;
9778
- return {
9779
- doc,
9780
- score: SCORE_EXACT_MATCH
9781
- };
9782
- }
9783
- /**
9784
- * Search for documents matching a query.
9785
- *
9786
- * Performs fuzzy matching against filename, title, and description.
9787
- * Returns matches sorted by score descending.
9788
- *
9789
- * @param query - Search query string
9790
- * @param limit - Maximum number of results (default: 10)
9791
- * @returns Array of matches sorted by score descending
9792
- */
9793
- search(query, limit = 10) {
9794
- const matches = [];
9795
- for (const doc of this.docs) {
9796
- const score = this.calculateScore(doc, query);
9797
- if (score >= SCORE_MIN_THRESHOLD) matches.push({
9798
- doc,
9799
- score
9800
- });
9801
- }
9802
- matches.sort((a, b) => {
9803
- if (b.score !== a.score) return b.score - a.score;
9804
- return a.doc.name.localeCompare(b.doc.name);
9805
- });
9806
- return matches.slice(0, limit);
9807
- }
9808
- /**
9809
- * Calculate relevance score for a document against a query.
9810
- */
9811
- calculateScore(doc, query) {
9812
- const queryLower = query.toLowerCase().trim();
9813
- if (queryLower.length === 0) return 0;
9814
- const nameLower = doc.name.toLowerCase();
9815
- const titleLower = doc.frontmatter?.title?.toLowerCase() ?? "";
9816
- const descLower = doc.frontmatter?.description?.toLowerCase() ?? "";
9817
- if (nameLower === queryLower) return SCORE_EXACT_MATCH;
9818
- if (nameLower.startsWith(queryLower)) return SCORE_PREFIX_MATCH;
9819
- const queryWords = queryLower.split(/\s+/).filter((w) => w.length > 0);
9820
- const searchableText = `${nameLower} ${titleLower} ${descLower}`;
9821
- if (queryWords.every((word) => searchableText.includes(word)) && queryWords.length > 0) return SCORE_CONTAINS_ALL;
9822
- const matchedWords = queryWords.filter((word) => searchableText.includes(word));
9823
- if (matchedWords.length > 0) return SCORE_PARTIAL_BASE * (matchedWords.length / queryWords.length);
9824
- return 0;
9825
- }
9826
- /**
9827
- * List all documents.
9828
- *
9829
- * @param includeAll - If true, include shadowed documents
9830
- * @returns Array of cached documents
9831
- */
9832
- list(includeAll = false) {
9833
- return includeAll ? this.allDocs : this.docs;
9834
- }
9835
- /**
9836
- * Check if a document is shadowed by an earlier path.
9837
- *
9838
- * @param doc - Document to check
9839
- * @returns True if this doc is shadowed (not the first with this name)
9840
- */
9841
- isShadowed(doc) {
9842
- return this.docs.find((d) => d.name === doc.name) !== doc;
9843
- }
9844
- /**
9845
- * Check if the cache has been loaded.
9846
- */
9847
- isLoaded() {
9848
- return this.loaded;
9849
- }
9850
- };
9851
- /**
9852
- * Marker comments for shortcut directory section in skill files.
9853
- * Used for incremental updates without overwriting user content.
9854
- */
9855
- const SHORTCUT_DIRECTORY_BEGIN = "<!-- BEGIN SHORTCUT DIRECTORY -->";
9856
- const SHORTCUT_DIRECTORY_END = "<!-- END SHORTCUT DIRECTORY -->";
9857
- const GUIDELINE_GROUPS = [
9858
- {
9859
- heading: "General engineering",
9860
- note: "Read all of these for any engineering work (writing or reviewing code).",
9861
- match: (n) => n.startsWith("general-") || n === "error-handling-rules" || n === "backward-compatibility-rules" || n === "commit-conventions" || n.includes("tdd") || n.includes("testing") || n.includes("golden")
9862
- },
9863
- {
9864
- heading: "TypeScript & JS ecosystem",
9865
- note: "Also load these when working in TypeScript or JavaScript.",
9866
- match: (n) => n.startsWith("typescript-") || n.endsWith("monorepo-patterns") || n.startsWith("electron-")
9867
- },
9868
- {
9869
- heading: "Python",
9870
- note: "Also load these when working in Python.",
9871
- match: (n) => n.startsWith("python-")
9872
- },
9873
- {
9874
- heading: "Convex",
9875
- note: "Also load these when working with Convex.",
9876
- match: (n) => n.startsWith("convex-")
9877
- },
9878
- {
9879
- heading: "Docs, process & tooling",
9880
- match: () => true
9881
- }
9882
- ];
9883
- /**
9884
- * Build table rows from docs (shared helper for shortcuts and guidelines).
9885
- */
9886
- function buildTableRows(docs, skipNames = []) {
9887
- const sortedDocs = [...docs].sort((a, b) => a.name.localeCompare(b.name));
9888
- const rows = [];
9889
- for (const doc of sortedDocs) {
9890
- if (skipNames.includes(doc.name)) continue;
9891
- const name = doc.name;
9892
- const escapedDescription = (doc.frontmatter?.description ?? "").replace(/\|/g, "\\|");
9893
- rows.push(`| ${name} | ${escapedDescription} |`);
9894
- }
9895
- return rows;
9896
- }
9897
- /**
9898
- * Generate a formatted markdown directory of shortcuts and guidelines.
9899
- *
9900
- * The output includes:
9901
- * 1. Marker comments for incremental updates
9902
- * 2. Available Shortcuts section with name and description
9903
- * 3. Available Guidelines section, grouped by area (General engineering first, then
9904
- * language/framework groups), with name and description (if provided)
9905
- *
9906
- * @param shortcuts - Array of shortcut CachedDoc objects
9907
- * @param guidelines - Optional array of guideline CachedDoc objects
9908
- * @returns Formatted markdown string with shortcuts and guidelines directory
9909
- *
9910
- * @example
9911
- * const directory = generateShortcutDirectory(shortcutDocs, guidelineDocs);
9912
- * // Returns:
9913
- * // <!-- BEGIN SHORTCUT DIRECTORY -->
9914
- * // ## Available Shortcuts
9915
- * // | Name | Description |
9916
- * // | --- | --- |
9917
- * // | code-review-and-commit | Run pre-commit checks, review changes, and commit code |
9918
- * // ...
9919
- * // ## Available Guidelines
9920
- * // ### General engineering
9921
- * // | Name | Description |
9922
- * // | --- | --- |
9923
- * // | error-handling-rules | Rules for handling errors... |
9924
- * // ...
9925
- * // ### TypeScript & JS ecosystem
9926
- * // | typescript-rules | TypeScript coding rules and best practices |
9927
- * // ...
9928
- * // <!-- END SHORTCUT DIRECTORY -->
9929
- */
9930
- function generateShortcutDirectory(shortcuts, guidelines = []) {
9931
- const lines = [SHORTCUT_DIRECTORY_BEGIN];
9932
- const shortcutRows = buildTableRows(shortcuts, [
9933
- "skill-baseline",
9934
- "skill-brief",
9935
- "skill-minimal",
9936
- "shortcut-explanation"
9937
- ]);
9938
- lines.push("## Available Shortcuts");
9939
- lines.push("");
9940
- lines.push("Run `tbd shortcut <name>` to use any of these shortcuts:");
9941
- lines.push("");
9942
- if (shortcutRows.length === 0) lines.push("No shortcuts available. Create shortcuts in `.tbd/docs/shortcuts/standard/`.");
9943
- else {
9944
- lines.push("| Name | Description |");
9945
- lines.push("| --- | --- |");
9946
- lines.push(...shortcutRows);
9947
- }
9948
- if (guidelines.length > 0) {
9949
- const grouped = GUIDELINE_GROUPS.map((group) => ({
9950
- group,
9951
- docs: []
9952
- }));
9953
- const catchAll = grouped[grouped.length - 1];
9954
- for (const doc of guidelines) (grouped.find((g) => g.group.match(doc.name)) ?? catchAll)?.docs.push(doc);
9955
- if (grouped.some((g) => g.docs.length > 0)) {
9956
- lines.push("");
9957
- lines.push("## Available Guidelines");
9958
- lines.push("");
9959
- lines.push("Run `tbd guidelines <name>` to apply any of these guidelines.");
9960
- lines.push("Load the **General engineering** group first, then the language or framework group.");
9961
- for (const { group, docs } of grouped) {
9962
- const rows = buildTableRows(docs);
9963
- if (rows.length === 0) continue;
9964
- lines.push("");
9965
- lines.push(`### ${group.heading}`);
9966
- if (group.note) {
9967
- lines.push("");
9968
- lines.push(`*${group.note}*`);
9969
- }
9970
- lines.push("");
9971
- lines.push("| Name | Description |");
9972
- lines.push("| --- | --- |");
9973
- lines.push(...rows);
9974
- }
9975
- }
9976
- }
9977
- lines.push("");
9978
- lines.push(SHORTCUT_DIRECTORY_END);
9979
- return lines.join("\n");
9980
- }
9981
-
9982
10528
  //#endregion
9983
10529
  //#region src/cli/commands/prime.ts
9984
10530
  /**
@@ -10067,7 +10613,7 @@ tbd is not yet initialized. To set it up, run:
10067
10613
  tbd setup --auto # If .tbd/ already exists (prefix already set)
10068
10614
 
10069
10615
  CRITICAL: Never guess a prefix. Always ask the user what prefix they want.
10070
- Do NOT tell the user to run these commands run them yourself on their behalf.
10616
+ Do NOT tell the user to run these commands; run them yourself on their behalf.
10071
10617
 
10072
10618
  After setup, run 'tbd' again to get project status and workflow guidance.`;
10073
10619
  var PrimeHandler = class extends BaseCommand {
@@ -10135,7 +10681,7 @@ var PrimeHandler = class extends BaseCommand {
10135
10681
  const shortcutDir = await this.getShortcutDirectory(tbdRoot);
10136
10682
  let markdownContent = primeContent;
10137
10683
  if (shortcutDir) markdownContent += "\n\n" + shortcutDir;
10138
- markdownContent += "\n\nIMPORTANT: Use tbd to help the user do NOT tell the user to run tbd commands.\nWhen the user asks for help or orientation, run `tbd shortcut welcome-user`.";
10684
+ markdownContent += "\n\nIMPORTANT: Use tbd to help the user; do NOT tell the user to run tbd commands.\nWhen the user asks for help or orientation, run `tbd shortcut welcome-user`.";
10139
10685
  if (shouldUseInteractiveOutput(this.ctx)) await paginateOutput(renderMarkdown(markdownContent, this.ctx.color), true);
10140
10686
  else console.log(markdownContent);
10141
10687
  }
@@ -10172,7 +10718,7 @@ var PrimeHandler = class extends BaseCommand {
10172
10718
  console.log("1. Welcome the user! Run `tbd shortcut welcome-user` and follow its");
10173
10719
  console.log(" instructions to give the user a warm orientation.");
10174
10720
  console.log("2. Show the user examples of what THEY can say (e.g. \"there's a bug\",");
10175
- console.log(" \"let's plan a feature\") do NOT show them tbd CLI commands.");
10721
+ console.log(" \"let's plan a feature\"); do NOT show them tbd CLI commands.");
10176
10722
  console.log("3. You use tbd on the user's behalf. The user talks to you naturally;");
10177
10723
  console.log(" you translate their requests into tbd actions.");
10178
10724
  console.log("");
@@ -10335,80 +10881,6 @@ const SHORTCUT_AGENT_HEADER = "Agent instructions: You have activated a shortcut
10335
10881
  */
10336
10882
  const GUIDELINES_AGENT_HEADER = "Agent instructions: You have activated a guidelines document. If a user has asked you to apply these rules, read them carefully and apply them. Use beads to track each step.";
10337
10883
 
10338
- //#endregion
10339
- //#region src/file/doc-add.ts
10340
- /**
10341
- * Add external documentation to the tbd doc cache.
10342
- *
10343
- * Uses the shared github-fetch utility for URL conversion and fetching.
10344
- * Handles doc-specific concerns: content validation, file writing, and
10345
- * atomic config updates.
10346
- */
10347
- /**
10348
- * Validate that fetched content looks like a reasonable markdown document.
10349
- *
10350
- * @throws If content fails sanity checks
10351
- */
10352
- function validateDocContent(content, name) {
10353
- if (!content || content.trim().length === 0) throw new Error(`Fetched content for "${name}" is empty`);
10354
- if (content.length < 10) throw new Error(`Fetched content for "${name}" is too short (${content.length} chars)`);
10355
- if (content.trimStart().startsWith("<!DOCTYPE") || content.trimStart().startsWith("<html")) throw new Error(`Fetched content for "${name}" appears to be an HTML page, not a markdown document`);
10356
- if (content.split("").filter((c) => {
10357
- const code = c.charCodeAt(0);
10358
- return code < 32 && code !== 9 && code !== 10 && code !== 13;
10359
- }).length / content.length > .1) throw new Error(`Fetched content for "${name}" appears to be binary, not a text document`);
10360
- }
10361
- /**
10362
- * Get the destination subdirectory for a doc type.
10363
- */
10364
- function getDocTypeSubdir(docType) {
10365
- switch (docType) {
10366
- case "guideline": return "guidelines";
10367
- case "shortcut": return "shortcuts/custom";
10368
- case "template": return "templates";
10369
- }
10370
- }
10371
- /**
10372
- * Add an external document to the tbd doc cache.
10373
- *
10374
- * This function:
10375
- * 1. Converts GitHub blob URLs to raw URLs
10376
- * 2. Fetches the content (with gh CLI fallback on 403)
10377
- * 3. Validates the content looks like markdown
10378
- * 4. Writes the file to .tbd/docs/{type}/{name}.md
10379
- * 5. Atomically updates the config to include the new source
10380
- *
10381
- * @throws If fetching, validation, or config update fails
10382
- */
10383
- async function addDoc(tbdRoot, options) {
10384
- const { url, name, docType } = options;
10385
- const cleanName = name.endsWith(".md") ? name.slice(0, -3) : name;
10386
- const filename = `${cleanName}.md`;
10387
- const subdir = getDocTypeSubdir(docType);
10388
- const destPath = `${subdir}/${filename}`;
10389
- const rawUrl = githubBlobToRawUrl(url);
10390
- const { content, usedGhCli } = await fetchWithGhFallback(url);
10391
- validateDocContent(content, cleanName);
10392
- const fullPath = join(tbdRoot, TBD_DOCS_DIR, destPath);
10393
- await mkdir(dirname(fullPath), { recursive: true });
10394
- await writeFile(fullPath, content);
10395
- const config = await readConfig(tbdRoot);
10396
- config.docs_cache ??= {
10397
- files: {},
10398
- lookup_path: []
10399
- };
10400
- config.docs_cache.files ??= {};
10401
- config.docs_cache.files[destPath] = rawUrl;
10402
- const lookupDir = `.tbd/docs/${subdir}`;
10403
- if (!config.docs_cache.lookup_path.includes(lookupDir)) config.docs_cache.lookup_path.push(lookupDir);
10404
- await writeConfig(tbdRoot, config);
10405
- return {
10406
- destPath,
10407
- rawUrl,
10408
- usedGhCli
10409
- };
10410
- }
10411
-
10412
10884
  //#endregion
10413
10885
  //#region src/cli/commands/shortcut.ts
10414
10886
  /**
@@ -10440,7 +10912,7 @@ var ShortcutHandler = class extends BaseCommand {
10440
10912
  return;
10441
10913
  }
10442
10914
  const tbdRoot = await requireInit();
10443
- const cache = new DocCache((await readConfig(tbdRoot)).docs_cache?.lookup_path ?? DEFAULT_SHORTCUT_PATHS, tbdRoot);
10915
+ const cache = new DocCache([FORK_SHORTCUTS_DIR, ...((await readConfig(tbdRoot)).docs_cache?.lookup_path ?? CACHE_SHORTCUT_PATHS).filter((p) => p !== FORK_SHORTCUTS_DIR)], tbdRoot);
10444
10916
  await cache.load({ quiet: this.ctx.quiet });
10445
10917
  if (options.refresh) {
10446
10918
  await this.handleRefresh(cache, tbdRoot, options.quiet);
@@ -10664,7 +11136,8 @@ var DocCommandHandler = class extends BaseCommand {
10664
11136
  */
10665
11137
  async initCache() {
10666
11138
  this.tbdRoot = await requireInit();
10667
- this.cache = new DocCache(this.config.paths, this.tbdRoot);
11139
+ const { localDirs } = await loadServeContext(this.tbdRoot);
11140
+ this.cache = new DocCache(localDirs.length > 0 ? effectiveServePaths(this.config.docType, localDirs) : this.config.paths, this.tbdRoot);
10668
11141
  await this.cache.load({ quiet: this.ctx.quiet });
10669
11142
  }
10670
11143
  /**
@@ -10674,16 +11147,7 @@ var DocCommandHandler = class extends BaseCommand {
10674
11147
  if (!this.cache) throw new Error("Cache not initialized");
10675
11148
  const docs = this.cache.list(includeAll);
10676
11149
  if (this.ctx.json) {
10677
- this.output.data(docs.map((d) => ({
10678
- name: d.name,
10679
- title: d.frontmatter?.title,
10680
- description: d.frontmatter?.description,
10681
- path: d.path,
10682
- sourceDir: d.sourceDir,
10683
- sizeBytes: d.sizeBytes,
10684
- approxTokens: d.approxTokens,
10685
- shadowed: this.cache.isShadowed(d)
10686
- })));
11150
+ this.output.data(createDocMap(await this.docMapEntries(docs), { name: "tbd-docs" }));
10687
11151
  return;
10688
11152
  }
10689
11153
  if (docs.length === 0) {
@@ -10710,6 +11174,45 @@ var DocCommandHandler = class extends BaseCommand {
10710
11174
  }
10711
11175
  }
10712
11176
  /**
11177
+ * Build docmap entries for this kind's docs through the shared constructor,
11178
+ * with per-kind extensions (size metrics; shadowed flag when applicable).
11179
+ */
11180
+ async docMapEntries(docs) {
11181
+ const { manifest, files, localDirs } = await loadServeContext(this.tbdRoot);
11182
+ return docs.map((d) => {
11183
+ const { entry } = servedEntryFor(this.tbdRoot, this.config.docType, d, manifest, files, localDirs);
11184
+ return {
11185
+ ...entry,
11186
+ sizeBytes: d.sizeBytes,
11187
+ approxTokens: d.approxTokens,
11188
+ ...this.cache.isShadowed(d) ? { shadowed: true } : {}
11189
+ };
11190
+ });
11191
+ }
11192
+ /**
11193
+ * Emit one doc: docmap entry + content in JSON mode (the one-entry read
11194
+ * shape, spec Decision 22); in text mode, a forked-copy provenance note on
11195
+ * stderr (Decision 18) before the content.
11196
+ */
11197
+ async emitDoc(doc, score) {
11198
+ if (this.ctx.json) {
11199
+ const [entry] = await this.docMapEntries([doc]);
11200
+ this.output.data({
11201
+ ...entry,
11202
+ ...score !== void 0 ? { score } : {},
11203
+ content: doc.content
11204
+ });
11205
+ return;
11206
+ }
11207
+ const { localDirs } = await loadServeContext(this.tbdRoot);
11208
+ if (!this.ctx.quiet) {
11209
+ const rel = relative(this.tbdRoot, doc.path).split("\\").join("/");
11210
+ if (doc.sourceDir.startsWith(FORK_DIR)) process.stderr.write(`(serving forked copy: ${rel})\n`);
11211
+ else if (localDirs.includes(doc.sourceDir)) process.stderr.write(`(serving local doc: ${rel})\n`);
11212
+ }
11213
+ await this.outputDocContent(doc.content);
11214
+ }
11215
+ /**
10713
11216
  * Handle no query: show explanation + help.
10714
11217
  */
10715
11218
  async handleNoQuery() {
@@ -10746,13 +11249,7 @@ var DocCommandHandler = class extends BaseCommand {
10746
11249
  if (!this.cache) throw new Error("Cache not initialized");
10747
11250
  const exactMatch = this.cache.get(query);
10748
11251
  if (exactMatch) {
10749
- if (this.ctx.json) this.output.data({
10750
- name: exactMatch.doc.name,
10751
- title: exactMatch.doc.frontmatter?.title,
10752
- score: exactMatch.score,
10753
- content: exactMatch.doc.content
10754
- });
10755
- else await this.outputDocContent(exactMatch.doc.content);
11252
+ await this.emitDoc(exactMatch.doc, exactMatch.score);
10756
11253
  return;
10757
11254
  }
10758
11255
  const matches = this.cache.search(query, 5);
@@ -10770,13 +11267,7 @@ var DocCommandHandler = class extends BaseCommand {
10770
11267
  }
10771
11268
  return;
10772
11269
  }
10773
- if (this.ctx.json) this.output.data({
10774
- name: best.doc.name,
10775
- title: best.doc.frontmatter?.title,
10776
- score: best.score,
10777
- content: best.doc.content
10778
- });
10779
- else await this.outputDocContent(best.doc.content);
11270
+ await this.emitDoc(best.doc, best.score);
10780
11271
  }
10781
11272
  /**
10782
11273
  * Output document content with interactive formatting (colors, pagination) when appropriate.
@@ -10811,7 +11302,7 @@ var DocCommandHandler = class extends BaseCommand {
10811
11302
  });
10812
11303
  if (result.usedGhCli) console.log(pc.dim(" (fetched via gh CLI due to direct access restriction)"));
10813
11304
  console.log(pc.green(` Added to ${result.destPath}`));
10814
- console.log(pc.green(` Config updated with source: ${result.rawUrl}`));
11305
+ console.log(pc.green(` Config updated with source: ${result.source}`));
10815
11306
  console.log("");
10816
11307
  console.log(`Run \`tbd ${this.config.typeNamePlural} --list\` to verify.`);
10817
11308
  }
@@ -10880,15 +11371,6 @@ var DocCommandHandler = class extends BaseCommand {
10880
11371
  * Guidelines are reusable coding rules and best practices documents.
10881
11372
  * Give a name or description and tbd will find the matching guideline.
10882
11373
  */
10883
- /**
10884
- * Infer category from guideline name.
10885
- */
10886
- function inferGuidelineCategory(name) {
10887
- if (name.startsWith("typescript-")) return "typescript";
10888
- if (name.startsWith("python-")) return "python";
10889
- if (name.includes("tdd") || name.includes("testing") || name.includes("golden")) return "testing";
10890
- if (name.startsWith("general-") || name.includes("rules") || name.includes("patterns") || name.startsWith("backward-") || name.startsWith("convex-") || name.startsWith("release-") || name.startsWith("writing-")) return "general";
10891
- }
10892
11374
  var GuidelinesHandler = class extends DocCommandHandler {
10893
11375
  constructor(command) {
10894
11376
  super(command, {
@@ -10923,27 +11405,24 @@ var GuidelinesHandler = class extends DocCommandHandler {
10923
11405
  async handleListWithCategory(includeAll, category) {
10924
11406
  if (!this.cache) throw new Error("Cache not initialized");
10925
11407
  let docs = this.cache.list(includeAll);
10926
- if (category) docs = docs.filter((d) => {
10927
- return inferGuidelineCategory(d.name) === category;
10928
- });
11408
+ if (category) {
11409
+ const wanted = parseCategoryOption(category);
11410
+ docs = docs.filter((d) => docCategory(d.frontmatter) === wanted);
11411
+ }
10929
11412
  if (this.ctx.json) {
10930
- this.output.data(docs.map((d) => ({
10931
- name: d.name,
10932
- title: d.frontmatter?.title,
10933
- description: d.frontmatter?.description,
10934
- category: inferGuidelineCategory(d.name),
10935
- path: d.path,
10936
- sourceDir: d.sourceDir,
10937
- sizeBytes: d.sizeBytes,
10938
- approxTokens: d.approxTokens,
10939
- shadowed: this.cache.isShadowed(d)
10940
- })));
11413
+ const entries = await this.docMapEntries(docs);
11414
+ const docByName = new Map(docs.map((d) => [d.name, d]));
11415
+ const withCategory = entries.map((e) => ({
11416
+ ...e,
11417
+ category: docCategory(docByName.get(e.name)?.frontmatter)
11418
+ }));
11419
+ this.output.data(createDocMap(withCategory, { name: "tbd-docs" }));
10941
11420
  return;
10942
11421
  }
10943
11422
  if (docs.length === 0) {
10944
11423
  if (category) {
10945
11424
  console.log(`No guidelines found in category: ${category}`);
10946
- console.log("Valid categories: typescript, python, testing, general");
11425
+ console.log(`Valid categories: ${DOC_CATEGORIES.join(", ")}`);
10947
11426
  } else {
10948
11427
  console.log("No guidelines found.");
10949
11428
  console.log("Run `tbd setup --auto` to install built-in guidelines.");
@@ -11020,12 +11499,11 @@ const templateCommand = new Command("template").description("Find and output doc
11020
11499
  * `tbd setup` - Configure tbd integration with editors and tools.
11021
11500
  *
11022
11501
  * Requires a git repository. All setup artifacts (.tbd/, .claude/) are placed
11023
- * at the git root, adjacent to .git/. Installation is always project-local
11502
+ * at the git root, adjacent to .git/. Installation is always project-local;
11024
11503
  * there is no global/user-level install.
11025
11504
  *
11026
11505
  * Options:
11027
11506
  * - `tbd setup --auto` - Non-interactive setup (for agents/scripts)
11028
- * - `tbd setup --interactive` - Interactive setup with prompts (for humans)
11029
11507
  * - `tbd setup --from-beads` - Migrate from Beads to tbd
11030
11508
  *
11031
11509
  * See: tbd-design.md §6.4.2 Claude Code Integration
@@ -11090,7 +11568,7 @@ async function writeSkillFile(targetPath, payload) {
11090
11568
  }
11091
11569
  /**
11092
11570
  * AGENTS.md managed-block markers. `CODEX_BEGIN_MARKER` is the stable PREFIX of
11093
- * the begin line the metadata (`format=fNN surface=agents-md`) follows it on the
11571
+ * the begin line; the metadata (`format=fNN surface=agents-md`) follows it on the
11094
11572
  * same line, e.g. `<!-- BEGIN TBD INTEGRATION format=f02 surface=agents-md -->`.
11095
11573
  * Cleanup/upgrade code matches on this prefix so it finds both legacy
11096
11574
  * (`<!-- BEGIN TBD INTEGRATION -->`) and current marked blocks.
@@ -11116,7 +11594,7 @@ function parseIntegrationFormat(content) {
11116
11594
  /**
11117
11595
  * Forward-compatibility guard. If a generated artifact was written by a NEWER
11118
11596
  * tbd than this one understands, refuse to rewrite it and tell the user to
11119
- * upgrade tbd overwriting would downgrade a newer managed format. This is what
11597
+ * upgrade tbd; overwriting would downgrade a newer managed format. This is what
11120
11598
  * makes pinning safe on a team: an older tbd fails loudly instead of clobbering.
11121
11599
  */
11122
11600
  function assertNotNewerFormat(content, artifact) {
@@ -11137,7 +11615,7 @@ function getCodexTbdSection() {
11137
11615
 
11138
11616
  This repository uses **tbd** for git-native issue tracking (beads), spec-driven
11139
11617
  planning, and on-demand engineering guidelines.
11140
- As the agent, you operate tbd on the user’s behalf translate their requests into tbd
11618
+ As the agent, you operate tbd on the user’s behalf: translate their requests into tbd
11141
11619
  actions rather than telling them to run commands.
11142
11620
 
11143
11621
  - Run \`tbd prime\` to load current project state and the full tbd workflow.
@@ -11866,11 +12344,10 @@ var SetupCodexHandler = class extends BaseCommand {
11866
12344
  }
11867
12345
  };
11868
12346
  /**
11869
- * Default handler for `tbd setup` with --auto or --interactive flags.
12347
+ * Default handler for `tbd setup --auto`.
11870
12348
  *
11871
12349
  * This implements the unified onboarding flow:
11872
12350
  * - `tbd setup --auto`: Non-interactive setup with smart defaults (for agents)
11873
- * - `tbd setup --interactive`: Interactive setup with prompts (for humans)
11874
12351
  *
11875
12352
  * Decision tree:
11876
12353
  * 1. Not in git repo → Error (git init first)
@@ -11908,14 +12385,16 @@ var SetupDefaultHandler = class extends BaseCommand {
11908
12385
  config.settings.use_gh_cli = false;
11909
12386
  ghCliChanged = true;
11910
12387
  }
12388
+ const stamped = stampSetupVersion(config, VERSION);
12389
+ const versionStamped = stamped !== config;
11911
12390
  if (migrated) {
11912
12391
  await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
11913
12392
  console.log(` ${colors.success("✓")} Config migrated to latest format`);
11914
12393
  for (const change of changes) console.log(` ${colors.dim(change)}`);
11915
12394
  }
11916
- if (ghCliChanged) {
11917
- await writeConfig(projectDir, config);
11918
- console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
12395
+ if (versionStamped || ghCliChanged) {
12396
+ await writeConfig(projectDir, stamped);
12397
+ if (ghCliChanged) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
11919
12398
  }
11920
12399
  console.log("");
11921
12400
  await this.handleAlreadyInitialized(projectDir, isAutoMode);
@@ -12124,7 +12603,7 @@ Example:
12124
12603
  * Agent integration surfaces that `tbd setup` can install, in install/report
12125
12604
  * order. Each is selectable via `--surfaces=<comma-list>`; with the flag omitted,
12126
12605
  * all are installed. Adding an agent is one entry here plus a case in
12127
- * `installSurface` there is no per-agent flag to add.
12606
+ * `installSurface`; there is no per-agent flag to add.
12128
12607
  */
12129
12608
  const SETUP_SURFACE_IDS = [
12130
12609
  "portable",
@@ -12257,6 +12736,44 @@ var SetupAutoHandler = class extends BaseCommand {
12257
12736
  for (const name of skippedSurfaces) console.log(` ${colors.dim("-")} ${name}`);
12258
12737
  }
12259
12738
  if (failed.length > 0) for (const r of failed) console.log(colors.warn(` ! ${r.name}: ${r.error}`));
12739
+ await this.printDocsSummary(cwd);
12740
+ }
12741
+ /**
12742
+ * Print the Docs summary after the integration sections: how many docs tbd
12743
+ * serves from the gitignored cache, or, when docs are forked into the repo,
12744
+ * the fork count plus any pending upstream updates. Reporting only: setup
12745
+ * never writes the fork dir (only `tbd docs update` modifies forked docs).
12746
+ *
12747
+ * Zero-fork wording is shared with the bare `tbd docs` overview via
12748
+ * {@link docsPostureMenuLines} so the two menus cannot drift.
12749
+ */
12750
+ async printDocsSummary(cwd) {
12751
+ if (this.ctx.quiet || this.ctx.json) return;
12752
+ let lines;
12753
+ try {
12754
+ const drift = await computeForkDriftSummary(cwd, FORK_DIR, await readForkManifest(cwd));
12755
+ if (drift.forks > 0) {
12756
+ const updates = drift.stale > 0 ? ` ${drift.stale} have upstream updates; run 'tbd docs update'.` : "";
12757
+ lines = [`Docs: ${drift.forks} forked into ${FORK_DIR}/.${updates}`];
12758
+ } else {
12759
+ let total = 0;
12760
+ for (const paths of [
12761
+ DEFAULT_GUIDELINES_PATHS,
12762
+ DEFAULT_SHORTCUT_PATHS,
12763
+ DEFAULT_TEMPLATE_PATHS,
12764
+ DEFAULT_REFERENCE_PATHS
12765
+ ]) {
12766
+ const cache = new DocCache(paths, cwd);
12767
+ await cache.load({ quiet: true });
12768
+ total += cache.list().length;
12769
+ }
12770
+ lines = [`Docs: ${total} docs available in the cache (.tbd/docs/, gitignored); none forked into the repo.`, ...docsPostureMenuLines().map((line) => ` ${line}`)];
12771
+ }
12772
+ } catch {
12773
+ return;
12774
+ }
12775
+ console.log("");
12776
+ for (const line of lines) console.log(line);
12260
12777
  }
12261
12778
  /**
12262
12779
  * Sync docs using syncDocsWithDefaults.
@@ -12407,8 +12924,8 @@ var SetupAutoHandler = class extends BaseCommand {
12407
12924
  return result;
12408
12925
  }
12409
12926
  };
12410
- const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--surfaces <list>", "Comma-separated agent surfaces to install: portable, agents-md, claude, codex (or \"all\"). Default: all").action(async (options, command) => {
12411
- if (options.auto || options.interactive) {
12927
+ const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--surfaces <list>", "Comma-separated agent surfaces to install: portable, agents-md, claude, codex (or \"all\"). Default: all").action(async (options, command) => {
12928
+ if (options.auto) {
12412
12929
  await new SetupDefaultHandler(command).run(options);
12413
12930
  return;
12414
12931
  }
@@ -12427,7 +12944,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
12427
12944
  console.log("");
12428
12945
  console.log("Modes (one required):");
12429
12946
  console.log(" --auto Non-interactive mode with smart defaults (for agents/scripts)");
12430
- console.log(" --interactive Interactive mode with prompts (for humans)");
12431
12947
  console.log(" --from-beads Migrate from Beads to tbd (implies --auto)");
12432
12948
  console.log("");
12433
12949
  console.log("Options:");
@@ -12439,7 +12955,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
12439
12955
  console.log("Examples:");
12440
12956
  console.log(" tbd setup --auto --prefix=tbd # Full automatic setup with prefix");
12441
12957
  console.log(" tbd setup --from-beads # Migrate from Beads (uses beads prefix)");
12442
- console.log(" tbd setup --interactive # Interactive setup with prompts");
12443
12958
  console.log("");
12444
12959
  console.log("For surgical initialization without integrations, see: tbd init --help");
12445
12960
  });