get-tbd 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +538 -162
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +479 -153
- package/dist/cli.mjs.map +1 -1
- package/dist/docs/SKILL.md +2 -2
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +82 -0
- package/dist/docs/shortcuts/system/skill-baseline.md +2 -2
- package/dist/{id-mapping-CtfTfGIh.mjs → id-mapping-687_UEsy.mjs} +66 -16
- package/dist/id-mapping-687_UEsy.mjs.map +1 -0
- package/dist/{id-mapping-CFoPVinz.mjs → id-mapping-mtoSP9Qt.mjs} +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-rIE4xSVs.mjs → src-CtZIHxYM.mjs} +2 -2
- package/dist/{src-rIE4xSVs.mjs.map → src-CtZIHxYM.mjs.map} +1 -1
- package/dist/tbd +538 -162
- package/package.json +1 -1
- package/dist/id-mapping-CtfTfGIh.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
|
|
2
|
-
import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-
|
|
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-CtZIHxYM.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
4
|
import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveDataSyncDir, I as resolveSharedTbdPaths, M as getWorkspaceDir, N as isValidWorkspaceName, O as TBD_SHORTCUTS_SYSTEM, P as resolveAtticDir, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-BJz1m9eN.mjs";
|
|
5
|
-
import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-
|
|
5
|
+
import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-687_UEsy.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError } from "zod";
|
|
8
8
|
import matter from "gray-matter";
|
|
@@ -620,6 +620,18 @@ var SyncError = class extends CLIError {
|
|
|
620
620
|
}
|
|
621
621
|
};
|
|
622
622
|
/**
|
|
623
|
+
* Unrelated-history error - the local and remote tbd-sync share no common
|
|
624
|
+
* ancestor, so a push can never fast-forward and a plain git merge refuses.
|
|
625
|
+
* `tbd sync` cannot resolve this; the rescue lives in `tbd doctor --fix`.
|
|
626
|
+
* See: plan-2026-05-29-tbd-sync-unrelated-history-hardening.md
|
|
627
|
+
*/
|
|
628
|
+
var UnrelatedHistoriesError = class extends SyncError {
|
|
629
|
+
constructor(remote = "origin", syncBranch = "tbd-sync") {
|
|
630
|
+
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).`);
|
|
631
|
+
this.name = "UnrelatedHistoriesError";
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
/**
|
|
623
635
|
* Classify a sync error to determine appropriate recovery action.
|
|
624
636
|
*
|
|
625
637
|
* Used by `tbd sync` to decide whether to:
|
|
@@ -917,6 +929,124 @@ function nowFilenameTimestamp() {
|
|
|
917
929
|
return (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
|
|
918
930
|
}
|
|
919
931
|
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/utils/zod-error-utils.ts
|
|
934
|
+
/**
|
|
935
|
+
* Helpers for rendering Zod errors without relying on object inspection.
|
|
936
|
+
*/
|
|
937
|
+
/**
|
|
938
|
+
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
939
|
+
*/
|
|
940
|
+
function formatZodError(error) {
|
|
941
|
+
const messages = error.issues.map((issue) => {
|
|
942
|
+
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
943
|
+
});
|
|
944
|
+
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
948
|
+
*/
|
|
949
|
+
function formatUnknownError(error) {
|
|
950
|
+
if (error instanceof ZodError) return formatZodError(error);
|
|
951
|
+
if (error instanceof Error) return error.message;
|
|
952
|
+
return String(error);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
//#endregion
|
|
956
|
+
//#region src/file/storage.ts
|
|
957
|
+
/**
|
|
958
|
+
* Storage layer for issue files.
|
|
959
|
+
*
|
|
960
|
+
* Provides atomic file operations and issue CRUD operations.
|
|
961
|
+
* All operations work on the data-sync directory selected by the caller. In production
|
|
962
|
+
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
963
|
+
*
|
|
964
|
+
* See: tbd-design.md §3.2 Storage Layer
|
|
965
|
+
*/
|
|
966
|
+
/**
|
|
967
|
+
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
968
|
+
*/
|
|
969
|
+
const ISSUE_READ_BATCH_SIZE = 200;
|
|
970
|
+
/**
|
|
971
|
+
* Get the path to an issue file.
|
|
972
|
+
*/
|
|
973
|
+
function getIssuePath(baseDir, id) {
|
|
974
|
+
return join(baseDir, "issues", `${id}.md`);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Read an issue from the worktree.
|
|
978
|
+
* @throws If the issue file doesn't exist or is invalid.
|
|
979
|
+
*/
|
|
980
|
+
async function readIssue(baseDir, id) {
|
|
981
|
+
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Write an issue to the worktree.
|
|
985
|
+
* Uses atomic write to prevent corruption.
|
|
986
|
+
*/
|
|
987
|
+
async function writeIssue(baseDir, issue) {
|
|
988
|
+
const validIssue = IssueSchema.parse(issue);
|
|
989
|
+
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* List all issues in the worktree.
|
|
993
|
+
* Returns empty array if issues directory doesn't exist.
|
|
994
|
+
*
|
|
995
|
+
* Uses parallel file reading for better performance with many issues.
|
|
996
|
+
*/
|
|
997
|
+
async function listIssues(baseDir, options = {}) {
|
|
998
|
+
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
999
|
+
const issuesDir = join(baseDir, "issues");
|
|
1000
|
+
let files;
|
|
1001
|
+
try {
|
|
1002
|
+
files = await readdir(issuesDir);
|
|
1003
|
+
} catch {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
1007
|
+
const issues = [];
|
|
1008
|
+
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
1009
|
+
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
1010
|
+
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
1011
|
+
const filePath = join(issuesDir, file);
|
|
1012
|
+
try {
|
|
1013
|
+
return {
|
|
1014
|
+
file,
|
|
1015
|
+
content: await readFile(filePath, "utf-8")
|
|
1016
|
+
};
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
return {
|
|
1019
|
+
file,
|
|
1020
|
+
error: formatUnknownError(error)
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
}));
|
|
1024
|
+
for (const result of fileContents) {
|
|
1025
|
+
if ("error" in result) {
|
|
1026
|
+
reportInvalidIssueFile({
|
|
1027
|
+
file: result.file,
|
|
1028
|
+
reason: `failed to read file: ${result.error}`
|
|
1029
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
const issue = parseIssue(result.content);
|
|
1034
|
+
issues.push(issue);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
reportInvalidIssueFile({
|
|
1037
|
+
file: result.file,
|
|
1038
|
+
reason: formatUnknownError(error)
|
|
1039
|
+
}, warnOnInvalid, options.onInvalidIssue);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return issues;
|
|
1044
|
+
}
|
|
1045
|
+
function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
|
|
1046
|
+
onInvalidIssue?.(invalidIssue);
|
|
1047
|
+
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
920
1050
|
//#endregion
|
|
921
1051
|
//#region src/file/git.ts
|
|
922
1052
|
/**
|
|
@@ -931,6 +1061,55 @@ function nowFilenameTimestamp() {
|
|
|
931
1061
|
*/
|
|
932
1062
|
const execFileAsync$1 = promisify(execFile);
|
|
933
1063
|
/**
|
|
1064
|
+
* Error thrown by {@link git} when a git command exits non-zero.
|
|
1065
|
+
*
|
|
1066
|
+
* Carries the process `exitCode` so callers can branch on git's exit status
|
|
1067
|
+
* (e.g. `ls-remote --exit-code` => 2 means "ref absent", `merge-base` => 1
|
|
1068
|
+
* means "no common ancestor") instead of string-matching stderr. The original
|
|
1069
|
+
* message/stderr is preserved so existing message-based classifiers
|
|
1070
|
+
* (classifySyncError) keep working.
|
|
1071
|
+
*/
|
|
1072
|
+
var GitError = class GitError extends Error {
|
|
1073
|
+
/** Process exit code, or null for a spawn failure (e.g. git not found). */
|
|
1074
|
+
exitCode;
|
|
1075
|
+
stderr;
|
|
1076
|
+
stdout;
|
|
1077
|
+
args;
|
|
1078
|
+
constructor(message, opts) {
|
|
1079
|
+
super(message);
|
|
1080
|
+
this.name = "GitError";
|
|
1081
|
+
this.exitCode = opts.exitCode;
|
|
1082
|
+
this.stderr = opts.stderr;
|
|
1083
|
+
this.stdout = opts.stdout;
|
|
1084
|
+
this.args = opts.args;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Wrap a raw execFile rejection into a GitError.
|
|
1088
|
+
*
|
|
1089
|
+
* Node's execFile rejection carries `code` (numeric exit code for a normal
|
|
1090
|
+
* exit, or a string like 'ENOENT' for a spawn failure), plus `stderr`/`stdout`.
|
|
1091
|
+
*/
|
|
1092
|
+
static from(err, args) {
|
|
1093
|
+
const raw = err;
|
|
1094
|
+
const exitCode = typeof raw.code === "number" ? raw.code : null;
|
|
1095
|
+
const stderr = typeof raw.stderr === "string" ? raw.stderr : "";
|
|
1096
|
+
const stdout = typeof raw.stdout === "string" ? raw.stdout : "";
|
|
1097
|
+
return new GitError(raw.message ?? `git ${args.join(" ")} failed`, {
|
|
1098
|
+
exitCode,
|
|
1099
|
+
stderr,
|
|
1100
|
+
stdout,
|
|
1101
|
+
args
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
/**
|
|
1106
|
+
* Read the git exit code from a thrown value, or null if it is not a GitError
|
|
1107
|
+
* (or carries no numeric code, e.g. a spawn failure).
|
|
1108
|
+
*/
|
|
1109
|
+
function exitCodeOf(err) {
|
|
1110
|
+
return err instanceof GitError ? err.exitCode : null;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
934
1113
|
* Maximum buffer size for git command output.
|
|
935
1114
|
*
|
|
936
1115
|
* Node.js child_process.execFile() defaults to 1MB (1024 * 1024 bytes).
|
|
@@ -945,8 +1124,31 @@ const GIT_MAX_BUFFER = 50 * 1024 * 1024;
|
|
|
945
1124
|
* Uses execFile for security - prevents shell injection attacks.
|
|
946
1125
|
*/
|
|
947
1126
|
async function git(...args) {
|
|
948
|
-
|
|
949
|
-
|
|
1127
|
+
try {
|
|
1128
|
+
const { stdout } = await execFileAsync$1("git", args, { maxBuffer: GIT_MAX_BUFFER });
|
|
1129
|
+
return stdout.trim();
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
throw GitError.from(err, args);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Like {@link git} but with `GIT_TERMINAL_PROMPT=0` so a network operation
|
|
1136
|
+
* (e.g. a best-effort push) fails fast instead of blocking on a credential
|
|
1137
|
+
* prompt in a non-interactive environment.
|
|
1138
|
+
*/
|
|
1139
|
+
async function gitNoPrompt(...args) {
|
|
1140
|
+
try {
|
|
1141
|
+
const { stdout } = await execFileAsync$1("git", args, {
|
|
1142
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
1143
|
+
env: {
|
|
1144
|
+
...process.env,
|
|
1145
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
return stdout.trim();
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
throw GitError.from(err, args);
|
|
1151
|
+
}
|
|
950
1152
|
}
|
|
951
1153
|
/**
|
|
952
1154
|
* Run `git commit` in a worktree with gpg signing disabled at the command level.
|
|
@@ -1244,15 +1446,42 @@ function mergeIssues(base, local, remote) {
|
|
|
1244
1446
|
};
|
|
1245
1447
|
}
|
|
1246
1448
|
/**
|
|
1449
|
+
* Categorize issues from two unrelated tbd-sync roots by id (which embeds the
|
|
1450
|
+
* globally unique ULID). Pure and git-free so the rescue is robust to the
|
|
1451
|
+
* missing merge base. Substantive equality ignores version/updated_at so
|
|
1452
|
+
* trivial bumps don't masquerade as conflicts.
|
|
1453
|
+
*/
|
|
1454
|
+
function categorizeIssuesByUlid(local, remote) {
|
|
1455
|
+
const localById = new Map(local.map((i) => [i.id, i]));
|
|
1456
|
+
const remoteById = new Map(remote.map((i) => [i.id, i]));
|
|
1457
|
+
const buckets = {
|
|
1458
|
+
localOnly: [],
|
|
1459
|
+
remoteOnly: [],
|
|
1460
|
+
bothIdentical: [],
|
|
1461
|
+
bothDifferent: []
|
|
1462
|
+
};
|
|
1463
|
+
for (const [id, localIssue] of localById) {
|
|
1464
|
+
const remoteIssue = remoteById.get(id);
|
|
1465
|
+
if (!remoteIssue) buckets.localOnly.push(localIssue);
|
|
1466
|
+
else if (issuesSubstantivelyEqual(localIssue, remoteIssue)) buckets.bothIdentical.push(remoteIssue);
|
|
1467
|
+
else buckets.bothDifferent.push({
|
|
1468
|
+
local: localIssue,
|
|
1469
|
+
remote: remoteIssue
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
for (const [id, remoteIssue] of remoteById) if (!localById.has(id)) buckets.remoteOnly.push(remoteIssue);
|
|
1473
|
+
return buckets;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1247
1476
|
* Maximum retry attempts for push operations.
|
|
1248
1477
|
*/
|
|
1249
1478
|
const MAX_PUSH_RETRIES = 3;
|
|
1250
1479
|
/**
|
|
1251
|
-
* Check if error is a non-fast-forward rejection.
|
|
1480
|
+
* Check if error is a non-fast-forward rejection (also the init race signal).
|
|
1252
1481
|
*/
|
|
1253
1482
|
function isNonFastForward(error) {
|
|
1254
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1255
|
-
return
|
|
1483
|
+
const msg = error instanceof GitError ? `${error.stderr}\n${error.message}` : error instanceof Error ? error.message : String(error);
|
|
1484
|
+
return /non-fast-forward|fetch first|rejected|updates were rejected/i.test(msg);
|
|
1256
1485
|
}
|
|
1257
1486
|
/**
|
|
1258
1487
|
* Push with retry and merge on conflict.
|
|
@@ -1315,14 +1544,22 @@ async function branchExists(branch, baseDir) {
|
|
|
1315
1544
|
}
|
|
1316
1545
|
}
|
|
1317
1546
|
/**
|
|
1318
|
-
*
|
|
1547
|
+
* Probe whether a remote branch exists, distinguishing a clean "absent" from a
|
|
1548
|
+
* "check failed".
|
|
1549
|
+
*
|
|
1550
|
+
* `git ls-remote --exit-code` exits 2 when the connection succeeded but no ref
|
|
1551
|
+
* matched; any other failure (auth/network/transient, or git not found) is a
|
|
1552
|
+
* check failure. Orphan-creating callers MUST branch on all three states and
|
|
1553
|
+
* never treat `check-failed` as `absent` — doing so risks creating a divergent
|
|
1554
|
+
* local branch when the remote is merely unreachable.
|
|
1319
1555
|
*/
|
|
1320
|
-
async function
|
|
1556
|
+
async function probeRemoteBranch(remote, branch, baseDir) {
|
|
1557
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1321
1558
|
try {
|
|
1322
|
-
await git(...
|
|
1323
|
-
return
|
|
1324
|
-
} catch {
|
|
1325
|
-
return
|
|
1559
|
+
await git(...dirArgs, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1560
|
+
return "present";
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
return exitCodeOf(err) === 2 ? "absent" : "check-failed";
|
|
1326
1563
|
}
|
|
1327
1564
|
}
|
|
1328
1565
|
async function pathExists(path) {
|
|
@@ -1542,6 +1779,66 @@ async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH)
|
|
|
1542
1779
|
}
|
|
1543
1780
|
}
|
|
1544
1781
|
/**
|
|
1782
|
+
* Whether the given remote is configured (has a URL) in the repo at baseDir.
|
|
1783
|
+
* A local-only repo (no remote) is safe to orphan and never pushes.
|
|
1784
|
+
*/
|
|
1785
|
+
async function remoteIsConfigured(remote, baseDir) {
|
|
1786
|
+
try {
|
|
1787
|
+
await git("-C", baseDir, "config", "--get", `remote.${remote}.url`);
|
|
1788
|
+
return true;
|
|
1789
|
+
} catch {
|
|
1790
|
+
return false;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Whether the data-sync worktree carries any user issue files (is-<ulid>.md),
|
|
1795
|
+
* as opposed to only the initial orphan scaffold (.gitkeep / .gitattributes).
|
|
1796
|
+
*/
|
|
1797
|
+
async function worktreeHasUserIssues(dataSyncPath) {
|
|
1798
|
+
try {
|
|
1799
|
+
return (await readdir(join(dataSyncPath, "issues"))).some((name) => /^is-.*\.md$/.test(name));
|
|
1800
|
+
} catch {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Push a freshly-created orphan tbd-sync immediately so "first init wins"
|
|
1806
|
+
* (closes the #137 race window). Classifies the outcome:
|
|
1807
|
+
*
|
|
1808
|
+
* - success => pushed: true ("first init wins").
|
|
1809
|
+
* - transient/permanent network/auth failure => best-effort, ignored
|
|
1810
|
+
* (branch stays local-only until the first reachable sync).
|
|
1811
|
+
* - non-fast-forward rejection => a detected init race (environment B pushed
|
|
1812
|
+
* its own orphan first). Fetch, then:
|
|
1813
|
+
* - scaffold-only local => adopt the remote (reset local to remote).
|
|
1814
|
+
* - local has user issues => fail loudly toward `tbd doctor --fix` so the
|
|
1815
|
+
* local work is never silently discarded.
|
|
1816
|
+
*
|
|
1817
|
+
* MUST be called after the orphan's initial commit, while the worktree is on
|
|
1818
|
+
* syncBranch.
|
|
1819
|
+
*/
|
|
1820
|
+
async function pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath) {
|
|
1821
|
+
try {
|
|
1822
|
+
await gitNoPrompt("-C", worktreePath, "push", remote, `HEAD:refs/heads/${syncBranch}`);
|
|
1823
|
+
return {
|
|
1824
|
+
pushed: true,
|
|
1825
|
+
adopted: false
|
|
1826
|
+
};
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
if (!isNonFastForward(err)) return {
|
|
1829
|
+
pushed: false,
|
|
1830
|
+
adopted: false
|
|
1831
|
+
};
|
|
1832
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
1833
|
+
if (await worktreeHasUserIssues(dataSyncPath)) throw new Error(`Detected unrelated ${remote}/${syncBranch} histories during init, and the local branch already contains issues. Refusing to discard local work. Run \`tbd doctor --fix\` to reconcile the histories.`);
|
|
1834
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
1835
|
+
return {
|
|
1836
|
+
pushed: false,
|
|
1837
|
+
adopted: true
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1545
1842
|
* Initialize the hidden worktree for the tbd-sync branch.
|
|
1546
1843
|
* Follows the decision tree from tbd-design.md §2.3.
|
|
1547
1844
|
*
|
|
@@ -1583,7 +1880,13 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1583
1880
|
created: true
|
|
1584
1881
|
};
|
|
1585
1882
|
}
|
|
1586
|
-
|
|
1883
|
+
const hasRemote = await remoteIsConfigured(remote, baseDir);
|
|
1884
|
+
const probe = hasRemote ? await probeRemoteBranch(remote, syncBranch, baseDir) : "absent";
|
|
1885
|
+
if (probe === "check-failed") return {
|
|
1886
|
+
success: false,
|
|
1887
|
+
error: `Could not verify whether ${remote}/${syncBranch} exists (remote check failed); refusing to create a divergent local branch. Check connectivity/auth and retry.`
|
|
1888
|
+
};
|
|
1889
|
+
if (probe === "present") {
|
|
1587
1890
|
await git("-C", baseDir, "fetch", remote, syncBranch);
|
|
1588
1891
|
await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
|
|
1589
1892
|
return {
|
|
@@ -1604,6 +1907,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1604
1907
|
await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
|
|
1605
1908
|
await git("-C", worktreePath, "add", ".");
|
|
1606
1909
|
await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
|
|
1910
|
+
if (hasRemote) await pushFreshOrphan(baseDir, worktreePath, remote, syncBranch, dataSyncPath);
|
|
1607
1911
|
return {
|
|
1608
1912
|
success: true,
|
|
1609
1913
|
path: worktreePath,
|
|
@@ -1660,22 +1964,30 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
|
|
|
1660
1964
|
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1661
1965
|
const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1662
1966
|
let diverged = false;
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const localHead = await git(...dirArgs, "rev-parse", syncBranch);
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1967
|
+
let unrelated = false;
|
|
1968
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
1969
|
+
const localHead = (await git(...dirArgs, "rev-parse", syncBranch)).trim();
|
|
1970
|
+
try {
|
|
1971
|
+
const mergeBase = (await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`)).trim();
|
|
1972
|
+
diverged = mergeBase !== localHead && mergeBase !== remoteHead;
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
if (exitCodeOf(err) === 1) {
|
|
1975
|
+
unrelated = true;
|
|
1976
|
+
diverged = true;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1669
1979
|
}
|
|
1670
1980
|
return {
|
|
1671
1981
|
exists: true,
|
|
1672
1982
|
diverged,
|
|
1983
|
+
unrelated,
|
|
1673
1984
|
head: remoteHead
|
|
1674
1985
|
};
|
|
1675
1986
|
} catch {
|
|
1676
1987
|
return {
|
|
1677
1988
|
exists: false,
|
|
1678
|
-
diverged: false
|
|
1989
|
+
diverged: false,
|
|
1990
|
+
unrelated: false
|
|
1679
1991
|
};
|
|
1680
1992
|
}
|
|
1681
1993
|
}
|
|
@@ -1714,6 +2026,88 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
1714
2026
|
localBehind
|
|
1715
2027
|
};
|
|
1716
2028
|
}
|
|
2029
|
+
/** Read all issues from a git ref's data-sync tree (no checkout needed). */
|
|
2030
|
+
async function readBranchIssues(baseDir, ref) {
|
|
2031
|
+
let listing;
|
|
2032
|
+
try {
|
|
2033
|
+
listing = await git("-C", baseDir, "ls-tree", "-r", "--name-only", ref, "--", `${DATA_SYNC_DIR}/issues/`);
|
|
2034
|
+
} catch {
|
|
2035
|
+
return [];
|
|
2036
|
+
}
|
|
2037
|
+
const issues = [];
|
|
2038
|
+
for (const path of listing.split("\n").filter((p) => /\/is-.*\.md$/.test(p))) try {
|
|
2039
|
+
const content = await git("-C", baseDir, "show", `${ref}:${path}`);
|
|
2040
|
+
issues.push(parseIssue(content));
|
|
2041
|
+
} catch {}
|
|
2042
|
+
return issues;
|
|
2043
|
+
}
|
|
2044
|
+
/** Read the ID mapping from a git ref's ids.yml (empty if absent). */
|
|
2045
|
+
async function readBranchMapping(baseDir, ref) {
|
|
2046
|
+
try {
|
|
2047
|
+
return parseIdMappingFromYaml(await git("-C", baseDir, "show", `${ref}:${DATA_SYNC_DIR}/mappings/ids.yml`));
|
|
2048
|
+
} catch {
|
|
2049
|
+
return {
|
|
2050
|
+
shortToUlid: /* @__PURE__ */ new Map(),
|
|
2051
|
+
ulidToShort: /* @__PURE__ */ new Map()
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
/** Preserve a losing issue version explicitly under attic/conflicts/. */
|
|
2056
|
+
async function preserveLosingVersion(dataSyncPath, loser) {
|
|
2057
|
+
const conflictsDir = join(dataSyncPath, "attic", "conflicts");
|
|
2058
|
+
await mkdir(conflictsDir, { recursive: true });
|
|
2059
|
+
await writeFile(join(conflictsDir, `${loser.id}__${nowFilenameTimestamp()}.md`), serializeIssue(loser));
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Non-destructively rescue an unrelated tbd-sync history (#139).
|
|
2063
|
+
*
|
|
2064
|
+
* Reconciles at the issue-file layer (ULIDs guarantee no collisions), never via
|
|
2065
|
+
* a git history merge. Adopts the remote as the canonical base and replays
|
|
2066
|
+
* local work onto it. The only destructive step (the reset) happens AFTER a
|
|
2067
|
+
* backup branch is created, so the pre-rescue HEAD is always recoverable and
|
|
2068
|
+
* the rescue is restartable.
|
|
2069
|
+
*
|
|
2070
|
+
* MUST be called while holding `withSharedDataSyncLock`. Aborts if the
|
|
2071
|
+
* data-sync worktree is dirty or has a merge in progress.
|
|
2072
|
+
*/
|
|
2073
|
+
async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
2074
|
+
const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
|
|
2075
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) throw new Error("Refusing to rescue: the tbd-sync worktree has uncommitted changes. Commit or stash them, then retry.");
|
|
2076
|
+
if (await git("-C", worktreePath, "rev-parse", "-q", "--verify", "MERGE_HEAD").then(() => true).catch(() => false)) throw new Error("Refusing to rescue: a merge is in progress in the tbd-sync worktree. Resolve or abort it, then retry.");
|
|
2077
|
+
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
2078
|
+
const localIssues = await listIssues(dataSyncPath);
|
|
2079
|
+
const localMapping = await loadIdMapping(dataSyncPath);
|
|
2080
|
+
const remoteIssues = await readBranchIssues(baseDir, `${remote}/${syncBranch}`);
|
|
2081
|
+
const remoteMapping = await readBranchMapping(baseDir, `${remote}/${syncBranch}`);
|
|
2082
|
+
const localHead = (await git("-C", baseDir, "rev-parse", syncBranch)).trim();
|
|
2083
|
+
const backupBranch = `tbd-backup-${nowFilenameTimestamp()}`;
|
|
2084
|
+
await git("-C", baseDir, "branch", backupBranch, localHead);
|
|
2085
|
+
const buckets = categorizeIssuesByUlid(localIssues, remoteIssues);
|
|
2086
|
+
await git("-C", worktreePath, "reset", "--hard", `${remote}/${syncBranch}`);
|
|
2087
|
+
let conflicts = 0;
|
|
2088
|
+
for (const issue of buckets.localOnly) await writeIssue(dataSyncPath, issue);
|
|
2089
|
+
for (const { local, remote: remoteIssue } of buckets.bothDifferent) {
|
|
2090
|
+
const { merged } = mergeIssues(null, local, remoteIssue);
|
|
2091
|
+
await writeIssue(dataSyncPath, merged);
|
|
2092
|
+
for (const side of [local, remoteIssue]) if (!issuesSubstantivelyEqual(side, merged)) {
|
|
2093
|
+
await preserveLosingVersion(dataSyncPath, side);
|
|
2094
|
+
conflicts++;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const mergedMapping = mergeIdMappings(remoteMapping, localMapping);
|
|
2098
|
+
const allIssues = await listIssues(dataSyncPath);
|
|
2099
|
+
reconcileMappings(allIssues.map((i) => i.id), mergedMapping, remoteMapping);
|
|
2100
|
+
await saveIdMapping(dataSyncPath, mergedMapping);
|
|
2101
|
+
await git("-C", worktreePath, "add", "-A");
|
|
2102
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) await gitCommit(worktreePath, "--no-verify", "-m", `tbd rescue: adopt remote base + reconcile ${buckets.localOnly.length + buckets.bothDifferent.length} issue(s) (${buckets.localOnly.length} local-only, ${buckets.bothDifferent.length} merged)`);
|
|
2103
|
+
return {
|
|
2104
|
+
backupBranch,
|
|
2105
|
+
localOnly: buckets.localOnly.length,
|
|
2106
|
+
merged: buckets.bothDifferent.length,
|
|
2107
|
+
conflicts,
|
|
2108
|
+
totalIssues: allIssues.length
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
1717
2111
|
/**
|
|
1718
2112
|
* Count issues on a remote sync branch without creating a worktree.
|
|
1719
2113
|
* Used by doctor to show accurate statistics on fresh clones.
|
|
@@ -2061,124 +2455,6 @@ const initCommand = new Command("init").description("Initialize tbd in a git rep
|
|
|
2061
2455
|
await new InitHandler(command).run(options);
|
|
2062
2456
|
});
|
|
2063
2457
|
|
|
2064
|
-
//#endregion
|
|
2065
|
-
//#region src/utils/zod-error-utils.ts
|
|
2066
|
-
/**
|
|
2067
|
-
* Helpers for rendering Zod errors without relying on object inspection.
|
|
2068
|
-
*/
|
|
2069
|
-
/**
|
|
2070
|
-
* Format a ZodError as concise path-qualified messages for CLI output.
|
|
2071
|
-
*/
|
|
2072
|
-
function formatZodError(error) {
|
|
2073
|
-
const messages = error.issues.map((issue) => {
|
|
2074
|
-
return `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`;
|
|
2075
|
-
});
|
|
2076
|
-
return messages.length > 0 ? messages.join("; ") : error.message;
|
|
2077
|
-
}
|
|
2078
|
-
/**
|
|
2079
|
-
* Format unknown thrown values as safe strings for warnings and diagnostics.
|
|
2080
|
-
*/
|
|
2081
|
-
function formatUnknownError(error) {
|
|
2082
|
-
if (error instanceof ZodError) return formatZodError(error);
|
|
2083
|
-
if (error instanceof Error) return error.message;
|
|
2084
|
-
return String(error);
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
//#endregion
|
|
2088
|
-
//#region src/file/storage.ts
|
|
2089
|
-
/**
|
|
2090
|
-
* Storage layer for issue files.
|
|
2091
|
-
*
|
|
2092
|
-
* Provides atomic file operations and issue CRUD operations.
|
|
2093
|
-
* All operations work on the data-sync directory selected by the caller. In production
|
|
2094
|
-
* that is the shared hidden worktree under $GIT_COMMON_DIR/tbd/data-sync-worktree/.
|
|
2095
|
-
*
|
|
2096
|
-
* See: tbd-design.md §3.2 Storage Layer
|
|
2097
|
-
*/
|
|
2098
|
-
/**
|
|
2099
|
-
* Maximum issue files read concurrently to avoid exhausting file descriptors in large repos.
|
|
2100
|
-
*/
|
|
2101
|
-
const ISSUE_READ_BATCH_SIZE = 200;
|
|
2102
|
-
/**
|
|
2103
|
-
* Get the path to an issue file.
|
|
2104
|
-
*/
|
|
2105
|
-
function getIssuePath(baseDir, id) {
|
|
2106
|
-
return join(baseDir, "issues", `${id}.md`);
|
|
2107
|
-
}
|
|
2108
|
-
/**
|
|
2109
|
-
* Read an issue from the worktree.
|
|
2110
|
-
* @throws If the issue file doesn't exist or is invalid.
|
|
2111
|
-
*/
|
|
2112
|
-
async function readIssue(baseDir, id) {
|
|
2113
|
-
return parseIssue(await readFile(getIssuePath(baseDir, id), "utf-8"));
|
|
2114
|
-
}
|
|
2115
|
-
/**
|
|
2116
|
-
* Write an issue to the worktree.
|
|
2117
|
-
* Uses atomic write to prevent corruption.
|
|
2118
|
-
*/
|
|
2119
|
-
async function writeIssue(baseDir, issue) {
|
|
2120
|
-
const validIssue = IssueSchema.parse(issue);
|
|
2121
|
-
await writeFile(getIssuePath(baseDir, validIssue.id), serializeIssue(validIssue));
|
|
2122
|
-
}
|
|
2123
|
-
/**
|
|
2124
|
-
* List all issues in the worktree.
|
|
2125
|
-
* Returns empty array if issues directory doesn't exist.
|
|
2126
|
-
*
|
|
2127
|
-
* Uses parallel file reading for better performance with many issues.
|
|
2128
|
-
*/
|
|
2129
|
-
async function listIssues(baseDir, options = {}) {
|
|
2130
|
-
const warnOnInvalid = options.warnOnInvalid ?? true;
|
|
2131
|
-
const issuesDir = join(baseDir, "issues");
|
|
2132
|
-
let files;
|
|
2133
|
-
try {
|
|
2134
|
-
files = await readdir(issuesDir);
|
|
2135
|
-
} catch {
|
|
2136
|
-
return [];
|
|
2137
|
-
}
|
|
2138
|
-
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
2139
|
-
const issues = [];
|
|
2140
|
-
for (let i = 0; i < mdFiles.length; i += ISSUE_READ_BATCH_SIZE) {
|
|
2141
|
-
const batch = mdFiles.slice(i, i + ISSUE_READ_BATCH_SIZE);
|
|
2142
|
-
const fileContents = await Promise.all(batch.map(async (file) => {
|
|
2143
|
-
const filePath = join(issuesDir, file);
|
|
2144
|
-
try {
|
|
2145
|
-
return {
|
|
2146
|
-
file,
|
|
2147
|
-
content: await readFile(filePath, "utf-8")
|
|
2148
|
-
};
|
|
2149
|
-
} catch (error) {
|
|
2150
|
-
return {
|
|
2151
|
-
file,
|
|
2152
|
-
error: formatUnknownError(error)
|
|
2153
|
-
};
|
|
2154
|
-
}
|
|
2155
|
-
}));
|
|
2156
|
-
for (const result of fileContents) {
|
|
2157
|
-
if ("error" in result) {
|
|
2158
|
-
reportInvalidIssueFile({
|
|
2159
|
-
file: result.file,
|
|
2160
|
-
reason: `failed to read file: ${result.error}`
|
|
2161
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
2162
|
-
continue;
|
|
2163
|
-
}
|
|
2164
|
-
try {
|
|
2165
|
-
const issue = parseIssue(result.content);
|
|
2166
|
-
issues.push(issue);
|
|
2167
|
-
} catch (error) {
|
|
2168
|
-
reportInvalidIssueFile({
|
|
2169
|
-
file: result.file,
|
|
2170
|
-
reason: formatUnknownError(error)
|
|
2171
|
-
}, warnOnInvalid, options.onInvalidIssue);
|
|
2172
|
-
}
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
return issues;
|
|
2176
|
-
}
|
|
2177
|
-
function reportInvalidIssueFile(invalidIssue, warnOnInvalid, onInvalidIssue) {
|
|
2178
|
-
onInvalidIssue?.(invalidIssue);
|
|
2179
|
-
if (warnOnInvalid) console.warn(`Skipping invalid issue file: ${invalidIssue.file}: ${invalidIssue.reason}`);
|
|
2180
|
-
}
|
|
2181
|
-
|
|
2182
2458
|
//#endregion
|
|
2183
2459
|
//#region src/lib/priority.ts
|
|
2184
2460
|
/**
|
|
@@ -5452,6 +5728,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5452
5728
|
this.output.debug(`Committed ${count} file(s) to sync branch`);
|
|
5453
5729
|
}
|
|
5454
5730
|
await git("fetch", remote, syncBranch);
|
|
5731
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
5455
5732
|
let behindCommits = 0;
|
|
5456
5733
|
try {
|
|
5457
5734
|
const behindOutput = await git("rev-list", "--count", `${syncBranch}..${remote}/${syncBranch}`);
|
|
@@ -5556,6 +5833,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5556
5833
|
}
|
|
5557
5834
|
}
|
|
5558
5835
|
} catch (error) {
|
|
5836
|
+
if (error instanceof SyncError) throw error;
|
|
5559
5837
|
this.output.debug(`Fetch failed (may be first sync): ${error.message}`);
|
|
5560
5838
|
}
|
|
5561
5839
|
let aheadCommits = 0;
|
|
@@ -5588,6 +5866,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5588
5866
|
summary.conflicts = conflicts.length;
|
|
5589
5867
|
spinner.stop();
|
|
5590
5868
|
if (pushFailed) {
|
|
5869
|
+
if ((await checkRemoteBranchHealth(remote, syncBranch)).unrelated) throw new UnrelatedHistoriesError(remote, syncBranch);
|
|
5591
5870
|
let displayError = pushError;
|
|
5592
5871
|
const httpMatch = /HTTP (\d+)/.exec(pushError);
|
|
5593
5872
|
const curlMatch = /curl \d+ (.+?)(?:\n|$)/.exec(pushError);
|
|
@@ -6724,6 +7003,35 @@ function renderDiagnostics(results, colors) {
|
|
|
6724
7003
|
*
|
|
6725
7004
|
* See: tbd-design.md §4.9 Doctor
|
|
6726
7005
|
*/
|
|
7006
|
+
/**
|
|
7007
|
+
* Map remote sync-branch health to a "Remote sync branch" diagnostic.
|
|
7008
|
+
*
|
|
7009
|
+
* Returns null when the remote branch does not exist (the caller then falls
|
|
7010
|
+
* through to local-branch / new-repo handling). Unrelated histories are a hard
|
|
7011
|
+
* ✗ finding routed to `tbd doctor --fix` (the rescue), NOT `tbd sync` — push
|
|
7012
|
+
* can never fast-forward and a plain merge refuses, so `tbd sync` cannot help.
|
|
7013
|
+
*/
|
|
7014
|
+
function classifyRemoteSyncHealth(health, remote, syncBranch) {
|
|
7015
|
+
if (!health.exists) return null;
|
|
7016
|
+
if (health.unrelated) return {
|
|
7017
|
+
name: "Remote sync branch",
|
|
7018
|
+
status: "error",
|
|
7019
|
+
fixable: true,
|
|
7020
|
+
message: `${remote}/${syncBranch} histories are unrelated (no common ancestor) — push cannot succeed`,
|
|
7021
|
+
suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
|
|
7022
|
+
};
|
|
7023
|
+
if (health.diverged) return {
|
|
7024
|
+
name: "Remote sync branch",
|
|
7025
|
+
status: "warn",
|
|
7026
|
+
message: `${remote}/${syncBranch} has diverged`,
|
|
7027
|
+
suggestion: "Run: tbd sync to reconcile changes"
|
|
7028
|
+
};
|
|
7029
|
+
return {
|
|
7030
|
+
name: "Remote sync branch",
|
|
7031
|
+
status: "ok",
|
|
7032
|
+
message: `${remote}/${syncBranch}`
|
|
7033
|
+
};
|
|
7034
|
+
}
|
|
6727
7035
|
const CONFIG_DIR = TBD_DIR;
|
|
6728
7036
|
var DoctorHandler = class extends BaseCommand {
|
|
6729
7037
|
dataSyncDir = "";
|
|
@@ -6775,7 +7083,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6775
7083
|
const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
|
|
6776
7084
|
healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
|
|
6777
7085
|
healthChecks.push(await this.checkLocalSyncBranch());
|
|
6778
|
-
healthChecks.push(await this.checkRemoteSyncBranch());
|
|
7086
|
+
healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
|
|
6779
7087
|
healthChecks.push(await this.checkLocalVsRemoteData());
|
|
6780
7088
|
healthChecks.push(await this.checkCloneScenarios());
|
|
6781
7089
|
healthChecks.push(await this.checkSyncConsistency());
|
|
@@ -7010,7 +7318,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7010
7318
|
status: "ok"
|
|
7011
7319
|
};
|
|
7012
7320
|
if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
|
|
7013
|
-
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-
|
|
7321
|
+
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
7014
7322
|
const resolved = resolveIdMappingConflicts(content);
|
|
7015
7323
|
await saveIdMapping(this.dataSyncDir, resolved);
|
|
7016
7324
|
return {
|
|
@@ -7059,7 +7367,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7059
7367
|
status: "ok"
|
|
7060
7368
|
};
|
|
7061
7369
|
if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
|
|
7062
|
-
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-
|
|
7370
|
+
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
7063
7371
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
7064
7372
|
await saveIdMapping(this.dataSyncDir, mapping);
|
|
7065
7373
|
return {
|
|
@@ -7198,7 +7506,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7198
7506
|
name: "ID mapping coverage",
|
|
7199
7507
|
status: "ok"
|
|
7200
7508
|
};
|
|
7201
|
-
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-
|
|
7509
|
+
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
7202
7510
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
7203
7511
|
const missingIds = [];
|
|
7204
7512
|
for (const issue of this.issues) {
|
|
@@ -7210,7 +7518,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7210
7518
|
status: "ok"
|
|
7211
7519
|
};
|
|
7212
7520
|
if (fix && !this.checkDryRun("Create missing ID mappings")) {
|
|
7213
|
-
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-
|
|
7521
|
+
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
7214
7522
|
let historicalMapping;
|
|
7215
7523
|
try {
|
|
7216
7524
|
const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
|
|
@@ -7608,23 +7916,27 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7608
7916
|
* Check remote sync branch health.
|
|
7609
7917
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4b
|
|
7610
7918
|
*/
|
|
7611
|
-
async checkRemoteSyncBranch() {
|
|
7919
|
+
async checkRemoteSyncBranch(fix) {
|
|
7612
7920
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7613
7921
|
const remote = this.config?.sync.remote ?? "origin";
|
|
7614
7922
|
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
7615
|
-
if (remoteHealth.
|
|
7616
|
-
|
|
7923
|
+
if (remoteHealth.unrelated && fix && !this.checkDryRun("Rescue unrelated tbd-sync histories")) try {
|
|
7924
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => rescueUnrelatedHistory(this.cwd, remote, syncBranch));
|
|
7925
|
+
return {
|
|
7617
7926
|
name: "Remote sync branch",
|
|
7618
|
-
status: "
|
|
7619
|
-
message:
|
|
7620
|
-
suggestion: "Run: tbd sync to reconcile changes"
|
|
7927
|
+
status: "ok",
|
|
7928
|
+
message: `rescued: adopted ${remote}/${syncBranch} base, reconciled ${result.localOnly} local-only + ${result.merged} merged (backup: ${result.backupBranch})`
|
|
7621
7929
|
};
|
|
7930
|
+
} catch (error) {
|
|
7622
7931
|
return {
|
|
7623
7932
|
name: "Remote sync branch",
|
|
7624
|
-
status: "
|
|
7625
|
-
message:
|
|
7933
|
+
status: "error",
|
|
7934
|
+
message: `rescue failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
7935
|
+
suggestion: "Resolve manually; the pre-rescue state is on the tbd-backup-* branch"
|
|
7626
7936
|
};
|
|
7627
7937
|
}
|
|
7938
|
+
const diag = classifyRemoteSyncHealth(remoteHealth, remote, syncBranch);
|
|
7939
|
+
if (diag) return diag;
|
|
7628
7940
|
if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
|
|
7629
7941
|
name: "Remote sync branch",
|
|
7630
7942
|
status: "warn",
|
|
@@ -9453,6 +9765,7 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
9453
9765
|
console.log(`${colors.success("✓")} Initialized in this repo`);
|
|
9454
9766
|
if (await this.checkHooksInstalled(tbdRoot)) console.log(`${colors.success("✓")} Hooks installed`);
|
|
9455
9767
|
else console.log(`${colors.dim("✗")} Hooks not installed (run: tbd setup --auto)`);
|
|
9768
|
+
console.log(colors.dim(" Run `tbd setup --auto` to refresh skills and settings (e.g. after upgrading tbd)."));
|
|
9456
9769
|
console.log("");
|
|
9457
9770
|
console.log(colors.bold("=== PROJECT STATUS ==="));
|
|
9458
9771
|
try {
|
|
@@ -10395,9 +10708,11 @@ async function getShortcutDirectory(quiet = false) {
|
|
|
10395
10708
|
}
|
|
10396
10709
|
/**
|
|
10397
10710
|
* DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
|
|
10711
|
+
* Carries the shared `format=fNN` integration code so the forward-compatibility
|
|
10712
|
+
* guard can detect a skill written by a newer tbd (see {@link writeSkillFile}).
|
|
10398
10713
|
* Formatted to match flowmark output.
|
|
10399
10714
|
*/
|
|
10400
|
-
const SKILL_DO_NOT_EDIT_MARKER =
|
|
10715
|
+
const SKILL_DO_NOT_EDIT_MARKER = `<!-- DO NOT EDIT: Generated by tbd setup (format=${AGENT_INTEGRATION_FORMAT}).\nRun 'tbd setup' to update.\n-->`;
|
|
10401
10716
|
/**
|
|
10402
10717
|
* Build the full generated SKILL.md payload: the bundled skill content with the
|
|
10403
10718
|
* shortcut/guideline directory appended and a DO NOT EDIT marker after the
|
|
@@ -10415,8 +10730,19 @@ async function buildSkillPayload(quiet = false) {
|
|
|
10415
10730
|
}
|
|
10416
10731
|
/**
|
|
10417
10732
|
* Write a generated SKILL.md payload to a target path, creating parent dirs.
|
|
10733
|
+
*
|
|
10734
|
+
* Forward-compatibility guard: if an existing skill carries a newer
|
|
10735
|
+
* `format=fNN` stamp than this tbd understands, refuse to overwrite it and tell
|
|
10736
|
+
* the user to upgrade. Guarding each generated surface at its own write point
|
|
10737
|
+
* means an older tbd cannot partial-downgrade a newer committed skill,
|
|
10738
|
+
* regardless of the order in which surfaces are written.
|
|
10418
10739
|
*/
|
|
10419
10740
|
async function writeSkillFile(targetPath, payload) {
|
|
10741
|
+
let existing = null;
|
|
10742
|
+
try {
|
|
10743
|
+
existing = await readFile(targetPath, "utf-8");
|
|
10744
|
+
} catch {}
|
|
10745
|
+
if (existing !== null) assertNotNewerFormat(existing, targetPath);
|
|
10420
10746
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
10421
10747
|
await writeFile(targetPath, payload);
|
|
10422
10748
|
}
|