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.
- package/README.md +38 -6
- package/dist/bin.mjs +3494 -949
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +1827 -1312
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-1ouUTKQr.mjs → config-B1w3pAkY.mjs} +142 -290
- package/dist/config-B1w3pAkY.mjs.map +1 -0
- package/dist/config-DXhifxOw.mjs +3 -0
- package/dist/doc-cache-CamtXfi4.mjs +1035 -0
- package/dist/doc-cache-CamtXfi4.mjs.map +1 -0
- package/dist/doc-cache-CiBwpDTf.mjs +3 -0
- package/dist/doc-fork-BMjqzfAs.mjs +3 -0
- package/dist/doc-fork-CoPi1G1N.mjs +304 -0
- package/dist/doc-fork-CoPi1G1N.mjs.map +1 -0
- package/dist/docref-C7g0sjvL.mjs +167 -0
- package/dist/docref-C7g0sjvL.mjs.map +1 -0
- package/dist/docs/README.md +38 -6
- package/dist/docs/SKILL.md +13 -4
- package/dist/docs/guidelines/backward-compatibility-rules.md +1 -0
- package/dist/docs/guidelines/bun-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +124 -38
- package/dist/docs/guidelines/commit-conventions.md +1 -0
- package/dist/docs/guidelines/common-doc-guidelines.md +1 -0
- package/dist/docs/guidelines/convex-limits-best-practices.md +1 -0
- package/dist/docs/guidelines/convex-rules.md +1 -0
- package/dist/docs/guidelines/electron-app-development-patterns.md +1 -0
- package/dist/docs/guidelines/error-handling-rules.md +1 -0
- package/dist/docs/guidelines/general-coding-rules.md +1 -0
- package/dist/docs/guidelines/general-comment-rules.md +1 -0
- package/dist/docs/guidelines/general-eng-agent-principles.md +1 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +1 -0
- package/dist/docs/guidelines/general-testing-rules.md +1 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +1 -0
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +1 -0
- package/dist/docs/guidelines/python-cli-patterns.md +1 -0
- package/dist/docs/guidelines/python-modern-guidelines.md +1 -0
- package/dist/docs/guidelines/python-rules.md +1 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +1 -0
- package/dist/docs/guidelines/supply-chain-hardening.md +3 -2
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +1 -0
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +1 -0
- package/dist/docs/guidelines/typescript-code-coverage.md +1 -0
- package/dist/docs/guidelines/typescript-rules.md +1 -0
- package/dist/docs/guidelines/typescript-sorting-patterns.md +1 -0
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +1 -0
- package/dist/docs/references/docmap-format.md +64 -0
- package/dist/docs/references/docref-format.md +71 -0
- package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +17 -4
- package/dist/docs/shortcuts/standard/new-shortcut.md +5 -5
- package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +1 -1
- package/dist/docs/shortcuts/standard/suggest-upstream-improvements.md +69 -0
- package/dist/docs/shortcuts/standard/sync-failure-recovery.md +1 -1
- package/dist/docs/shortcuts/standard/welcome-user.md +28 -0
- package/dist/docs/shortcuts/system/skill-baseline.md +13 -4
- package/dist/docs/tbd-design.md +141 -0
- package/dist/docs/tbd-docs.md +169 -5
- package/dist/docs/tbd-prime.md +0 -1
- package/dist/docs/templates/qa-playbook.md +3 -1
- package/dist/docs/templates/research-brief.md +2 -2
- package/dist/fork-manifest-ByU7U2do.mjs +253 -0
- package/dist/fork-manifest-ByU7U2do.mjs.map +1 -0
- package/dist/fork-manifest-C7lGRq-6.mjs +3 -0
- package/dist/{id-mapping-mtoSP9Qt.mjs → id-mapping-D0iitY-F.mjs} +1 -1
- package/dist/{id-mapping-687_UEsy.mjs → id-mapping-DAIeLKzm.mjs} +8 -200
- package/dist/id-mapping-DAIeLKzm.mjs.map +1 -0
- package/dist/index.d.mts +90 -13
- package/dist/index.mjs +3 -3
- package/dist/lockfile-BR0laSDy.mjs +198 -0
- package/dist/lockfile-BR0laSDy.mjs.map +1 -0
- package/dist/paths-C1DpnZJW.mjs +405 -0
- package/dist/paths-C1DpnZJW.mjs.map +1 -0
- package/dist/{schemas-f0EcuAVu.mjs → schemas-lCwRk30L.mjs} +19 -6
- package/dist/schemas-lCwRk30L.mjs.map +1 -0
- package/dist/{src-DTyyuaG_.mjs → src-CxKOynr1.mjs} +3 -3
- package/dist/src-CxKOynr1.mjs.map +1 -0
- package/dist/tbd +3494 -949
- package/dist/yaml-utils-BPy991by.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/config-1ouUTKQr.mjs.map +0 -1
- package/dist/config-YRRW9l89.mjs +0 -3
- package/dist/id-mapping-687_UEsy.mjs.map +0 -1
- package/dist/schemas-f0EcuAVu.mjs.map +0 -1
- 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-
|
|
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-
|
|
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 {
|
|
5
|
-
import { C as
|
|
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
|
|
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.
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2421
|
-
* the lock
|
|
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}
|
|
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
|
-
|
|
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"
|
|
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}
|
|
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.
|
|
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.
|
|
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
|
|
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/
|
|
5689
|
+
//#region src/cli/commands/search.ts
|
|
6197
5690
|
/**
|
|
6198
|
-
*
|
|
5691
|
+
* `tbd search` - Search issues.
|
|
6199
5692
|
*
|
|
6200
|
-
*
|
|
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
|
|
6560
|
-
* schema OR a generated agent surface
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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)
|
|
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)
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
7228
|
+
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-D0iitY-F.mjs");
|
|
7762
7229
|
let historicalMapping;
|
|
7763
7230
|
try {
|
|
7764
|
-
const syncBranch = (await import("./config-
|
|
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
|
|
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
|
|
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/
|
|
8779
|
+
//#region src/file/fork-update.ts
|
|
9083
8780
|
/**
|
|
9084
|
-
*
|
|
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
|
-
*
|
|
9090
|
-
*
|
|
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
|
-
*
|
|
9094
|
-
*
|
|
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
|
|
9097
|
-
|
|
9098
|
-
|
|
9099
|
-
|
|
9100
|
-
|
|
9101
|
-
|
|
9102
|
-
|
|
9103
|
-
|
|
9104
|
-
|
|
9105
|
-
|
|
9106
|
-
|
|
9107
|
-
|
|
9108
|
-
|
|
9109
|
-
|
|
9110
|
-
|
|
9111
|
-
|
|
9112
|
-
|
|
9113
|
-
|
|
9114
|
-
|
|
9115
|
-
|
|
9116
|
-
|
|
9117
|
-
|
|
9118
|
-
|
|
9119
|
-
|
|
9120
|
-
|
|
9121
|
-
|
|
9122
|
-
|
|
9123
|
-
|
|
9124
|
-
|
|
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
|
-
|
|
9127
|
-
|
|
8837
|
+
resolve({
|
|
8838
|
+
merged: stdout,
|
|
8839
|
+
conflicts: 0
|
|
8840
|
+
});
|
|
9128
8841
|
});
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
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
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
|
|
9152
|
-
|
|
9153
|
-
|
|
9154
|
-
|
|
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
|
-
|
|
8883
|
+
});
|
|
8884
|
+
} finally {
|
|
8885
|
+
await rm(dir, {
|
|
8886
|
+
recursive: true,
|
|
8887
|
+
force: true
|
|
8888
|
+
});
|
|
9158
8889
|
}
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
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
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
9199
|
-
|
|
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
|
-
|
|
9226
|
-
|
|
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/
|
|
9231
|
-
/**
|
|
9232
|
-
*
|
|
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
|
|
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
|
|
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\")
|
|
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 ??
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
10927
|
-
|
|
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.
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
category:
|
|
10935
|
-
|
|
10936
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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("--
|
|
12411
|
-
if (options.auto
|
|
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
|
});
|