get-tbd 0.1.29 → 0.2.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 +5 -1
- package/dist/bin.mjs +3241 -2326
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +1503 -791
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-B38rbI9u.mjs → config-BJz1m9eN.mjs} +183 -39
- package/dist/config-BJz1m9eN.mjs.map +1 -0
- package/dist/{config-C0ITTrtc.mjs → config-DlCUMyCG.mjs} +1 -1
- package/dist/docs/README.md +5 -1
- package/dist/docs/SKILL.md +0 -1
- package/dist/docs/guidelines/backward-compatibility-rules.md +4 -0
- package/dist/docs/guidelines/bun-monorepo-patterns.md +20 -4
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +354 -37
- package/dist/docs/guidelines/commit-conventions.md +4 -0
- package/dist/docs/guidelines/common-doc-guidelines.md +234 -0
- package/dist/docs/guidelines/convex-limits-best-practices.md +4 -0
- package/dist/docs/guidelines/convex-rules.md +4 -0
- package/dist/docs/guidelines/electron-app-development-patterns.md +4 -0
- package/dist/docs/guidelines/error-handling-rules.md +4 -0
- package/dist/docs/guidelines/general-coding-rules.md +4 -0
- package/dist/docs/guidelines/general-comment-rules.md +4 -0
- package/dist/docs/guidelines/general-eng-assistant-rules.md +4 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +4 -0
- package/dist/docs/guidelines/general-testing-rules.md +4 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +4 -0
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +27 -6
- package/dist/docs/guidelines/python-cli-patterns.md +4 -0
- package/dist/docs/guidelines/python-modern-guidelines.md +30 -0
- package/dist/docs/guidelines/python-rules.md +4 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +4 -0
- package/dist/docs/guidelines/supply-chain-hardening.md +11 -7
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +10 -4
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +27 -24
- package/dist/docs/guidelines/typescript-code-coverage.md +11 -7
- package/dist/docs/guidelines/typescript-rules.md +10 -6
- package/dist/docs/guidelines/typescript-sorting-patterns.md +4 -0
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +7 -3
- package/dist/docs/install/ensure-gh-cli.sh +59 -24
- package/dist/docs/shortcuts/standard/agent-handoff.md +4 -0
- package/dist/docs/shortcuts/standard/checkout-third-party-repo.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-all.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-docstrings.md +4 -0
- package/dist/docs/shortcuts/standard/code-cleanup-tests.md +4 -0
- package/dist/docs/shortcuts/standard/code-review-and-commit.md +4 -0
- package/dist/docs/shortcuts/standard/coding-spike.md +4 -0
- package/dist/docs/shortcuts/standard/create-or-update-pr-simple.md +4 -0
- package/dist/docs/shortcuts/standard/create-or-update-pr-with-validation-plan.md +4 -0
- package/dist/docs/shortcuts/standard/implement-beads.md +4 -0
- package/dist/docs/shortcuts/standard/merge-upstream.md +4 -0
- package/dist/docs/shortcuts/standard/new-architecture-doc.md +4 -0
- package/dist/docs/shortcuts/standard/new-guideline.md +4 -0
- package/dist/docs/shortcuts/standard/new-plan-spec.md +4 -0
- package/dist/docs/shortcuts/standard/new-qa-playbook.md +4 -0
- package/dist/docs/shortcuts/standard/new-research-brief.md +4 -0
- package/dist/docs/shortcuts/standard/new-shortcut.md +4 -0
- package/dist/docs/shortcuts/standard/new-validation-plan.md +4 -0
- package/dist/docs/shortcuts/standard/plan-implementation-with-beads.md +4 -0
- package/dist/docs/shortcuts/standard/precommit-process.md +4 -0
- package/dist/docs/shortcuts/standard/review-code-python.md +4 -0
- package/dist/docs/shortcuts/standard/review-code-typescript.md +4 -0
- package/dist/docs/shortcuts/standard/review-code.md +4 -0
- package/dist/docs/shortcuts/standard/review-github-pr.md +4 -0
- package/dist/docs/shortcuts/standard/revise-all-architecture-docs.md +4 -0
- package/dist/docs/shortcuts/standard/revise-architecture-doc.md +4 -0
- package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -0
- package/dist/docs/shortcuts/standard/sync-failure-recovery.md +4 -0
- package/dist/docs/shortcuts/standard/update-specs-status.md +4 -0
- package/dist/docs/shortcuts/standard/welcome-user.md +4 -0
- package/dist/docs/tbd-closing.md +4 -0
- package/dist/docs/tbd-design.md +109 -68
- package/dist/docs/tbd-docs.md +20 -13
- package/dist/docs/tbd-prime.md +4 -0
- package/dist/docs/templates/architecture-doc.md +4 -0
- package/dist/docs/templates/plan-spec.md +4 -0
- package/dist/docs/templates/qa-playbook.md +4 -0
- package/dist/docs/templates/research-brief.md +4 -0
- package/dist/{id-mapping-Ctfl_nc1.mjs → id-mapping-CFoPVinz.mjs} +1 -1
- package/dist/{id-mapping-CqrrLgeX.mjs → id-mapping-CtfTfGIh.mjs} +146 -122
- package/dist/id-mapping-CtfTfGIh.mjs.map +1 -0
- package/dist/index.d.mts +53 -1
- package/dist/index.mjs +3 -3
- package/dist/{schemas-C8mOQykE.mjs → schemas-f0EcuAVu.mjs} +40 -3
- package/dist/schemas-f0EcuAVu.mjs.map +1 -0
- package/dist/{src-CJyVkC3V.mjs → src-rIE4xSVs.mjs} +3 -3
- package/dist/src-rIE4xSVs.mjs.map +1 -0
- package/dist/tbd +3241 -2326
- package/package.json +1 -1
- package/dist/config-B38rbI9u.mjs.map +0 -1
- package/dist/docs/guidelines/general-style-rules.md +0 -38
- package/dist/docs/guidelines/writing-style-guidelines.md +0 -42
- package/dist/id-mapping-CqrrLgeX.mjs.map +0 -1
- package/dist/schemas-C8mOQykE.mjs.map +0 -1
- package/dist/src-CJyVkC3V.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { S as IssueTitle,
|
|
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-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-rIE4xSVs.mjs";
|
|
3
3
|
import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
|
|
4
|
-
import { A as
|
|
5
|
-
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-
|
|
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-CtfTfGIh.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError } from "zod";
|
|
8
8
|
import matter from "gray-matter";
|
|
@@ -12,7 +12,7 @@ import pc from "picocolors";
|
|
|
12
12
|
import { marked } from "marked";
|
|
13
13
|
import { markedTerminal } from "marked-terminal";
|
|
14
14
|
import { execFile, execSync, spawn, spawnSync } from "node:child_process";
|
|
15
|
-
import { access, chmod, cp, mkdir, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
|
|
15
|
+
import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink } from "node:fs/promises";
|
|
16
16
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { writeFile } from "atomically";
|
|
18
18
|
import { homedir } from "node:os";
|
|
@@ -620,28 +620,6 @@ var SyncError = class extends CLIError {
|
|
|
620
620
|
}
|
|
621
621
|
};
|
|
622
622
|
/**
|
|
623
|
-
* Worktree missing error - the data-sync-worktree directory doesn't exist.
|
|
624
|
-
* This indicates the worktree was never created or was deleted.
|
|
625
|
-
* See: tbd-design.md §2.3.6 Worktree Error Classes
|
|
626
|
-
*/
|
|
627
|
-
var WorktreeMissingError = class extends CLIError {
|
|
628
|
-
constructor(message = "Worktree not found at .tbd/data-sync-worktree/. Run 'tbd doctor --fix' to repair.") {
|
|
629
|
-
super(message, 1);
|
|
630
|
-
this.name = "WorktreeMissingError";
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
/**
|
|
634
|
-
* Worktree corrupted error - the worktree exists but is invalid.
|
|
635
|
-
* This can occur when the .git file is missing or points to an invalid location.
|
|
636
|
-
* See: tbd-design.md §2.3.6 Worktree Error Classes
|
|
637
|
-
*/
|
|
638
|
-
var WorktreeCorruptedError = class extends CLIError {
|
|
639
|
-
constructor(message = "Worktree at .tbd/data-sync-worktree/ is corrupted. Run 'tbd doctor --fix' to repair.") {
|
|
640
|
-
super(message, 1);
|
|
641
|
-
this.name = "WorktreeCorruptedError";
|
|
642
|
-
}
|
|
643
|
-
};
|
|
644
|
-
/**
|
|
645
623
|
* Classify a sync error to determine appropriate recovery action.
|
|
646
624
|
*
|
|
647
625
|
* Used by `tbd sync` to decide whether to:
|
|
@@ -706,10 +684,7 @@ var BaseCommand = class {
|
|
|
706
684
|
try {
|
|
707
685
|
return await action();
|
|
708
686
|
} catch (error) {
|
|
709
|
-
if (error instanceof CLIError)
|
|
710
|
-
this.output.error(error.message);
|
|
711
|
-
throw error;
|
|
712
|
-
}
|
|
687
|
+
if (error instanceof CLIError) throw error;
|
|
713
688
|
const originalError = error instanceof Error ? error : void 0;
|
|
714
689
|
const detail = originalError?.message;
|
|
715
690
|
const fullMessage = detail && detail !== errorMessage ? `${errorMessage}: ${detail}` : errorMessage;
|
|
@@ -740,7 +715,7 @@ var BaseCommand = class {
|
|
|
740
715
|
/**
|
|
741
716
|
* Check if a path exists on the filesystem.
|
|
742
717
|
*/
|
|
743
|
-
async function pathExists(path) {
|
|
718
|
+
async function pathExists$1(path) {
|
|
744
719
|
try {
|
|
745
720
|
await access(path);
|
|
746
721
|
return true;
|
|
@@ -780,7 +755,7 @@ function hasGitignorePattern(content, pattern) {
|
|
|
780
755
|
async function ensureGitignorePatterns(gitignorePath, patterns, header) {
|
|
781
756
|
let content = "";
|
|
782
757
|
let created = false;
|
|
783
|
-
if (await pathExists(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
758
|
+
if (await pathExists$1(gitignorePath)) content = await readFile(gitignorePath, "utf-8");
|
|
784
759
|
else created = true;
|
|
785
760
|
const entries = [];
|
|
786
761
|
let currentPreamble = [];
|
|
@@ -974,6 +949,18 @@ async function git(...args) {
|
|
|
974
949
|
return stdout.trim();
|
|
975
950
|
}
|
|
976
951
|
/**
|
|
952
|
+
* Run `git commit` in a worktree with gpg signing disabled at the command level.
|
|
953
|
+
*
|
|
954
|
+
* Internal tbd-sync commits are machine-generated data commits on the data branch,
|
|
955
|
+
* not user commits. They must not depend on ambient `commit.gpgsign` config: in
|
|
956
|
+
* signed-by-default environments without a usable signing key, an unguarded
|
|
957
|
+
* `git commit` fails and leaves `tbd-sync` unborn, which the f04 fail-closed
|
|
958
|
+
* health check then surfaces as "worktree corrupted" on every command.
|
|
959
|
+
*/
|
|
960
|
+
async function gitCommit(workdir, ...args) {
|
|
961
|
+
return git("-c", "commit.gpgsign=false", "-C", workdir, "commit", ...args);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
977
964
|
* Minimum Git version required.
|
|
978
965
|
* Git 2.42 (August 2023) introduced `git worktree add --orphan` which tbd requires.
|
|
979
966
|
*/
|
|
@@ -1088,23 +1075,6 @@ function getUpgradeInstructions(currentVersion) {
|
|
|
1088
1075
|
return `Git ${versionStr} detected. Git ${MIN_GIT_VERSION}+ required for tbd.\nUpgrade: ${upgradeUrl}`;
|
|
1089
1076
|
}
|
|
1090
1077
|
/**
|
|
1091
|
-
* Execute a git command with isolated index.
|
|
1092
|
-
* This protects the user's staging area during sync operations.
|
|
1093
|
-
*
|
|
1094
|
-
* See: tbd-design.md §3.3.2 Writing to Sync Branch
|
|
1095
|
-
*/
|
|
1096
|
-
async function withIsolatedIndex(fn) {
|
|
1097
|
-
const isolatedIndex = join(await git("rev-parse", "--git-dir"), "tbd-index");
|
|
1098
|
-
const originalIndex = process.env.GIT_INDEX_FILE;
|
|
1099
|
-
try {
|
|
1100
|
-
process.env.GIT_INDEX_FILE = isolatedIndex;
|
|
1101
|
-
return await fn();
|
|
1102
|
-
} finally {
|
|
1103
|
-
if (originalIndex) process.env.GIT_INDEX_FILE = originalIndex;
|
|
1104
|
-
else delete process.env.GIT_INDEX_FILE;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
/**
|
|
1108
1078
|
* Field-level merge strategies for Issue fields.
|
|
1109
1079
|
* See: tbd-design.md §3.5 Merge Rules
|
|
1110
1080
|
*/
|
|
@@ -1330,15 +1300,15 @@ async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
|
|
|
1330
1300
|
/**
|
|
1331
1301
|
* Get the current branch name.
|
|
1332
1302
|
*/
|
|
1333
|
-
async function getCurrentBranch() {
|
|
1334
|
-
return git("rev-parse", "--abbrev-ref", "HEAD");
|
|
1303
|
+
async function getCurrentBranch(baseDir) {
|
|
1304
|
+
return git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--abbrev-ref", "HEAD");
|
|
1335
1305
|
}
|
|
1336
1306
|
/**
|
|
1337
1307
|
* Check if a branch exists locally.
|
|
1338
1308
|
*/
|
|
1339
|
-
async function branchExists(branch) {
|
|
1309
|
+
async function branchExists(branch, baseDir) {
|
|
1340
1310
|
try {
|
|
1341
|
-
await git("rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1311
|
+
await git(...baseDir ? ["-C", baseDir] : [], "rev-parse", "--verify", `refs/heads/${branch}`);
|
|
1342
1312
|
return true;
|
|
1343
1313
|
} catch {
|
|
1344
1314
|
return false;
|
|
@@ -1347,19 +1317,38 @@ async function branchExists(branch) {
|
|
|
1347
1317
|
/**
|
|
1348
1318
|
* Check if a remote branch exists.
|
|
1349
1319
|
*/
|
|
1350
|
-
async function remoteBranchExists(remote, branch) {
|
|
1320
|
+
async function remoteBranchExists(remote, branch, baseDir) {
|
|
1351
1321
|
try {
|
|
1352
|
-
await git("ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1322
|
+
await git(...baseDir ? ["-C", baseDir] : [], "ls-remote", "--exit-code", remote, `refs/heads/${branch}`);
|
|
1353
1323
|
return true;
|
|
1354
1324
|
} catch {
|
|
1355
1325
|
return false;
|
|
1356
1326
|
}
|
|
1357
1327
|
}
|
|
1328
|
+
async function pathExists(path) {
|
|
1329
|
+
try {
|
|
1330
|
+
await access(path);
|
|
1331
|
+
return true;
|
|
1332
|
+
} catch {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
async function pathsReferToSameLocation(a, b) {
|
|
1337
|
+
if (normalize(a) === normalize(b)) return true;
|
|
1338
|
+
try {
|
|
1339
|
+
return normalize(await realpath(a)) === normalize(await realpath(b));
|
|
1340
|
+
} catch {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
async function getSharedPaths(baseDir) {
|
|
1345
|
+
return resolveSharedTbdPaths(baseDir);
|
|
1346
|
+
}
|
|
1358
1347
|
/**
|
|
1359
1348
|
* Check if the hidden worktree exists and is valid.
|
|
1360
1349
|
*/
|
|
1361
1350
|
async function worktreeExists(baseDir) {
|
|
1362
|
-
const worktreePath =
|
|
1351
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1363
1352
|
try {
|
|
1364
1353
|
await access(worktreePath);
|
|
1365
1354
|
await access(join(worktreePath, ".git"));
|
|
@@ -1373,15 +1362,15 @@ async function worktreeExists(baseDir) {
|
|
|
1373
1362
|
* See: tbd-design.md §2.3 Worktree Lifecycle
|
|
1374
1363
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §3
|
|
1375
1364
|
*/
|
|
1376
|
-
async function checkWorktreeHealth(baseDir) {
|
|
1377
|
-
const worktreePath =
|
|
1365
|
+
async function checkWorktreeHealth(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1366
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1378
1367
|
try {
|
|
1379
1368
|
const lines = (await git("-C", baseDir, "worktree", "list", "--porcelain")).split("\n");
|
|
1380
1369
|
let foundWorktree = false;
|
|
1381
1370
|
let isPrunable = false;
|
|
1382
1371
|
for (let i = 0; i < lines.length; i++) {
|
|
1383
1372
|
const line = lines[i];
|
|
1384
|
-
if (line?.startsWith("worktree ") && line.
|
|
1373
|
+
if (line?.startsWith("worktree ") && await pathsReferToSameLocation(line.slice(9), worktreePath)) {
|
|
1385
1374
|
foundWorktree = true;
|
|
1386
1375
|
for (let j = i + 1; j < lines.length && !lines[j]?.startsWith("worktree "); j++) if (lines[j]?.startsWith("prunable")) {
|
|
1387
1376
|
isPrunable = true;
|
|
@@ -1449,6 +1438,14 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1449
1438
|
} catch {
|
|
1450
1439
|
branch = null;
|
|
1451
1440
|
}
|
|
1441
|
+
if (branch !== syncBranch) return {
|
|
1442
|
+
exists: true,
|
|
1443
|
+
valid: false,
|
|
1444
|
+
status: "corrupted",
|
|
1445
|
+
branch,
|
|
1446
|
+
commit,
|
|
1447
|
+
error: branch === null ? `Shared worktree is detached; expected branch ${syncBranch}` : `Shared worktree is on ${branch}; expected branch ${syncBranch}`
|
|
1448
|
+
};
|
|
1452
1449
|
return {
|
|
1453
1450
|
exists: true,
|
|
1454
1451
|
valid: true,
|
|
@@ -1467,22 +1464,110 @@ async function checkWorktreeHealth(baseDir) {
|
|
|
1467
1464
|
};
|
|
1468
1465
|
}
|
|
1469
1466
|
}
|
|
1467
|
+
function isLegacyDataSyncWorktreePath(path, sharedWorktreePath) {
|
|
1468
|
+
const normalized = normalize(path);
|
|
1469
|
+
if (normalized === normalize(sharedWorktreePath)) return false;
|
|
1470
|
+
return basename(normalized) === WORKTREE_DIR_NAME && basename(dirname(normalized)) === TBD_DIR;
|
|
1471
|
+
}
|
|
1472
|
+
async function listLegacyWorktreePaths(baseDir, sharedWorktreePath) {
|
|
1473
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1474
|
+
try {
|
|
1475
|
+
const worktreeList = await git("-C", baseDir, "worktree", "list", "--porcelain");
|
|
1476
|
+
for (const line of worktreeList.split("\n")) {
|
|
1477
|
+
if (!line.startsWith("worktree ")) continue;
|
|
1478
|
+
const worktreePath = line.slice(9);
|
|
1479
|
+
if (isLegacyDataSyncWorktreePath(worktreePath, sharedWorktreePath)) paths.add(worktreePath);
|
|
1480
|
+
}
|
|
1481
|
+
} catch {}
|
|
1482
|
+
const currentCheckoutLegacyPath = join(baseDir, LEGACY_WORKTREE_DIR);
|
|
1483
|
+
if (currentCheckoutLegacyPath !== sharedWorktreePath && await pathExists(currentCheckoutLegacyPath)) paths.add(currentCheckoutLegacyPath);
|
|
1484
|
+
return Array.from(paths);
|
|
1485
|
+
}
|
|
1486
|
+
async function preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch) {
|
|
1487
|
+
if (!await pathExists(join(legacyPath, ".git"))) return;
|
|
1488
|
+
if ((await git("-C", legacyPath, "status", "--porcelain").catch(() => "")).trim()) {
|
|
1489
|
+
await git("-C", legacyPath, "add", "-A");
|
|
1490
|
+
await gitCommit(legacyPath, "--no-verify", "-m", "tbd: preserve legacy sync data").catch((error) => {
|
|
1491
|
+
if (!(error instanceof Error ? error.message : String(error)).includes("nothing to commit")) throw error;
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
const head = await git("-C", legacyPath, "rev-parse", "HEAD").catch(() => "");
|
|
1495
|
+
if (!head) return;
|
|
1496
|
+
const branchHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1497
|
+
if (!branchHead) {
|
|
1498
|
+
await git("-C", baseDir, "branch", syncBranch, head);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
if (head === branchHead) return;
|
|
1502
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", branchHead, head).then(() => true).catch(() => false)) {
|
|
1503
|
+
await git("-C", baseDir, "update-ref", `refs/heads/${syncBranch}`, head);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (await git("-C", baseDir, "merge-base", "--is-ancestor", head, branchHead).then(() => true).catch(() => false)) return;
|
|
1507
|
+
const backupBranch = `tbd-legacy-preserve-${nowFilenameTimestamp()}`;
|
|
1508
|
+
await git("-C", baseDir, "branch", backupBranch, head);
|
|
1509
|
+
throw new Error(`Legacy sync worktree at ${legacyPath} diverges from ${syncBranch}. Preserved its HEAD as ${backupBranch}; run 'tbd doctor --fix' after reviewing the backup branch.`);
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Preserve and remove f03 per-checkout sync worktrees before creating the shared worktree.
|
|
1513
|
+
*/
|
|
1514
|
+
async function migrateLegacyWorktreesToShared(baseDir, syncBranch = SYNC_BRANCH) {
|
|
1515
|
+
const { sharedWorktreePath } = await getSharedPaths(baseDir);
|
|
1516
|
+
const legacyPaths = await listLegacyWorktreePaths(baseDir, sharedWorktreePath);
|
|
1517
|
+
let migrated = 0;
|
|
1518
|
+
try {
|
|
1519
|
+
for (const legacyPath of legacyPaths) {
|
|
1520
|
+
await preserveLegacyWorktreeHead(baseDir, legacyPath, syncBranch);
|
|
1521
|
+
try {
|
|
1522
|
+
await git("-C", baseDir, "worktree", "remove", legacyPath, "--force");
|
|
1523
|
+
} catch {
|
|
1524
|
+
await rm(legacyPath, {
|
|
1525
|
+
recursive: true,
|
|
1526
|
+
force: true
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
migrated += 1;
|
|
1530
|
+
}
|
|
1531
|
+
if (legacyPaths.length > 0) await git("-C", baseDir, "worktree", "prune");
|
|
1532
|
+
return {
|
|
1533
|
+
success: true,
|
|
1534
|
+
migrated
|
|
1535
|
+
};
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
return {
|
|
1538
|
+
success: false,
|
|
1539
|
+
migrated,
|
|
1540
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1470
1544
|
/**
|
|
1471
1545
|
* Initialize the hidden worktree for the tbd-sync branch.
|
|
1472
1546
|
* Follows the decision tree from tbd-design.md §2.3.
|
|
1473
1547
|
*
|
|
1548
|
+
* MUST be called while holding `withSharedDataSyncLock` — it migrates legacy
|
|
1549
|
+
* per-checkout worktrees and creates the shared attached worktree on tbd-sync,
|
|
1550
|
+
* so concurrent callers can otherwise race branch ownership and migration.
|
|
1551
|
+
*
|
|
1474
1552
|
* @param baseDir - The base directory of the repository
|
|
1475
1553
|
* @param remote - The remote name (default: 'origin')
|
|
1476
1554
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1477
1555
|
* @returns Path to the worktree or error message
|
|
1478
1556
|
*/
|
|
1479
1557
|
async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1480
|
-
const
|
|
1558
|
+
const paths = await getSharedPaths(baseDir);
|
|
1559
|
+
const worktreePath = paths.sharedWorktreePath;
|
|
1481
1560
|
if (await worktreeExists(baseDir)) return {
|
|
1482
1561
|
success: true,
|
|
1483
1562
|
path: worktreePath,
|
|
1484
1563
|
created: false
|
|
1485
1564
|
};
|
|
1565
|
+
await mkdir(paths.sharedTbdDir, { recursive: true });
|
|
1566
|
+
const migrationResult = await migrateLegacyWorktreesToShared(baseDir, syncBranch);
|
|
1567
|
+
if (!migrationResult.success) return {
|
|
1568
|
+
success: false,
|
|
1569
|
+
error: migrationResult.error
|
|
1570
|
+
};
|
|
1486
1571
|
try {
|
|
1487
1572
|
await rm(worktreePath, {
|
|
1488
1573
|
recursive: true,
|
|
@@ -1490,7 +1575,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1490
1575
|
});
|
|
1491
1576
|
} catch {}
|
|
1492
1577
|
try {
|
|
1493
|
-
if (await branchExists(syncBranch)) {
|
|
1578
|
+
if (await branchExists(syncBranch, baseDir)) {
|
|
1494
1579
|
await git("-C", baseDir, "worktree", "add", worktreePath, syncBranch);
|
|
1495
1580
|
return {
|
|
1496
1581
|
success: true,
|
|
@@ -1498,7 +1583,7 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1498
1583
|
created: true
|
|
1499
1584
|
};
|
|
1500
1585
|
}
|
|
1501
|
-
if (await remoteBranchExists(remote, syncBranch)) {
|
|
1586
|
+
if (await remoteBranchExists(remote, syncBranch, baseDir)) {
|
|
1502
1587
|
await git("-C", baseDir, "fetch", remote, syncBranch);
|
|
1503
1588
|
await git("-C", baseDir, "worktree", "add", "-b", syncBranch, worktreePath, `${remote}/${syncBranch}`);
|
|
1504
1589
|
return {
|
|
@@ -1513,12 +1598,12 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1513
1598
|
await mkdir(join(dataSyncPath, "issues"), { recursive: true });
|
|
1514
1599
|
await mkdir(join(dataSyncPath, "mappings"), { recursive: true });
|
|
1515
1600
|
await mkdir(join(dataSyncPath, "attic", "conflicts"), { recursive: true });
|
|
1516
|
-
await writeFile(join(dataSyncPath, "meta.yml"),
|
|
1601
|
+
await writeFile(join(dataSyncPath, "meta.yml"), `schema_version: ${DATA_SYNC_SCHEMA_VERSION}\n`);
|
|
1517
1602
|
await writeFile(join(dataSyncPath, "issues", ".gitkeep"), "");
|
|
1518
1603
|
await writeFile(join(dataSyncPath, "mappings", ".gitkeep"), "");
|
|
1519
1604
|
await writeFile(join(dataSyncPath, "mappings", ".gitattributes"), "ids.yml merge=union\n");
|
|
1520
1605
|
await git("-C", worktreePath, "add", ".");
|
|
1521
|
-
await
|
|
1606
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "Initialize tbd-sync branch");
|
|
1522
1607
|
return {
|
|
1523
1608
|
success: true,
|
|
1524
1609
|
path: worktreePath,
|
|
@@ -1538,16 +1623,17 @@ async function initWorktree(baseDir, remote = "origin", syncBranch = SYNC_BRANCH
|
|
|
1538
1623
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1539
1624
|
* @returns Health status indicating if branch exists and has commits
|
|
1540
1625
|
*/
|
|
1541
|
-
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
1626
|
+
async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH, baseDir) {
|
|
1627
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1542
1628
|
try {
|
|
1543
1629
|
return {
|
|
1544
1630
|
exists: true,
|
|
1545
1631
|
orphaned: false,
|
|
1546
|
-
head: (await git("rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1632
|
+
head: (await git(...dirArgs, "rev-parse", `refs/heads/${syncBranch}`)).trim()
|
|
1547
1633
|
};
|
|
1548
1634
|
} catch {
|
|
1549
1635
|
try {
|
|
1550
|
-
await git("show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1636
|
+
await git(...dirArgs, "show-ref", "--verify", `refs/heads/${syncBranch}`);
|
|
1551
1637
|
return {
|
|
1552
1638
|
exists: true,
|
|
1553
1639
|
orphaned: true
|
|
@@ -1568,14 +1654,15 @@ async function checkLocalBranchHealth(syncBranch = SYNC_BRANCH) {
|
|
|
1568
1654
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1569
1655
|
* @returns Health status indicating if remote branch exists and divergence state
|
|
1570
1656
|
*/
|
|
1571
|
-
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1657
|
+
async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
1658
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1572
1659
|
try {
|
|
1573
|
-
await git("fetch", remote, syncBranch);
|
|
1574
|
-
const remoteHead = (await git("rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1660
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1661
|
+
const remoteHead = (await git(...dirArgs, "rev-parse", `refs/remotes/${remote}/${syncBranch}`)).trim();
|
|
1575
1662
|
let diverged = false;
|
|
1576
1663
|
try {
|
|
1577
|
-
const mergeBase = await git("merge-base", syncBranch, `${remote}/${syncBranch}`);
|
|
1578
|
-
const localHead = await git("rev-parse", syncBranch);
|
|
1664
|
+
const mergeBase = await git(...dirArgs, "merge-base", syncBranch, `${remote}/${syncBranch}`);
|
|
1665
|
+
const localHead = await git(...dirArgs, "rev-parse", syncBranch);
|
|
1579
1666
|
diverged = mergeBase.trim() !== localHead.trim() && mergeBase.trim() !== remoteHead;
|
|
1580
1667
|
} catch {
|
|
1581
1668
|
diverged = false;
|
|
@@ -1602,7 +1689,8 @@ async function checkRemoteBranchHealth(remote = "origin", syncBranch = SYNC_BRAN
|
|
|
1602
1689
|
* @returns Consistency status with HEAD comparisons and ahead/behind counts
|
|
1603
1690
|
*/
|
|
1604
1691
|
async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote = "origin") {
|
|
1605
|
-
const
|
|
1692
|
+
const { sharedWorktreePath: worktreePath } = await getSharedPaths(baseDir);
|
|
1693
|
+
const worktreeHead = await git("-C", worktreePath, "rev-parse", "HEAD").catch(() => "");
|
|
1606
1694
|
const localHead = await git("-C", baseDir, "rev-parse", syncBranch).catch(() => "");
|
|
1607
1695
|
const remoteHead = await git("-C", baseDir, "rev-parse", `${remote}/${syncBranch}`).catch(() => "");
|
|
1608
1696
|
let localAhead = 0;
|
|
@@ -1634,10 +1722,12 @@ async function checkSyncConsistency(baseDir, syncBranch = SYNC_BRANCH, remote =
|
|
|
1634
1722
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1635
1723
|
* @returns Number of issue files on the remote branch, or null if branch doesn't exist
|
|
1636
1724
|
*/
|
|
1637
|
-
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1725
|
+
async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH, baseDir) {
|
|
1726
|
+
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1638
1727
|
try {
|
|
1639
|
-
await git("fetch", remote, syncBranch);
|
|
1640
|
-
const
|
|
1728
|
+
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1729
|
+
const remoteBranch = `${remote}/${syncBranch}`;
|
|
1730
|
+
const output = await git(...dirArgs, "ls-tree", "-r", "--name-only", remoteBranch);
|
|
1641
1731
|
const issuesDir = `${TBD_DIR}/${DATA_SYNC_DIR_NAME}/issues/`;
|
|
1642
1732
|
return output.split("\n").filter(Boolean).filter((line) => line.startsWith(issuesDir) && line.endsWith(".md")).length;
|
|
1643
1733
|
} catch {
|
|
@@ -1652,6 +1742,10 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1652
1742
|
* - CORRUPTED: backup to .tbd/backups/, remove, then recreate
|
|
1653
1743
|
* - MISSING: just create
|
|
1654
1744
|
*
|
|
1745
|
+
* MUST be called while holding `withSharedDataSyncLock` — repair mutates
|
|
1746
|
+
* shared worktree and branch state and shares the same locking contract as
|
|
1747
|
+
* `initWorktree`.
|
|
1748
|
+
*
|
|
1655
1749
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
|
|
1656
1750
|
*
|
|
1657
1751
|
* @param baseDir - The base directory of the repository
|
|
@@ -1660,13 +1754,12 @@ async function countRemoteIssues(remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
|
1660
1754
|
* @param syncBranch - The sync branch name (default: 'tbd-sync')
|
|
1661
1755
|
*/
|
|
1662
1756
|
async function repairWorktree(baseDir, status, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
1663
|
-
const worktreePath =
|
|
1757
|
+
const { sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1664
1758
|
try {
|
|
1665
1759
|
if (status === "missing" || status === "prunable") await git("-C", baseDir, "worktree", "prune");
|
|
1666
1760
|
if (status === "corrupted") {
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
const backupPath = join(backupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1761
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
1762
|
+
const backupPath = join(sharedBackupsDir, `corrupted-worktree-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1670
1763
|
try {
|
|
1671
1764
|
await cp(worktreePath, backupPath, { recursive: true });
|
|
1672
1765
|
} catch {}
|
|
@@ -1697,9 +1790,15 @@ async function repairWorktree(baseDir, status, remote = "origin", syncBranch = S
|
|
|
1697
1790
|
* @returns true if worktree was detached and repaired, false if already attached
|
|
1698
1791
|
*/
|
|
1699
1792
|
async function ensureWorktreeAttached(worktreePath) {
|
|
1793
|
+
return ensureWorktreeAttachedToBranch(worktreePath, SYNC_BRANCH);
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Ensure worktree is attached to the requested sync branch, not detached HEAD.
|
|
1797
|
+
*/
|
|
1798
|
+
async function ensureWorktreeAttachedToBranch(worktreePath, syncBranch = SYNC_BRANCH) {
|
|
1700
1799
|
try {
|
|
1701
|
-
if (
|
|
1702
|
-
await git("-C", worktreePath, "checkout",
|
|
1800
|
+
if (await git("-C", worktreePath, "branch", "--show-current").catch(() => "") !== syncBranch) {
|
|
1801
|
+
await git("-C", worktreePath, "checkout", syncBranch);
|
|
1703
1802
|
return true;
|
|
1704
1803
|
}
|
|
1705
1804
|
return false;
|
|
@@ -1725,28 +1824,23 @@ async function ensureWorktreeAttached(worktreePath) {
|
|
|
1725
1824
|
*/
|
|
1726
1825
|
async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
1727
1826
|
const wrongPath = join(baseDir, TBD_DIR, DATA_SYNC_DIR_NAME);
|
|
1728
|
-
const
|
|
1729
|
-
const worktreePath = join(baseDir, WORKTREE_DIR);
|
|
1827
|
+
const { sharedDataSyncDir: correctPath, sharedWorktreePath: worktreePath, sharedBackupsDir } = await getSharedPaths(baseDir);
|
|
1730
1828
|
try {
|
|
1731
1829
|
await ensureWorktreeAttached(worktreePath);
|
|
1732
1830
|
const wrongIssuesPath = join(wrongPath, "issues");
|
|
1733
1831
|
const wrongMappingsPath = join(wrongPath, "mappings");
|
|
1734
1832
|
let issueFiles = [];
|
|
1735
1833
|
let mappingFiles = [];
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
1739
|
-
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1740
|
-
} catch {}
|
|
1834
|
+
issueFiles = await readdir(wrongIssuesPath).catch(() => []);
|
|
1835
|
+
mappingFiles = await readdir(wrongMappingsPath).catch(() => []);
|
|
1741
1836
|
issueFiles = issueFiles.filter((f) => f !== ".gitkeep");
|
|
1742
1837
|
mappingFiles = mappingFiles.filter((f) => f !== ".gitkeep");
|
|
1743
1838
|
if (issueFiles.length === 0 && mappingFiles.length === 0) return {
|
|
1744
1839
|
success: true,
|
|
1745
1840
|
migratedCount: 0
|
|
1746
1841
|
};
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const backupPath = join(backupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1842
|
+
await mkdir(sharedBackupsDir, { recursive: true });
|
|
1843
|
+
const backupPath = join(sharedBackupsDir, `data-sync-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}`);
|
|
1750
1844
|
await cp(wrongPath, backupPath, { recursive: true });
|
|
1751
1845
|
const correctIssuesPath = join(correctPath, "issues");
|
|
1752
1846
|
const correctMappingsPath = join(correctPath, "mappings");
|
|
@@ -1754,8 +1848,6 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1754
1848
|
await mkdir(correctMappingsPath, { recursive: true });
|
|
1755
1849
|
for (const file of issueFiles) await cp(join(wrongIssuesPath, file), join(correctIssuesPath, file));
|
|
1756
1850
|
for (const file of mappingFiles) if (file === "ids.yml") {
|
|
1757
|
-
const { readFile } = await import("node:fs/promises");
|
|
1758
|
-
const { loadIdMapping, mergeIdMappings, saveIdMapping, resolveIdMappingConflicts } = await import("./id-mapping-Ctfl_nc1.mjs");
|
|
1759
1851
|
const sourceMapping = resolveIdMappingConflicts(await readFile(join(wrongMappingsPath, file), "utf-8"));
|
|
1760
1852
|
let targetMapping;
|
|
1761
1853
|
try {
|
|
@@ -1767,7 +1859,7 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1767
1859
|
} else await cp(join(wrongMappingsPath, file), join(correctMappingsPath, file));
|
|
1768
1860
|
const totalFiles = issueFiles.length + mappingFiles.length;
|
|
1769
1861
|
await git("-C", worktreePath, "add", "-A");
|
|
1770
|
-
if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await
|
|
1862
|
+
if (await git("-C", worktreePath, "diff", "--cached", "--quiet").then(() => false).catch(() => true)) await gitCommit(worktreePath, "--no-verify", "-m", `tbd: migrate ${totalFiles} file(s) from incorrect location`);
|
|
1771
1863
|
if (removeSource) {
|
|
1772
1864
|
for (const file of issueFiles) await rm(join(wrongIssuesPath, file));
|
|
1773
1865
|
for (const file of mappingFiles) await rm(join(wrongMappingsPath, file));
|
|
@@ -1786,6 +1878,83 @@ async function migrateDataToWorktree(baseDir, removeSource = false) {
|
|
|
1786
1878
|
}
|
|
1787
1879
|
}
|
|
1788
1880
|
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/file/common-dir-layout.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* Git common-dir layout metadata for shared issue sync machinery.
|
|
1885
|
+
*/
|
|
1886
|
+
/**
|
|
1887
|
+
* Error thrown when common-dir layout metadata cannot be used safely.
|
|
1888
|
+
*/
|
|
1889
|
+
var CommonDirLayoutError = class extends Error {
|
|
1890
|
+
constructor(message) {
|
|
1891
|
+
super(message);
|
|
1892
|
+
this.name = "CommonDirLayoutError";
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
/**
|
|
1896
|
+
* Read $GIT_COMMON_DIR/tbd/layout.yml, returning null when it has not been created yet.
|
|
1897
|
+
*/
|
|
1898
|
+
async function readCommonDirLayout(layoutPath) {
|
|
1899
|
+
try {
|
|
1900
|
+
const content = await readFile(layoutPath, "utf-8");
|
|
1901
|
+
return CommonDirLayoutSchema.parse(parse(content));
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
if (error.code === "ENOENT") return null;
|
|
1904
|
+
throw new CommonDirLayoutError(`Invalid tbd common-dir layout metadata at ${layoutPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
/**
|
|
1908
|
+
* Validate that common-dir layout metadata matches the checkout config.
|
|
1909
|
+
*/
|
|
1910
|
+
function validateCommonDirLayout(layout, config) {
|
|
1911
|
+
if (!isCompatibleFormat(layout.tbd_format)) throw new CommonDirLayoutError(formatUpgradeMessage("Common-dir layout", layout.tbd_format, CURRENT_FORMAT));
|
|
1912
|
+
if (layout.tbd_format !== config.tbd_format) throw new CommonDirLayoutError(`Common-dir layout format '${layout.tbd_format}' does not match config format '${config.tbd_format}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
|
|
1913
|
+
const layoutStorage = layout.sync_storage;
|
|
1914
|
+
const configStorage = config.sync.storage;
|
|
1915
|
+
if (layoutStorage !== configStorage) throw new CommonDirLayoutError(`Common-dir sync storage '${layoutStorage}' does not match config storage '${configStorage}'. This indicates a partial migration or manual edit. Run 'tbd doctor --fix' to rewrite the layout from the current config. (Manual fallback: rm "$(git rev-parse --git-common-dir)/tbd/layout.yml".)`);
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Write common-dir layout metadata using the synchronized tbd_format ID.
|
|
1919
|
+
*/
|
|
1920
|
+
async function writeCommonDirLayout(paths, config, existing) {
|
|
1921
|
+
await mkdir(dirname(paths.sharedLayoutPath), { recursive: true });
|
|
1922
|
+
const timestamp = now();
|
|
1923
|
+
const layout = CommonDirLayoutSchema.parse({
|
|
1924
|
+
tbd_format: config.tbd_format,
|
|
1925
|
+
sync_storage: config.sync.storage,
|
|
1926
|
+
data_sync_worktree: "data-sync-worktree",
|
|
1927
|
+
lock_profile: "data-sync-v1",
|
|
1928
|
+
created_at: existing?.created_at ?? timestamp,
|
|
1929
|
+
updated_at: timestamp
|
|
1930
|
+
});
|
|
1931
|
+
const yaml = stringifyYaml(sortKeys(layout, COMMON_DIR_LAYOUT_FIELD_ORDER), {
|
|
1932
|
+
lineWidth: 0,
|
|
1933
|
+
sortMapEntries: false
|
|
1934
|
+
});
|
|
1935
|
+
await writeFile(paths.sharedLayoutPath, yaml);
|
|
1936
|
+
return layout;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Ensure layout metadata exists and matches the checkout config.
|
|
1940
|
+
*/
|
|
1941
|
+
async function ensureCommonDirLayout(paths, config) {
|
|
1942
|
+
const existing = await readCommonDirLayout(paths.sharedLayoutPath);
|
|
1943
|
+
if (existing) {
|
|
1944
|
+
validateCommonDirLayout(existing, config);
|
|
1945
|
+
return existing;
|
|
1946
|
+
}
|
|
1947
|
+
return writeCommonDirLayout(paths, config);
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Run a critical section while holding the repo-scoped data-sync lock.
|
|
1951
|
+
*/
|
|
1952
|
+
async function withSharedDataSyncLock(tbdRoot, fn) {
|
|
1953
|
+
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
1954
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
1955
|
+
return withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1789
1958
|
//#endregion
|
|
1790
1959
|
//#region src/cli/commands/init.ts
|
|
1791
1960
|
/**
|
|
@@ -1816,7 +1985,7 @@ Example:
|
|
|
1816
1985
|
tbd init --prefix=${prefix} --force`);
|
|
1817
1986
|
if (this.checkDryRun("Would initialize tbd repository", options)) return;
|
|
1818
1987
|
await this.execute(async () => {
|
|
1819
|
-
await initConfig(cwd, VERSION, options.prefix);
|
|
1988
|
+
const config = await initConfig(cwd, VERSION, options.prefix);
|
|
1820
1989
|
this.output.debug(`Created ${TBD_DIR}/config.yml with prefix '${options.prefix}'`);
|
|
1821
1990
|
await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
1822
1991
|
"# Installed documentation (regenerated on setup)",
|
|
@@ -1857,11 +2026,19 @@ Example:
|
|
|
1857
2026
|
if (error instanceof CLIError) throw error;
|
|
1858
2027
|
this.output.debug(`Git version check skipped: ${error.message}`);
|
|
1859
2028
|
}
|
|
1860
|
-
const worktreeResult = await
|
|
2029
|
+
const { worktreeResult, sharedPaths } = await withSharedDataSyncLock(cwd, async () => {
|
|
2030
|
+
const result = await initWorktree(cwd, remote, syncBranch);
|
|
2031
|
+
const paths = await resolveSharedTbdPaths(cwd);
|
|
2032
|
+
if (result.success) await writeCommonDirLayout(paths, config);
|
|
2033
|
+
return {
|
|
2034
|
+
worktreeResult: result,
|
|
2035
|
+
sharedPaths: paths
|
|
2036
|
+
};
|
|
2037
|
+
});
|
|
1861
2038
|
if (worktreeResult.success) {
|
|
1862
|
-
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${
|
|
1863
|
-
else this.output.debug(`Worktree already exists at ${
|
|
1864
|
-
const health = await checkWorktreeHealth(cwd);
|
|
2039
|
+
if (worktreeResult.created) this.output.debug(`Created hidden worktree at ${sharedPaths.sharedWorktreePath}`);
|
|
2040
|
+
else this.output.debug(`Worktree already exists at ${sharedPaths.sharedWorktreePath}`);
|
|
2041
|
+
const health = await checkWorktreeHealth(cwd, syncBranch);
|
|
1865
2042
|
if (!health.valid) this.output.warn(`Worktree created but failed verification (status: ${health.status}). Run 'tbd doctor' to diagnose.`);
|
|
1866
2043
|
} else this.output.debug(`Note: Worktree not created (${worktreeResult.error})`);
|
|
1867
2044
|
}, "Failed to initialize tbd");
|
|
@@ -1913,7 +2090,8 @@ function formatUnknownError(error) {
|
|
|
1913
2090
|
* Storage layer for issue files.
|
|
1914
2091
|
*
|
|
1915
2092
|
* Provides atomic file operations and issue CRUD operations.
|
|
1916
|
-
* All operations work on the
|
|
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/.
|
|
1917
2095
|
*
|
|
1918
2096
|
* See: tbd-design.md §3.2 Storage Layer
|
|
1919
2097
|
*/
|
|
@@ -2208,6 +2386,152 @@ function validateIssueTitle(title, options) {
|
|
|
2208
2386
|
return title;
|
|
2209
2387
|
}
|
|
2210
2388
|
|
|
2389
|
+
//#endregion
|
|
2390
|
+
//#region src/cli/lib/data-context.ts
|
|
2391
|
+
async function probeDataSyncReadiness(tbdRoot) {
|
|
2392
|
+
const { config, migrated, fromFormat } = await readConfigWithMigration(tbdRoot);
|
|
2393
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot);
|
|
2394
|
+
const layout = await readCommonDirLayout(sharedPaths.sharedLayoutPath);
|
|
2395
|
+
if (layout) validateCommonDirLayout(layout, config);
|
|
2396
|
+
const health = await checkWorktreeHealth(tbdRoot, config.sync.branch);
|
|
2397
|
+
return {
|
|
2398
|
+
config,
|
|
2399
|
+
migrated,
|
|
2400
|
+
fromFormat,
|
|
2401
|
+
sharedPaths,
|
|
2402
|
+
layout,
|
|
2403
|
+
health,
|
|
2404
|
+
ready: !migrated && layout !== null && health.valid
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* Apply any pending first-use initialization, migration, or repair to the shared
|
|
2409
|
+
* data-sync layout. MUST be called while holding `withSharedDataSyncLock` so that
|
|
2410
|
+
* worktree repair, layout writes, and migrated-config writes are serialized.
|
|
2411
|
+
*/
|
|
2412
|
+
async function ensureSharedDataSyncLayout(tbdRoot, probe) {
|
|
2413
|
+
let repairedWorktreeStatus;
|
|
2414
|
+
if (!probe.health.valid) if (probe.health.status === "missing" || probe.health.status === "prunable") {
|
|
2415
|
+
const repairResult = await repairWorktree(tbdRoot, probe.health.status, probe.config.sync.remote, probe.config.sync.branch);
|
|
2416
|
+
if (!repairResult.success) throw new Error(`Failed to initialize shared data-sync worktree: ${repairResult.error}`);
|
|
2417
|
+
repairedWorktreeStatus = probe.health.status;
|
|
2418
|
+
} else throw new Error(`Shared data-sync worktree is ${probe.health.status}: ${probe.health.error ?? "unknown error"}. Run 'tbd doctor --fix' to repair.`);
|
|
2419
|
+
await ensureCommonDirLayout(probe.sharedPaths, probe.config);
|
|
2420
|
+
if (probe.migrated) {
|
|
2421
|
+
await writeConfig(tbdRoot, probe.config);
|
|
2422
|
+
notifyConfigMigrated(probe.fromFormat, CURRENT_FORMAT);
|
|
2423
|
+
}
|
|
2424
|
+
return repairedWorktreeStatus;
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Emit a one-time stderr notice when this checkout's `.tbd/config.yml` was migrated
|
|
2428
|
+
* (typically `fXX → fYY`). The config bump is the "publish" step of the format
|
|
2429
|
+
* migration and lands as a tracked diff on the current branch; users on a sibling
|
|
2430
|
+
* worktree (and even on main) deserve to know that without having to discover the
|
|
2431
|
+
* diff themselves later.
|
|
2432
|
+
*
|
|
2433
|
+
* See: docs/tbd-format-versioning.md (internal contributor guide) and
|
|
2434
|
+
* plan-2026-05-17-shared-common-dir-sync-worktree.md.
|
|
2435
|
+
*/
|
|
2436
|
+
function notifyConfigMigrated(fromFormat, toFormat) {
|
|
2437
|
+
if (fromFormat === toFormat) return;
|
|
2438
|
+
const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
|
|
2439
|
+
process.stderr.write(`• tbd_format ${arrow}: .tbd/config.yml updated in this checkout. Commit on this branch or merge main to publish the format upgrade.\n`);
|
|
2440
|
+
}
|
|
2441
|
+
async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
|
|
2442
|
+
const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
|
|
2443
|
+
return {
|
|
2444
|
+
dataSyncDir,
|
|
2445
|
+
mapping: await loadIdMapping(dataSyncDir),
|
|
2446
|
+
config: probe.config,
|
|
2447
|
+
prefix: probe.config.display.id_prefix,
|
|
2448
|
+
sharedPaths: probe.sharedPaths,
|
|
2449
|
+
repairedWorktreeStatus
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Load all common data context needed by tbd commands.
|
|
2454
|
+
*
|
|
2455
|
+
* For writers this is called inside `withSharedDataSyncLock` by
|
|
2456
|
+
* `withDataSyncContext({ lock: true }, ...)`, so any ensure/migrate/repair work
|
|
2457
|
+
* is serialized.
|
|
2458
|
+
*/
|
|
2459
|
+
async function prepareDataSyncContext(tbdRoot) {
|
|
2460
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2461
|
+
return assembleDataContext(tbdRoot, probe, probe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, probe));
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Prepare shared data-sync context, optionally holding the repo-scoped lock.
|
|
2465
|
+
*
|
|
2466
|
+
* - `{ lock: true }` (writers): always acquire the lock, then prepare under it.
|
|
2467
|
+
* - `{ lock: false }` (readers): probe first; only acquire the lock if first-use
|
|
2468
|
+
* init/migrate/repair is actually required. Steady-state reads take no lock.
|
|
2469
|
+
*/
|
|
2470
|
+
async function withDataSyncContext(tbdRoot, options, fn) {
|
|
2471
|
+
if (options.lock) return withSharedDataSyncLock(tbdRoot, async () => fn(await prepareDataSyncContext(tbdRoot)));
|
|
2472
|
+
const probe = await probeDataSyncReadiness(tbdRoot);
|
|
2473
|
+
if (probe.ready) return fn(await assembleDataContext(tbdRoot, probe));
|
|
2474
|
+
return withSharedDataSyncLock(tbdRoot, async () => {
|
|
2475
|
+
const reProbe = await probeDataSyncReadiness(tbdRoot);
|
|
2476
|
+
return fn(await assembleDataContext(tbdRoot, reProbe, reProbe.ready ? void 0 : await ensureSharedDataSyncLayout(tbdRoot, reProbe)));
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Load the shared data-sync context for a read-only command.
|
|
2481
|
+
*
|
|
2482
|
+
* Read commands skip the shared lock when the layout and worktree are already
|
|
2483
|
+
* valid and the on-disk config needs no migration. When first-use
|
|
2484
|
+
* init/migrate/repair IS required, the underlying `withDataSyncContext` takes
|
|
2485
|
+
* the lock and runs the ensure path so concurrent readers cannot race
|
|
2486
|
+
* migration or worktree repair.
|
|
2487
|
+
*/
|
|
2488
|
+
async function loadDataContext(tbdRoot) {
|
|
2489
|
+
return withDataSyncContext(tbdRoot, { lock: false }, async (context) => context);
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Load unified command context with CLI options, data, and helper methods.
|
|
2493
|
+
*
|
|
2494
|
+
* This is the recommended way to initialize command context. It:
|
|
2495
|
+
* 1. Checks that tbd is initialized (calls requireInit)
|
|
2496
|
+
* 2. Loads data context (dataSyncDir, mapping, config, prefix)
|
|
2497
|
+
* 3. Extracts CLI context from Commander
|
|
2498
|
+
* 4. Provides helper methods like displayId() and resolveId()
|
|
2499
|
+
*
|
|
2500
|
+
* Usage:
|
|
2501
|
+
* ```ts
|
|
2502
|
+
* class MyHandler extends BaseCommand {
|
|
2503
|
+
* async run(id: string): Promise<void> {
|
|
2504
|
+
* const ctx = await loadFullContext(this.command);
|
|
2505
|
+
* const internalId = ctx.resolveId(id);
|
|
2506
|
+
* const issue = await readIssue(ctx.dataSyncDir, internalId);
|
|
2507
|
+
* console.log(ctx.displayId(issue.id));
|
|
2508
|
+
* }
|
|
2509
|
+
* }
|
|
2510
|
+
* ```
|
|
2511
|
+
*
|
|
2512
|
+
* @param command - The Commander command instance
|
|
2513
|
+
* @throws Error if tbd is not initialized or resources fail to load
|
|
2514
|
+
*/
|
|
2515
|
+
async function loadFullContext(command) {
|
|
2516
|
+
const tbdRoot = await requireInit();
|
|
2517
|
+
const cli = getCommandContext(command);
|
|
2518
|
+
const dataCtx = await loadDataContext(tbdRoot);
|
|
2519
|
+
return {
|
|
2520
|
+
...dataCtx,
|
|
2521
|
+
cli,
|
|
2522
|
+
displayId(internalId) {
|
|
2523
|
+
return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
|
|
2524
|
+
},
|
|
2525
|
+
resolveId(inputId) {
|
|
2526
|
+
try {
|
|
2527
|
+
return resolveToInternalId(inputId, dataCtx.mapping);
|
|
2528
|
+
} catch {
|
|
2529
|
+
throw new NotFoundError("Issue", inputId);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2211
2535
|
//#endregion
|
|
2212
2536
|
//#region src/cli/commands/create.ts
|
|
2213
2537
|
/**
|
|
@@ -2252,52 +2576,52 @@ var CreateHandler = class extends BaseCommand {
|
|
|
2252
2576
|
let prefix;
|
|
2253
2577
|
let issue;
|
|
2254
2578
|
await this.execute(async () => {
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
throw new ValidationError(`Invalid parent ID: ${options.parent}`);
|
|
2265
|
-
}
|
|
2266
|
-
if (!specPath && parentId) {
|
|
2267
|
-
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2268
|
-
if (parentIssue.spec_path) specPath = parentIssue.spec_path;
|
|
2269
|
-
}
|
|
2270
|
-
issue = {
|
|
2271
|
-
type: "is",
|
|
2272
|
-
id,
|
|
2273
|
-
version: 1,
|
|
2274
|
-
title: validatedTitle,
|
|
2275
|
-
kind,
|
|
2276
|
-
status: "open",
|
|
2277
|
-
priority,
|
|
2278
|
-
labels: options.label ?? [],
|
|
2279
|
-
dependencies: [],
|
|
2280
|
-
created_at: timestamp,
|
|
2281
|
-
updated_at: timestamp,
|
|
2282
|
-
description: description ?? void 0,
|
|
2283
|
-
assignee: options.assignee ?? void 0,
|
|
2284
|
-
due_date: options.due ?? void 0,
|
|
2285
|
-
deferred_until: options.defer ?? void 0,
|
|
2286
|
-
parent_id: parentId,
|
|
2287
|
-
spec_path: specPath
|
|
2288
|
-
};
|
|
2289
|
-
await writeIssue(dataSyncDir, issue);
|
|
2290
|
-
await saveIdMapping(dataSyncDir, mapping);
|
|
2291
|
-
if (parentId) try {
|
|
2292
|
-
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2293
|
-
const hints = parentIssue.child_order_hints ?? [];
|
|
2294
|
-
if (!hints.includes(id)) {
|
|
2295
|
-
parentIssue.child_order_hints = [...hints, id];
|
|
2296
|
-
parentIssue.version += 1;
|
|
2297
|
-
parentIssue.updated_at = timestamp;
|
|
2298
|
-
await writeIssue(dataSyncDir, parentIssue);
|
|
2579
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
|
|
2580
|
+
prefix = config.display.id_prefix;
|
|
2581
|
+
shortId = generateUniqueShortId(mapping);
|
|
2582
|
+
addIdMapping(mapping, ulid, shortId);
|
|
2583
|
+
let parentId;
|
|
2584
|
+
if (options.parent) try {
|
|
2585
|
+
parentId = resolveToInternalId(options.parent, mapping);
|
|
2586
|
+
} catch {
|
|
2587
|
+
throw new ValidationError(`Invalid parent ID: ${options.parent}`);
|
|
2299
2588
|
}
|
|
2300
|
-
|
|
2589
|
+
if (!specPath && parentId) {
|
|
2590
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2591
|
+
if (parentIssue.spec_path) specPath = parentIssue.spec_path;
|
|
2592
|
+
}
|
|
2593
|
+
issue = {
|
|
2594
|
+
type: "is",
|
|
2595
|
+
id,
|
|
2596
|
+
version: 1,
|
|
2597
|
+
title: validatedTitle,
|
|
2598
|
+
kind,
|
|
2599
|
+
status: "open",
|
|
2600
|
+
priority,
|
|
2601
|
+
labels: options.label ?? [],
|
|
2602
|
+
dependencies: [],
|
|
2603
|
+
created_at: timestamp,
|
|
2604
|
+
updated_at: timestamp,
|
|
2605
|
+
description: description ?? void 0,
|
|
2606
|
+
assignee: options.assignee ?? void 0,
|
|
2607
|
+
due_date: options.due ?? void 0,
|
|
2608
|
+
deferred_until: options.defer ?? void 0,
|
|
2609
|
+
parent_id: parentId,
|
|
2610
|
+
spec_path: specPath
|
|
2611
|
+
};
|
|
2612
|
+
await writeIssue(dataSyncDir, issue);
|
|
2613
|
+
await saveIdMapping(dataSyncDir, mapping);
|
|
2614
|
+
if (parentId) try {
|
|
2615
|
+
const parentIssue = await readIssue(dataSyncDir, parentId);
|
|
2616
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
2617
|
+
if (!hints.includes(id)) {
|
|
2618
|
+
parentIssue.child_order_hints = [...hints, id];
|
|
2619
|
+
parentIssue.version += 1;
|
|
2620
|
+
parentIssue.updated_at = timestamp;
|
|
2621
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
2622
|
+
}
|
|
2623
|
+
} catch {}
|
|
2624
|
+
});
|
|
2301
2625
|
}, "Failed to create issue");
|
|
2302
2626
|
const displayId = `${prefix}-${shortId}`;
|
|
2303
2627
|
this.output.data({
|
|
@@ -2342,77 +2666,6 @@ function applyLimit(items, limitOption) {
|
|
|
2342
2666
|
return items.slice(0, limit);
|
|
2343
2667
|
}
|
|
2344
2668
|
|
|
2345
|
-
//#endregion
|
|
2346
|
-
//#region src/cli/lib/data-context.ts
|
|
2347
|
-
/**
|
|
2348
|
-
* Load all common data context needed by tbd commands.
|
|
2349
|
-
*
|
|
2350
|
-
* This loads:
|
|
2351
|
-
* - dataSyncDir from resolveDataSyncDir()
|
|
2352
|
-
* - mapping from loadIdMapping()
|
|
2353
|
-
* - config from readConfig()
|
|
2354
|
-
* - prefix from config.display.id_prefix
|
|
2355
|
-
*
|
|
2356
|
-
* Call this once at the start of a command handler instead of
|
|
2357
|
-
* loading each piece separately.
|
|
2358
|
-
*
|
|
2359
|
-
* @param tbdRoot - The tbd repository root directory (from requireInit or findTbdRoot)
|
|
2360
|
-
* @throws Error if any of the resources fail to load
|
|
2361
|
-
*/
|
|
2362
|
-
async function loadDataContext(tbdRoot) {
|
|
2363
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
2364
|
-
const [mapping, config] = await Promise.all([loadIdMapping(dataSyncDir), readConfig(tbdRoot)]);
|
|
2365
|
-
return {
|
|
2366
|
-
dataSyncDir,
|
|
2367
|
-
mapping,
|
|
2368
|
-
config,
|
|
2369
|
-
prefix: config.display.id_prefix
|
|
2370
|
-
};
|
|
2371
|
-
}
|
|
2372
|
-
/**
|
|
2373
|
-
* Load unified command context with CLI options, data, and helper methods.
|
|
2374
|
-
*
|
|
2375
|
-
* This is the recommended way to initialize command context. It:
|
|
2376
|
-
* 1. Checks that tbd is initialized (calls requireInit)
|
|
2377
|
-
* 2. Loads data context (dataSyncDir, mapping, config, prefix)
|
|
2378
|
-
* 3. Extracts CLI context from Commander
|
|
2379
|
-
* 4. Provides helper methods like displayId() and resolveId()
|
|
2380
|
-
*
|
|
2381
|
-
* Usage:
|
|
2382
|
-
* ```ts
|
|
2383
|
-
* class MyHandler extends BaseCommand {
|
|
2384
|
-
* async run(id: string): Promise<void> {
|
|
2385
|
-
* const ctx = await loadFullContext(this.command);
|
|
2386
|
-
* const internalId = ctx.resolveId(id);
|
|
2387
|
-
* const issue = await readIssue(ctx.dataSyncDir, internalId);
|
|
2388
|
-
* console.log(ctx.displayId(issue.id));
|
|
2389
|
-
* }
|
|
2390
|
-
* }
|
|
2391
|
-
* ```
|
|
2392
|
-
*
|
|
2393
|
-
* @param command - The Commander command instance
|
|
2394
|
-
* @throws Error if tbd is not initialized or resources fail to load
|
|
2395
|
-
*/
|
|
2396
|
-
async function loadFullContext(command) {
|
|
2397
|
-
const tbdRoot = await requireInit();
|
|
2398
|
-
const cli = getCommandContext(command);
|
|
2399
|
-
const dataCtx = await loadDataContext(tbdRoot);
|
|
2400
|
-
return {
|
|
2401
|
-
...dataCtx,
|
|
2402
|
-
cli,
|
|
2403
|
-
displayId(internalId) {
|
|
2404
|
-
return cli.debug ? formatDebugId(internalId, dataCtx.mapping, dataCtx.prefix) : formatDisplayId(internalId, dataCtx.mapping, dataCtx.prefix);
|
|
2405
|
-
},
|
|
2406
|
-
resolveId(inputId) {
|
|
2407
|
-
try {
|
|
2408
|
-
return resolveToInternalId(inputId, dataCtx.mapping);
|
|
2409
|
-
} catch {
|
|
2410
|
-
throw new NotFoundError("Issue", inputId);
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
};
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
2669
|
//#endregion
|
|
2417
2670
|
//#region src/lib/status.ts
|
|
2418
2671
|
/**
|
|
@@ -3060,81 +3313,83 @@ const showCommand = new Command("show").description("Show issue details").argume
|
|
|
3060
3313
|
var UpdateHandler = class extends BaseCommand {
|
|
3061
3314
|
async run(id, options) {
|
|
3062
3315
|
const tbdRoot = await requireInit();
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
let internalId;
|
|
3066
|
-
try {
|
|
3067
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3068
|
-
} catch {
|
|
3069
|
-
throw new NotFoundError("Issue", id);
|
|
3070
|
-
}
|
|
3071
|
-
let issue;
|
|
3072
|
-
try {
|
|
3073
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3074
|
-
} catch {
|
|
3075
|
-
throw new NotFoundError("Issue", id);
|
|
3076
|
-
}
|
|
3077
|
-
const updates = await this.parseUpdates(options, mapping, tbdRoot);
|
|
3078
|
-
if (updates === null) return;
|
|
3079
|
-
if (this.checkDryRun("Would update issue", {
|
|
3080
|
-
id: internalId,
|
|
3081
|
-
...updates
|
|
3082
|
-
})) return;
|
|
3083
|
-
const oldSpecPath = issue.spec_path;
|
|
3084
|
-
if (updates.title !== void 0) issue.title = updates.title;
|
|
3085
|
-
if (updates.status !== void 0) issue.status = updates.status;
|
|
3086
|
-
if (updates.kind !== void 0) issue.kind = updates.kind;
|
|
3087
|
-
if (updates.priority !== void 0) issue.priority = updates.priority;
|
|
3088
|
-
if (updates.assignee !== void 0) issue.assignee = updates.assignee;
|
|
3089
|
-
if (updates.description !== void 0) issue.description = updates.description;
|
|
3090
|
-
if (updates.notes !== void 0) issue.notes = updates.notes;
|
|
3091
|
-
if (updates.due_date !== void 0) issue.due_date = updates.due_date;
|
|
3092
|
-
if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
|
|
3093
|
-
if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
|
|
3094
|
-
if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
|
|
3095
|
-
if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
|
|
3096
|
-
if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
|
|
3097
|
-
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3098
|
-
if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
|
|
3099
|
-
} catch {}
|
|
3100
|
-
if (updates.labels !== void 0) issue.labels = updates.labels;
|
|
3101
|
-
if (updates.addLabels && updates.addLabels.length > 0) {
|
|
3102
|
-
const labelsSet = new Set(issue.labels);
|
|
3103
|
-
for (const label of updates.addLabels) labelsSet.add(label);
|
|
3104
|
-
issue.labels = [...labelsSet];
|
|
3105
|
-
}
|
|
3106
|
-
if (updates.removeLabels && updates.removeLabels.length > 0) {
|
|
3107
|
-
const removeSet = new Set(updates.removeLabels);
|
|
3108
|
-
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3109
|
-
}
|
|
3110
|
-
issue.version += 1;
|
|
3111
|
-
issue.updated_at = now();
|
|
3316
|
+
let displayId = id;
|
|
3317
|
+
let didUpdate = false;
|
|
3112
3318
|
await this.execute(async () => {
|
|
3113
|
-
await
|
|
3319
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3320
|
+
let internalId;
|
|
3321
|
+
try {
|
|
3322
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3323
|
+
} catch {
|
|
3324
|
+
throw new NotFoundError("Issue", id);
|
|
3325
|
+
}
|
|
3326
|
+
let issue;
|
|
3327
|
+
try {
|
|
3328
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3329
|
+
} catch {
|
|
3330
|
+
throw new NotFoundError("Issue", id);
|
|
3331
|
+
}
|
|
3332
|
+
const updates = await this.parseUpdates(options, mapping, tbdRoot);
|
|
3333
|
+
if (updates === null) return;
|
|
3334
|
+
if (this.checkDryRun("Would update issue", {
|
|
3335
|
+
id: internalId,
|
|
3336
|
+
...updates
|
|
3337
|
+
})) return;
|
|
3338
|
+
const oldSpecPath = issue.spec_path;
|
|
3339
|
+
if (updates.title !== void 0) issue.title = updates.title;
|
|
3340
|
+
if (updates.status !== void 0) issue.status = updates.status;
|
|
3341
|
+
if (updates.kind !== void 0) issue.kind = updates.kind;
|
|
3342
|
+
if (updates.priority !== void 0) issue.priority = updates.priority;
|
|
3343
|
+
if (updates.assignee !== void 0) issue.assignee = updates.assignee;
|
|
3344
|
+
if (updates.description !== void 0) issue.description = updates.description;
|
|
3345
|
+
if (updates.notes !== void 0) issue.notes = updates.notes;
|
|
3346
|
+
if (updates.due_date !== void 0) issue.due_date = updates.due_date;
|
|
3347
|
+
if (updates.deferred_until !== void 0) issue.deferred_until = updates.deferred_until;
|
|
3348
|
+
if (updates.parent_id !== void 0) issue.parent_id = updates.parent_id;
|
|
3349
|
+
if (updates.spec_path !== void 0) issue.spec_path = updates.spec_path;
|
|
3350
|
+
if (updates.child_order_hints !== void 0) issue.child_order_hints = updates.child_order_hints;
|
|
3351
|
+
if (updates.parent_id && options.spec === void 0 && !issue.spec_path) try {
|
|
3352
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3353
|
+
if (parentIssue.spec_path) issue.spec_path = parentIssue.spec_path;
|
|
3354
|
+
} catch {}
|
|
3355
|
+
if (updates.labels !== void 0) issue.labels = updates.labels;
|
|
3356
|
+
if (updates.addLabels && updates.addLabels.length > 0) {
|
|
3357
|
+
const labelsSet = new Set(issue.labels);
|
|
3358
|
+
for (const label of updates.addLabels) labelsSet.add(label);
|
|
3359
|
+
issue.labels = [...labelsSet];
|
|
3360
|
+
}
|
|
3361
|
+
if (updates.removeLabels && updates.removeLabels.length > 0) {
|
|
3362
|
+
const removeSet = new Set(updates.removeLabels);
|
|
3363
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3364
|
+
}
|
|
3365
|
+
issue.version += 1;
|
|
3366
|
+
issue.updated_at = now();
|
|
3367
|
+
await writeIssue(dataSyncDir, issue);
|
|
3368
|
+
if (updates.parent_id) try {
|
|
3369
|
+
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3370
|
+
const hints = parentIssue.child_order_hints ?? [];
|
|
3371
|
+
if (!hints.includes(internalId)) {
|
|
3372
|
+
parentIssue.child_order_hints = [...hints, internalId];
|
|
3373
|
+
parentIssue.version += 1;
|
|
3374
|
+
parentIssue.updated_at = now();
|
|
3375
|
+
await writeIssue(dataSyncDir, parentIssue);
|
|
3376
|
+
}
|
|
3377
|
+
} catch {}
|
|
3378
|
+
if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
|
|
3379
|
+
const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
|
|
3380
|
+
const timestamp = now();
|
|
3381
|
+
for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
|
|
3382
|
+
child.spec_path = issue.spec_path;
|
|
3383
|
+
child.version += 1;
|
|
3384
|
+
child.updated_at = timestamp;
|
|
3385
|
+
await writeIssue(dataSyncDir, child);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3389
|
+
didUpdate = true;
|
|
3390
|
+
});
|
|
3114
3391
|
}, "Failed to update issue");
|
|
3115
|
-
if (
|
|
3116
|
-
const parentIssue = await readIssue(dataSyncDir, updates.parent_id);
|
|
3117
|
-
const hints = parentIssue.child_order_hints ?? [];
|
|
3118
|
-
if (!hints.includes(internalId)) {
|
|
3119
|
-
parentIssue.child_order_hints = [...hints, internalId];
|
|
3120
|
-
parentIssue.version += 1;
|
|
3121
|
-
parentIssue.updated_at = now();
|
|
3122
|
-
await writeIssue(dataSyncDir, parentIssue);
|
|
3123
|
-
}
|
|
3124
|
-
} catch {}
|
|
3125
|
-
if (updates.spec_path !== void 0 && issue.spec_path && issue.spec_path !== oldSpecPath) {
|
|
3126
|
-
const children = (await listIssues(dataSyncDir)).filter((i) => i.parent_id === issue.id);
|
|
3127
|
-
const timestamp = now();
|
|
3128
|
-
for (const child of children) if (!child.spec_path || child.spec_path === oldSpecPath) {
|
|
3129
|
-
child.spec_path = issue.spec_path;
|
|
3130
|
-
child.version += 1;
|
|
3131
|
-
child.updated_at = timestamp;
|
|
3132
|
-
await writeIssue(dataSyncDir, child);
|
|
3133
|
-
}
|
|
3134
|
-
}
|
|
3135
|
-
const showDebug = this.ctx.debug;
|
|
3136
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3137
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3392
|
+
if (!didUpdate) return;
|
|
3138
3393
|
this.output.data({
|
|
3139
3394
|
id: displayId,
|
|
3140
3395
|
updated: true
|
|
@@ -3262,48 +3517,47 @@ const updateCommand = new Command("update").description("Update an issue").argum
|
|
|
3262
3517
|
var CloseHandler = class extends BaseCommand {
|
|
3263
3518
|
async run(id, options) {
|
|
3264
3519
|
const tbdRoot = await requireInit();
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
let
|
|
3268
|
-
try {
|
|
3269
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3270
|
-
} catch {
|
|
3271
|
-
throw new NotFoundError("Issue", id);
|
|
3272
|
-
}
|
|
3273
|
-
let issue;
|
|
3274
|
-
try {
|
|
3275
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3276
|
-
} catch {
|
|
3277
|
-
throw new NotFoundError("Issue", id);
|
|
3278
|
-
}
|
|
3279
|
-
const showDebug = this.ctx.debug;
|
|
3280
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3281
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3282
|
-
if (issue.status === "closed") {
|
|
3283
|
-
this.output.data({
|
|
3284
|
-
id: displayId,
|
|
3285
|
-
closed: true,
|
|
3286
|
-
alreadyClosed: true
|
|
3287
|
-
}, () => {
|
|
3288
|
-
this.output.success(`Closed ${displayId}`);
|
|
3289
|
-
});
|
|
3290
|
-
return;
|
|
3291
|
-
}
|
|
3292
|
-
if (this.checkDryRun("Would close issue", {
|
|
3293
|
-
id: internalId,
|
|
3294
|
-
reason: options.reason
|
|
3295
|
-
})) return;
|
|
3296
|
-
issue.status = "closed";
|
|
3297
|
-
issue.closed_at = now();
|
|
3298
|
-
issue.close_reason = options.reason ?? null;
|
|
3299
|
-
issue.version += 1;
|
|
3300
|
-
issue.updated_at = now();
|
|
3520
|
+
let displayId = id;
|
|
3521
|
+
let alreadyClosed = false;
|
|
3522
|
+
let didClose = false;
|
|
3301
3523
|
await this.execute(async () => {
|
|
3302
|
-
await
|
|
3524
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3525
|
+
let internalId;
|
|
3526
|
+
try {
|
|
3527
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3528
|
+
} catch {
|
|
3529
|
+
throw new NotFoundError("Issue", id);
|
|
3530
|
+
}
|
|
3531
|
+
let issue;
|
|
3532
|
+
try {
|
|
3533
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3534
|
+
} catch {
|
|
3535
|
+
throw new NotFoundError("Issue", id);
|
|
3536
|
+
}
|
|
3537
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3538
|
+
if (issue.status === "closed") {
|
|
3539
|
+
alreadyClosed = true;
|
|
3540
|
+
didClose = true;
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
if (this.checkDryRun("Would close issue", {
|
|
3544
|
+
id: internalId,
|
|
3545
|
+
reason: options.reason
|
|
3546
|
+
})) return;
|
|
3547
|
+
issue.status = "closed";
|
|
3548
|
+
issue.closed_at = now();
|
|
3549
|
+
issue.close_reason = options.reason ?? null;
|
|
3550
|
+
issue.version += 1;
|
|
3551
|
+
issue.updated_at = now();
|
|
3552
|
+
await writeIssue(dataSyncDir, issue);
|
|
3553
|
+
didClose = true;
|
|
3554
|
+
});
|
|
3303
3555
|
}, "Failed to close issue");
|
|
3556
|
+
if (!didClose) return;
|
|
3304
3557
|
this.output.data({
|
|
3305
3558
|
id: displayId,
|
|
3306
|
-
closed: true
|
|
3559
|
+
closed: true,
|
|
3560
|
+
alreadyClosed
|
|
3307
3561
|
}, () => {
|
|
3308
3562
|
this.output.success(`Closed ${displayId}`);
|
|
3309
3563
|
});
|
|
@@ -3323,40 +3577,42 @@ const closeCommand = new Command("close").description("Close an issue").argument
|
|
|
3323
3577
|
var ReopenHandler = class extends BaseCommand {
|
|
3324
3578
|
async run(id, options) {
|
|
3325
3579
|
const tbdRoot = await requireInit();
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
let internalId;
|
|
3329
|
-
try {
|
|
3330
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3331
|
-
} catch {
|
|
3332
|
-
throw new NotFoundError("Issue", id);
|
|
3333
|
-
}
|
|
3334
|
-
let issue;
|
|
3335
|
-
try {
|
|
3336
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3337
|
-
} catch {
|
|
3338
|
-
throw new NotFoundError("Issue", id);
|
|
3339
|
-
}
|
|
3340
|
-
if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
|
|
3341
|
-
if (this.checkDryRun("Would reopen issue", {
|
|
3342
|
-
id: internalId,
|
|
3343
|
-
reason: options.reason
|
|
3344
|
-
})) return;
|
|
3345
|
-
issue.status = "open";
|
|
3346
|
-
issue.closed_at = null;
|
|
3347
|
-
issue.close_reason = null;
|
|
3348
|
-
issue.version += 1;
|
|
3349
|
-
issue.updated_at = now();
|
|
3350
|
-
if (options.reason) {
|
|
3351
|
-
const reopenNote = `Reopened: ${options.reason}`;
|
|
3352
|
-
issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
|
|
3353
|
-
}
|
|
3580
|
+
let displayId = id;
|
|
3581
|
+
let didReopen = false;
|
|
3354
3582
|
await this.execute(async () => {
|
|
3355
|
-
await
|
|
3583
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3584
|
+
let internalId;
|
|
3585
|
+
try {
|
|
3586
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3587
|
+
} catch {
|
|
3588
|
+
throw new NotFoundError("Issue", id);
|
|
3589
|
+
}
|
|
3590
|
+
let issue;
|
|
3591
|
+
try {
|
|
3592
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3593
|
+
} catch {
|
|
3594
|
+
throw new NotFoundError("Issue", id);
|
|
3595
|
+
}
|
|
3596
|
+
if (issue.status !== "closed") throw new CLIError(`Issue ${id} is not closed (status: ${issue.status})`);
|
|
3597
|
+
if (this.checkDryRun("Would reopen issue", {
|
|
3598
|
+
id: internalId,
|
|
3599
|
+
reason: options.reason
|
|
3600
|
+
})) return;
|
|
3601
|
+
issue.status = "open";
|
|
3602
|
+
issue.closed_at = null;
|
|
3603
|
+
issue.close_reason = null;
|
|
3604
|
+
issue.version += 1;
|
|
3605
|
+
issue.updated_at = now();
|
|
3606
|
+
if (options.reason) {
|
|
3607
|
+
const reopenNote = `Reopened: ${options.reason}`;
|
|
3608
|
+
issue.notes = issue.notes ? `${issue.notes}\n\n${reopenNote}` : reopenNote;
|
|
3609
|
+
}
|
|
3610
|
+
await writeIssue(dataSyncDir, issue);
|
|
3611
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3612
|
+
didReopen = true;
|
|
3613
|
+
});
|
|
3356
3614
|
}, "Failed to reopen issue");
|
|
3357
|
-
|
|
3358
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3359
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3615
|
+
if (!didReopen) return;
|
|
3360
3616
|
this.output.data({
|
|
3361
3617
|
id: displayId,
|
|
3362
3618
|
reopened: true
|
|
@@ -3604,43 +3860,45 @@ const staleCommand = new Command("stale").description("List issues not updated r
|
|
|
3604
3860
|
var LabelAddHandler = class extends BaseCommand {
|
|
3605
3861
|
async run(id, labels) {
|
|
3606
3862
|
const tbdRoot = await requireInit();
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
let internalId;
|
|
3610
|
-
try {
|
|
3611
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3612
|
-
} catch {
|
|
3613
|
-
throw new NotFoundError("Issue", id);
|
|
3614
|
-
}
|
|
3615
|
-
let issue;
|
|
3616
|
-
try {
|
|
3617
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3618
|
-
} catch {
|
|
3619
|
-
throw new NotFoundError("Issue", id);
|
|
3620
|
-
}
|
|
3621
|
-
if (this.checkDryRun("Would add labels", {
|
|
3622
|
-
id: internalId,
|
|
3623
|
-
labels
|
|
3624
|
-
})) return;
|
|
3625
|
-
const labelsSet = new Set(issue.labels);
|
|
3626
|
-
let added = 0;
|
|
3627
|
-
for (const label of labels) if (!labelsSet.has(label)) {
|
|
3628
|
-
labelsSet.add(label);
|
|
3629
|
-
added++;
|
|
3630
|
-
}
|
|
3631
|
-
if (added === 0) {
|
|
3632
|
-
this.output.info("All labels already present");
|
|
3633
|
-
return;
|
|
3634
|
-
}
|
|
3635
|
-
issue.labels = [...labelsSet];
|
|
3636
|
-
issue.version += 1;
|
|
3637
|
-
issue.updated_at = now();
|
|
3863
|
+
let displayId = id;
|
|
3864
|
+
let didAdd = false;
|
|
3638
3865
|
await this.execute(async () => {
|
|
3639
|
-
await
|
|
3866
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3867
|
+
let internalId;
|
|
3868
|
+
try {
|
|
3869
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3870
|
+
} catch {
|
|
3871
|
+
throw new NotFoundError("Issue", id);
|
|
3872
|
+
}
|
|
3873
|
+
let issue;
|
|
3874
|
+
try {
|
|
3875
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3876
|
+
} catch {
|
|
3877
|
+
throw new NotFoundError("Issue", id);
|
|
3878
|
+
}
|
|
3879
|
+
if (this.checkDryRun("Would add labels", {
|
|
3880
|
+
id: internalId,
|
|
3881
|
+
labels
|
|
3882
|
+
})) return;
|
|
3883
|
+
const labelsSet = new Set(issue.labels);
|
|
3884
|
+
let added = 0;
|
|
3885
|
+
for (const label of labels) if (!labelsSet.has(label)) {
|
|
3886
|
+
labelsSet.add(label);
|
|
3887
|
+
added++;
|
|
3888
|
+
}
|
|
3889
|
+
if (added === 0) {
|
|
3890
|
+
this.output.info("All labels already present");
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
issue.labels = [...labelsSet];
|
|
3894
|
+
issue.version += 1;
|
|
3895
|
+
issue.updated_at = now();
|
|
3896
|
+
await writeIssue(dataSyncDir, issue);
|
|
3897
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3898
|
+
didAdd = true;
|
|
3899
|
+
});
|
|
3640
3900
|
}, "Failed to update issue");
|
|
3641
|
-
|
|
3642
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3643
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3901
|
+
if (!didAdd) return;
|
|
3644
3902
|
this.output.data({
|
|
3645
3903
|
id: displayId,
|
|
3646
3904
|
addedLabels: labels
|
|
@@ -3652,39 +3910,41 @@ var LabelAddHandler = class extends BaseCommand {
|
|
|
3652
3910
|
var LabelRemoveHandler = class extends BaseCommand {
|
|
3653
3911
|
async run(id, labels) {
|
|
3654
3912
|
const tbdRoot = await requireInit();
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
let internalId;
|
|
3658
|
-
try {
|
|
3659
|
-
internalId = resolveToInternalId(id, mapping);
|
|
3660
|
-
} catch {
|
|
3661
|
-
throw new NotFoundError("Issue", id);
|
|
3662
|
-
}
|
|
3663
|
-
let issue;
|
|
3664
|
-
try {
|
|
3665
|
-
issue = await readIssue(dataSyncDir, internalId);
|
|
3666
|
-
} catch {
|
|
3667
|
-
throw new NotFoundError("Issue", id);
|
|
3668
|
-
}
|
|
3669
|
-
if (this.checkDryRun("Would remove labels", {
|
|
3670
|
-
id: internalId,
|
|
3671
|
-
labels
|
|
3672
|
-
})) return;
|
|
3673
|
-
const removeSet = new Set(labels);
|
|
3674
|
-
const originalCount = issue.labels.length;
|
|
3675
|
-
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3676
|
-
if (originalCount - issue.labels.length === 0) {
|
|
3677
|
-
this.output.info("No matching labels found");
|
|
3678
|
-
return;
|
|
3679
|
-
}
|
|
3680
|
-
issue.version += 1;
|
|
3681
|
-
issue.updated_at = now();
|
|
3913
|
+
let displayId = id;
|
|
3914
|
+
let didRemove = false;
|
|
3682
3915
|
await this.execute(async () => {
|
|
3683
|
-
await
|
|
3916
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
3917
|
+
let internalId;
|
|
3918
|
+
try {
|
|
3919
|
+
internalId = resolveToInternalId(id, mapping);
|
|
3920
|
+
} catch {
|
|
3921
|
+
throw new NotFoundError("Issue", id);
|
|
3922
|
+
}
|
|
3923
|
+
let issue;
|
|
3924
|
+
try {
|
|
3925
|
+
issue = await readIssue(dataSyncDir, internalId);
|
|
3926
|
+
} catch {
|
|
3927
|
+
throw new NotFoundError("Issue", id);
|
|
3928
|
+
}
|
|
3929
|
+
if (this.checkDryRun("Would remove labels", {
|
|
3930
|
+
id: internalId,
|
|
3931
|
+
labels
|
|
3932
|
+
})) return;
|
|
3933
|
+
const removeSet = new Set(labels);
|
|
3934
|
+
const originalCount = issue.labels.length;
|
|
3935
|
+
issue.labels = issue.labels.filter((l) => !removeSet.has(l));
|
|
3936
|
+
if (originalCount - issue.labels.length === 0) {
|
|
3937
|
+
this.output.info("No matching labels found");
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
issue.version += 1;
|
|
3941
|
+
issue.updated_at = now();
|
|
3942
|
+
await writeIssue(dataSyncDir, issue);
|
|
3943
|
+
displayId = this.ctx.debug ? formatDebugId(issue.id, mapping, config.display.id_prefix) : formatDisplayId(issue.id, mapping, config.display.id_prefix);
|
|
3944
|
+
didRemove = true;
|
|
3945
|
+
});
|
|
3684
3946
|
}, "Failed to update issue");
|
|
3685
|
-
|
|
3686
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3687
|
-
const displayId = showDebug ? formatDebugId(issue.id, mapping, prefix) : formatDisplayId(issue.id, mapping, prefix);
|
|
3947
|
+
if (!didRemove) return;
|
|
3688
3948
|
this.output.data({
|
|
3689
3949
|
id: displayId,
|
|
3690
3950
|
removedLabels: labels
|
|
@@ -3695,7 +3955,7 @@ var LabelRemoveHandler = class extends BaseCommand {
|
|
|
3695
3955
|
};
|
|
3696
3956
|
var LabelListHandler = class extends BaseCommand {
|
|
3697
3957
|
async run() {
|
|
3698
|
-
const dataSyncDir = await
|
|
3958
|
+
const { dataSyncDir } = await loadDataContext(await requireInit());
|
|
3699
3959
|
let issues;
|
|
3700
3960
|
try {
|
|
3701
3961
|
issues = await listIssues(dataSyncDir);
|
|
@@ -3743,53 +4003,56 @@ const labelCommand = new Command("label").description("Manage issue labels").add
|
|
|
3743
4003
|
var DependsAddHandler = class extends BaseCommand {
|
|
3744
4004
|
async run(issueId, dependsOnId) {
|
|
3745
4005
|
const tbdRoot = await requireInit();
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
let
|
|
3749
|
-
let internalDependsOnId;
|
|
3750
|
-
try {
|
|
3751
|
-
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
3752
|
-
} catch {
|
|
3753
|
-
throw new NotFoundError("Issue", issueId);
|
|
3754
|
-
}
|
|
3755
|
-
try {
|
|
3756
|
-
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
3757
|
-
} catch {
|
|
3758
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3759
|
-
}
|
|
3760
|
-
try {
|
|
3761
|
-
await readIssue(dataSyncDir, internalIssueId);
|
|
3762
|
-
} catch {
|
|
3763
|
-
throw new NotFoundError("Issue", issueId);
|
|
3764
|
-
}
|
|
3765
|
-
let blockerIssue;
|
|
3766
|
-
try {
|
|
3767
|
-
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
3768
|
-
} catch {
|
|
3769
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3770
|
-
}
|
|
3771
|
-
if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
|
|
3772
|
-
if (this.checkDryRun("Would add dependency", {
|
|
3773
|
-
issue: internalIssueId,
|
|
3774
|
-
dependsOn: internalDependsOnId
|
|
3775
|
-
})) return;
|
|
3776
|
-
if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
|
|
3777
|
-
this.output.info("Dependency already exists");
|
|
3778
|
-
return;
|
|
3779
|
-
}
|
|
3780
|
-
blockerIssue.dependencies.push({
|
|
3781
|
-
type: "blocks",
|
|
3782
|
-
target: internalIssueId
|
|
3783
|
-
});
|
|
3784
|
-
blockerIssue.version += 1;
|
|
3785
|
-
blockerIssue.updated_at = now();
|
|
4006
|
+
let displayIssueId = issueId;
|
|
4007
|
+
let displayDependsOnId = dependsOnId;
|
|
4008
|
+
let didAdd = false;
|
|
3786
4009
|
await this.execute(async () => {
|
|
3787
|
-
await
|
|
4010
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4011
|
+
let internalIssueId;
|
|
4012
|
+
let internalDependsOnId;
|
|
4013
|
+
try {
|
|
4014
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4015
|
+
} catch {
|
|
4016
|
+
throw new NotFoundError("Issue", issueId);
|
|
4017
|
+
}
|
|
4018
|
+
try {
|
|
4019
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4020
|
+
} catch {
|
|
4021
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4022
|
+
}
|
|
4023
|
+
try {
|
|
4024
|
+
await readIssue(dataSyncDir, internalIssueId);
|
|
4025
|
+
} catch {
|
|
4026
|
+
throw new NotFoundError("Issue", issueId);
|
|
4027
|
+
}
|
|
4028
|
+
let blockerIssue;
|
|
4029
|
+
try {
|
|
4030
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4031
|
+
} catch {
|
|
4032
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4033
|
+
}
|
|
4034
|
+
if (internalIssueId === internalDependsOnId) throw new ValidationError("Issue cannot depend on itself");
|
|
4035
|
+
if (this.checkDryRun("Would add dependency", {
|
|
4036
|
+
issue: internalIssueId,
|
|
4037
|
+
dependsOn: internalDependsOnId
|
|
4038
|
+
})) return;
|
|
4039
|
+
if (blockerIssue.dependencies.some((dep) => dep.type === "blocks" && dep.target === internalIssueId)) {
|
|
4040
|
+
this.output.info("Dependency already exists");
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
blockerIssue.dependencies.push({
|
|
4044
|
+
type: "blocks",
|
|
4045
|
+
target: internalIssueId
|
|
4046
|
+
});
|
|
4047
|
+
blockerIssue.version += 1;
|
|
4048
|
+
blockerIssue.updated_at = now();
|
|
4049
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4050
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4051
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4052
|
+
didAdd = true;
|
|
4053
|
+
});
|
|
3788
4054
|
}, "Failed to update issue");
|
|
3789
|
-
|
|
3790
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3791
|
-
const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
|
|
3792
|
-
const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
|
|
4055
|
+
if (!didAdd) return;
|
|
3793
4056
|
this.output.data({
|
|
3794
4057
|
issue: displayIssueId,
|
|
3795
4058
|
dependsOn: displayDependsOnId
|
|
@@ -3801,45 +4064,48 @@ var DependsAddHandler = class extends BaseCommand {
|
|
|
3801
4064
|
var DependsRemoveHandler = class extends BaseCommand {
|
|
3802
4065
|
async run(issueId, dependsOnId) {
|
|
3803
4066
|
const tbdRoot = await requireInit();
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
let
|
|
3807
|
-
let internalDependsOnId;
|
|
3808
|
-
try {
|
|
3809
|
-
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
3810
|
-
} catch {
|
|
3811
|
-
throw new NotFoundError("Issue", issueId);
|
|
3812
|
-
}
|
|
3813
|
-
try {
|
|
3814
|
-
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
3815
|
-
} catch {
|
|
3816
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3817
|
-
}
|
|
3818
|
-
let blockerIssue;
|
|
3819
|
-
try {
|
|
3820
|
-
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
3821
|
-
} catch {
|
|
3822
|
-
throw new NotFoundError("Issue", dependsOnId);
|
|
3823
|
-
}
|
|
3824
|
-
if (this.checkDryRun("Would remove dependency", {
|
|
3825
|
-
issue: internalIssueId,
|
|
3826
|
-
dependsOn: internalDependsOnId
|
|
3827
|
-
})) return;
|
|
3828
|
-
const initialLength = blockerIssue.dependencies.length;
|
|
3829
|
-
blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
|
|
3830
|
-
if (blockerIssue.dependencies.length === initialLength) {
|
|
3831
|
-
this.output.info("Dependency not found");
|
|
3832
|
-
return;
|
|
3833
|
-
}
|
|
3834
|
-
blockerIssue.version += 1;
|
|
3835
|
-
blockerIssue.updated_at = now();
|
|
4067
|
+
let displayIssueId = issueId;
|
|
4068
|
+
let displayDependsOnId = dependsOnId;
|
|
4069
|
+
let didRemove = false;
|
|
3836
4070
|
await this.execute(async () => {
|
|
3837
|
-
await
|
|
4071
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
4072
|
+
let internalIssueId;
|
|
4073
|
+
let internalDependsOnId;
|
|
4074
|
+
try {
|
|
4075
|
+
internalIssueId = resolveToInternalId(issueId, mapping);
|
|
4076
|
+
} catch {
|
|
4077
|
+
throw new NotFoundError("Issue", issueId);
|
|
4078
|
+
}
|
|
4079
|
+
try {
|
|
4080
|
+
internalDependsOnId = resolveToInternalId(dependsOnId, mapping);
|
|
4081
|
+
} catch {
|
|
4082
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4083
|
+
}
|
|
4084
|
+
let blockerIssue;
|
|
4085
|
+
try {
|
|
4086
|
+
blockerIssue = await readIssue(dataSyncDir, internalDependsOnId);
|
|
4087
|
+
} catch {
|
|
4088
|
+
throw new NotFoundError("Issue", dependsOnId);
|
|
4089
|
+
}
|
|
4090
|
+
if (this.checkDryRun("Would remove dependency", {
|
|
4091
|
+
issue: internalIssueId,
|
|
4092
|
+
dependsOn: internalDependsOnId
|
|
4093
|
+
})) return;
|
|
4094
|
+
const initialLength = blockerIssue.dependencies.length;
|
|
4095
|
+
blockerIssue.dependencies = blockerIssue.dependencies.filter((dep) => !(dep.type === "blocks" && dep.target === internalIssueId));
|
|
4096
|
+
if (blockerIssue.dependencies.length === initialLength) {
|
|
4097
|
+
this.output.info("Dependency not found");
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
blockerIssue.version += 1;
|
|
4101
|
+
blockerIssue.updated_at = now();
|
|
4102
|
+
await writeIssue(dataSyncDir, blockerIssue);
|
|
4103
|
+
displayIssueId = this.ctx.debug ? formatDebugId(internalIssueId, mapping, config.display.id_prefix) : formatDisplayId(internalIssueId, mapping, config.display.id_prefix);
|
|
4104
|
+
displayDependsOnId = this.ctx.debug ? formatDebugId(internalDependsOnId, mapping, config.display.id_prefix) : formatDisplayId(internalDependsOnId, mapping, config.display.id_prefix);
|
|
4105
|
+
didRemove = true;
|
|
4106
|
+
});
|
|
3838
4107
|
}, "Failed to update issue");
|
|
3839
|
-
|
|
3840
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
3841
|
-
const displayIssueId = showDebug ? formatDebugId(internalIssueId, mapping, prefix) : formatDisplayId(internalIssueId, mapping, prefix);
|
|
3842
|
-
const displayDependsOnId = showDebug ? formatDebugId(internalDependsOnId, mapping, prefix) : formatDisplayId(internalDependsOnId, mapping, prefix);
|
|
4108
|
+
if (!didRemove) return;
|
|
3843
4109
|
this.output.data({
|
|
3844
4110
|
issue: displayIssueId,
|
|
3845
4111
|
removed: displayDependsOnId
|
|
@@ -3850,9 +4116,7 @@ var DependsRemoveHandler = class extends BaseCommand {
|
|
|
3850
4116
|
};
|
|
3851
4117
|
var DependsListHandler = class extends BaseCommand {
|
|
3852
4118
|
async run(id) {
|
|
3853
|
-
const
|
|
3854
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
3855
|
-
const mapping = await loadIdMapping(dataSyncDir);
|
|
4119
|
+
const { dataSyncDir, mapping, config } = await loadDataContext(await requireInit());
|
|
3856
4120
|
let internalId;
|
|
3857
4121
|
try {
|
|
3858
4122
|
internalId = resolveToInternalId(id, mapping);
|
|
@@ -3872,7 +4136,7 @@ var DependsListHandler = class extends BaseCommand {
|
|
|
3872
4136
|
allIssues = [];
|
|
3873
4137
|
}
|
|
3874
4138
|
const showDebug = this.ctx.debug;
|
|
3875
|
-
const prefix =
|
|
4139
|
+
const prefix = config.display.id_prefix;
|
|
3876
4140
|
const deps = getDependencyDirections(issue, allIssues, (dependencyId) => showDebug ? formatDebugId(dependencyId, mapping, prefix) : formatDisplayId(dependencyId, mapping, prefix));
|
|
3877
4141
|
this.output.data(deps, () => {
|
|
3878
4142
|
const colors = this.output.getColors();
|
|
@@ -4896,6 +5160,8 @@ async function workspaceExists(tbdRoot, name) {
|
|
|
4896
5160
|
var SyncHandler = class extends BaseCommand {
|
|
4897
5161
|
dataSyncDir = "";
|
|
4898
5162
|
tbdRoot = "";
|
|
5163
|
+
worktreePath = "";
|
|
5164
|
+
syncBranch = "";
|
|
4899
5165
|
async run(options) {
|
|
4900
5166
|
const tbdRoot = await requireInit();
|
|
4901
5167
|
this.tbdRoot = tbdRoot;
|
|
@@ -4908,42 +5174,28 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4908
5174
|
await this.syncDocs(options.status);
|
|
4909
5175
|
if (!syncIssues) return;
|
|
4910
5176
|
}
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
if (
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
if (options.status) {
|
|
4934
|
-
await this.showIssueStatus(syncBranch, remote);
|
|
4935
|
-
return;
|
|
4936
|
-
}
|
|
4937
|
-
if (this.checkDryRun("Would sync repository", {
|
|
4938
|
-
syncBranch,
|
|
4939
|
-
remote
|
|
4940
|
-
})) return;
|
|
4941
|
-
if (options.pull) await this.pullChanges(syncBranch, remote);
|
|
4942
|
-
else if (options.push) await this.pushChanges(syncBranch, remote);
|
|
4943
|
-
else await this.fullSync(syncBranch, remote, {
|
|
4944
|
-
force: options.force,
|
|
4945
|
-
autoSave: options.autoSave,
|
|
4946
|
-
outbox: options.outbox
|
|
5177
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, sharedPaths, repairedWorktreeStatus }) => {
|
|
5178
|
+
this.dataSyncDir = dataSyncDir;
|
|
5179
|
+
this.worktreePath = sharedPaths.sharedWorktreePath;
|
|
5180
|
+
this.syncBranch = config.sync.branch;
|
|
5181
|
+
const syncBranch = config.sync.branch;
|
|
5182
|
+
const remote = config.sync.remote;
|
|
5183
|
+
if (options.status) {
|
|
5184
|
+
await this.showIssueStatus(syncBranch, remote);
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
5187
|
+
if (this.checkDryRun("Would sync repository", {
|
|
5188
|
+
syncBranch,
|
|
5189
|
+
remote
|
|
5190
|
+
})) return;
|
|
5191
|
+
if (repairedWorktreeStatus) this.output.success("Worktree repaired successfully");
|
|
5192
|
+
if (options.pull) await this.pullChanges(syncBranch, remote);
|
|
5193
|
+
else if (options.push) await this.pushChanges(syncBranch, remote);
|
|
5194
|
+
else await this.fullSync(syncBranch, remote, {
|
|
5195
|
+
force: options.force,
|
|
5196
|
+
autoSave: options.autoSave,
|
|
5197
|
+
outbox: options.outbox
|
|
5198
|
+
});
|
|
4947
5199
|
});
|
|
4948
5200
|
}
|
|
4949
5201
|
/**
|
|
@@ -4988,28 +5240,10 @@ var SyncHandler = class extends BaseCommand {
|
|
|
4988
5240
|
const parts = [];
|
|
4989
5241
|
if (result.added.length > 0) parts.push(`+${result.added.length}`);
|
|
4990
5242
|
if (result.updated.length > 0) parts.push(`~${result.updated.length}`);
|
|
4991
|
-
if (result.removed.length > 0) parts.push(`-${result.removed.length}`);
|
|
4992
|
-
if (parts.length > 0) this.output.success(`Synced docs: ${parts.join(" ")} doc(s)`);
|
|
4993
|
-
if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
|
|
4994
|
-
for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
|
|
4995
|
-
}
|
|
4996
|
-
/**
|
|
4997
|
-
* Attempt to repair an unhealthy worktree.
|
|
4998
|
-
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md
|
|
4999
|
-
*/
|
|
5000
|
-
async doRepairWorktree(tbdRoot, status) {
|
|
5001
|
-
const spinner = this.output.spinner(`Repairing worktree (${status})...`);
|
|
5002
|
-
try {
|
|
5003
|
-
const result = await repairWorktree(tbdRoot, status);
|
|
5004
|
-
spinner.stop();
|
|
5005
|
-
if (!result.success) throw new WorktreeCorruptedError(`Failed to repair worktree: ${result.error}`);
|
|
5006
|
-
if (result.backedUp) this.output.info(`Corrupted worktree backed up to: ${result.backedUp}`);
|
|
5007
|
-
this.output.success("Worktree repaired successfully");
|
|
5008
|
-
} catch (error) {
|
|
5009
|
-
spinner.stop();
|
|
5010
|
-
if (error instanceof WorktreeCorruptedError) throw error;
|
|
5011
|
-
throw new WorktreeCorruptedError(`Failed to repair worktree: ${error.message}`);
|
|
5012
|
-
}
|
|
5243
|
+
if (result.removed.length > 0) parts.push(`-${result.removed.length}`);
|
|
5244
|
+
if (parts.length > 0) this.output.success(`Synced docs: ${parts.join(" ")} doc(s)`);
|
|
5245
|
+
if (result.pruned.length > 0) this.output.info(`Removed ${result.pruned.length} stale config entry/entries`);
|
|
5246
|
+
for (const err of result.errors) this.output.warn(`Doc sync error: ${err.path}: ${err.error}`);
|
|
5013
5247
|
}
|
|
5014
5248
|
async showIssueStatus(syncBranch, remote) {
|
|
5015
5249
|
const status = await this.getSyncStatus(syncBranch, remote);
|
|
@@ -5041,7 +5275,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5041
5275
|
let ahead = 0;
|
|
5042
5276
|
let behind = 0;
|
|
5043
5277
|
try {
|
|
5044
|
-
const status = await git("-C",
|
|
5278
|
+
const status = await git("-C", this.worktreePath, "status", "--porcelain");
|
|
5045
5279
|
if (status) for (const line of status.split("\n")) {
|
|
5046
5280
|
if (!line) continue;
|
|
5047
5281
|
const statusCode = line.slice(0, 2).trim();
|
|
@@ -5100,11 +5334,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5100
5334
|
this.output.success("Already up to date");
|
|
5101
5335
|
return;
|
|
5102
5336
|
}
|
|
5103
|
-
await
|
|
5104
|
-
|
|
5105
|
-
const remoteCommit = await git("rev-parse", `${remote}/${syncBranch}`);
|
|
5106
|
-
await git("update-ref", `refs/heads/${syncBranch}`, remoteCommit);
|
|
5107
|
-
});
|
|
5337
|
+
await ensureWorktreeAttachedToBranch(this.worktreePath, syncBranch);
|
|
5338
|
+
await git("-C", this.worktreePath, "merge", "--ff-only", `${remote}/${syncBranch}`);
|
|
5108
5339
|
this.output.success(`Pulled ${behind} change(s) from ${remote}/${syncBranch}`);
|
|
5109
5340
|
} catch (error) {
|
|
5110
5341
|
spinner.stop();
|
|
@@ -5120,15 +5351,15 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5120
5351
|
* @returns Tallies of new/updated/deleted files committed
|
|
5121
5352
|
*/
|
|
5122
5353
|
async commitWorktreeChanges() {
|
|
5123
|
-
const worktreePath =
|
|
5354
|
+
const worktreePath = this.worktreePath;
|
|
5124
5355
|
try {
|
|
5125
|
-
await
|
|
5356
|
+
await ensureWorktreeAttachedToBranch(worktreePath, this.syncBranch);
|
|
5126
5357
|
const status = await git("-C", worktreePath, "status", "--porcelain");
|
|
5127
5358
|
if (!status || status.trim() === "") return emptyTallies();
|
|
5128
5359
|
const tallies = parseGitStatus(status);
|
|
5129
5360
|
const fileCount = tallies.new + tallies.updated + tallies.deleted;
|
|
5130
5361
|
await git("-C", worktreePath, "add", "-A");
|
|
5131
|
-
await
|
|
5362
|
+
await gitCommit(worktreePath, "-m", `tbd sync: ${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)} (${fileCount} file${fileCount === 1 ? "" : "s"})`);
|
|
5132
5363
|
return tallies;
|
|
5133
5364
|
} catch (error) {
|
|
5134
5365
|
if (error.message.includes("nothing to commit")) return emptyTallies();
|
|
@@ -5210,7 +5441,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5210
5441
|
const spinner = this.output.spinner("Syncing with remote...");
|
|
5211
5442
|
const summary = emptySummary();
|
|
5212
5443
|
const conflicts = [];
|
|
5213
|
-
const worktreePath =
|
|
5444
|
+
const worktreePath = this.worktreePath;
|
|
5214
5445
|
try {
|
|
5215
5446
|
const committedTallies = await this.commitWorktreeChanges();
|
|
5216
5447
|
summary.sent.new += committedTallies.new;
|
|
@@ -5238,7 +5469,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5238
5469
|
this.output.debug("Remote sync branch does not exist yet");
|
|
5239
5470
|
}
|
|
5240
5471
|
{
|
|
5241
|
-
const { access, writeFile } = await import("node:fs/promises");
|
|
5242
5472
|
const attrPath = join(this.dataSyncDir, "mappings", ".gitattributes");
|
|
5243
5473
|
try {
|
|
5244
5474
|
await access(attrPath);
|
|
@@ -5246,7 +5476,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5246
5476
|
await writeFile(attrPath, "ids.yml merge=union\n");
|
|
5247
5477
|
await git("-C", worktreePath, "add", attrPath);
|
|
5248
5478
|
try {
|
|
5249
|
-
await
|
|
5479
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "chore: add merge=union for ids.yml");
|
|
5250
5480
|
} catch {}
|
|
5251
5481
|
}
|
|
5252
5482
|
}
|
|
@@ -5272,7 +5502,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5272
5502
|
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
5273
5503
|
await git("-C", worktreePath, "add", "-A");
|
|
5274
5504
|
try {
|
|
5275
|
-
await
|
|
5505
|
+
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
5276
5506
|
} catch {}
|
|
5277
5507
|
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
5278
5508
|
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
@@ -5294,7 +5524,6 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5294
5524
|
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5295
5525
|
if (remoteIdsContent) {
|
|
5296
5526
|
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5297
|
-
const { readFile } = await import("node:fs/promises");
|
|
5298
5527
|
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
5299
5528
|
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
5300
5529
|
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
@@ -5320,7 +5549,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5320
5549
|
throw new SyncError(`Cannot commit: ${conflictedFiles.length} file(s) still have merge conflict markers:\n` + conflictedFiles.map((f) => ` - ${f}`).join("\n") + `\n\nThis is a bug in tbd sync. Please report it and manually resolve conflicts in:\n ${worktreePath}`);
|
|
5321
5550
|
}
|
|
5322
5551
|
try {
|
|
5323
|
-
await
|
|
5552
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
5324
5553
|
} catch {
|
|
5325
5554
|
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
5326
5555
|
}
|
|
@@ -5813,16 +6042,23 @@ function renderBeadsWarning(colors) {
|
|
|
5813
6042
|
* Used by: status
|
|
5814
6043
|
*
|
|
5815
6044
|
* @param path - Worktree path
|
|
5816
|
-
* @param
|
|
6045
|
+
* @param status - 'valid' (healthy), 'missing' (created on next sync), or 'prunable' /
|
|
6046
|
+
* 'corrupted' (truly unhealthy and needs repair)
|
|
5817
6047
|
* @param colors - Color functions
|
|
5818
6048
|
*/
|
|
5819
|
-
function renderWorktreeStatus(path,
|
|
6049
|
+
function renderWorktreeStatus(path, status, colors) {
|
|
5820
6050
|
console.log("");
|
|
5821
|
-
if (
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
6051
|
+
if (status === "valid") {
|
|
6052
|
+
console.log(`${colors.dim("Worktree:")} ${path} (healthy)`);
|
|
6053
|
+
return;
|
|
6054
|
+
}
|
|
6055
|
+
if (status === "missing") {
|
|
6056
|
+
console.log(`${colors.dim("Worktree:")} ${path} (not initialized)`);
|
|
6057
|
+
console.log(" Run: tbd sync (or tbd doctor --fix) to initialize");
|
|
6058
|
+
return;
|
|
5825
6059
|
}
|
|
6060
|
+
console.log(`${colors.warn("Worktree:")} ${path} (${colors.error("unhealthy")})`);
|
|
6061
|
+
console.log(" Run: tbd doctor --fix");
|
|
5826
6062
|
}
|
|
5827
6063
|
/**
|
|
5828
6064
|
* Render footer with command suggestions.
|
|
@@ -5845,9 +6081,10 @@ function renderFooter(suggestions, colors) {
|
|
|
5845
6081
|
/**
|
|
5846
6082
|
* Centralized path constants and utilities for coding agent integrations.
|
|
5847
6083
|
*
|
|
5848
|
-
* IMPORTANT: All tbd integration files (hooks, settings,
|
|
5849
|
-
* to PROJECT-LOCAL directories (.
|
|
5850
|
-
* global/user directories
|
|
6084
|
+
* IMPORTANT: All tbd integration files (skills, hooks, settings, scripts) are
|
|
6085
|
+
* installed to PROJECT-LOCAL directories (.agents/, .claude/, .codex/,
|
|
6086
|
+
* scripts/agent/, AGENTS.md) ONLY. We do NOT install to global/user directories
|
|
6087
|
+
* (~/.claude/, ~/.codex/, ~/.agents/).
|
|
5851
6088
|
*
|
|
5852
6089
|
* This file defines all path constants in one place to:
|
|
5853
6090
|
* 1. Ensure consistency across the codebase
|
|
@@ -5855,6 +6092,19 @@ function renderFooter(suggestions, colors) {
|
|
|
5855
6092
|
* 3. Simplify future changes to path conventions
|
|
5856
6093
|
*/
|
|
5857
6094
|
/**
|
|
6095
|
+
* Format version stamped into generated agent integration artifacts (e.g. the
|
|
6096
|
+
* AGENTS.md managed block's begin marker: `... format=f03 surface=...`).
|
|
6097
|
+
*
|
|
6098
|
+
* UNIFIED with the `.tbd/` directory format (`tbd_format`): there is one format
|
|
6099
|
+
* code for all tbd-managed surfaces, sourced from `tbd-format.ts` (the single
|
|
6100
|
+
* source of truth). Bump `CURRENT_FORMAT` there when any managed surface — config
|
|
6101
|
+
* schema OR a generated agent surface — changes shape. A marked AGENTS.md block
|
|
6102
|
+
* with no `format=` field predates this and is treated as `f01`; a running tbd
|
|
6103
|
+
* that finds a HIGHER format than it knows refuses to overwrite it and tells the
|
|
6104
|
+
* user to upgrade tbd.
|
|
6105
|
+
*/
|
|
6106
|
+
const AGENT_INTEGRATION_FORMAT = CURRENT_FORMAT;
|
|
6107
|
+
/**
|
|
5858
6108
|
* Relative path to Claude Code settings file from project root.
|
|
5859
6109
|
* This is where hooks are configured.
|
|
5860
6110
|
*/
|
|
@@ -5888,11 +6138,33 @@ const TBD_CLOSING_REMINDER_REL = ".claude/hooks/tbd-closing-reminder.sh";
|
|
|
5888
6138
|
*/
|
|
5889
6139
|
const GH_CLI_SCRIPT_REL = ".claude/scripts/ensure-gh-cli.sh";
|
|
5890
6140
|
/**
|
|
6141
|
+
* Canonical portable project Agent Skill, scanned by Codex, Gemini CLI, Cursor,
|
|
6142
|
+
* GitHub Copilot, Amp, OpenCode, pi, and other Agent Skills clients.
|
|
6143
|
+
*/
|
|
6144
|
+
const AGENTS_SKILL_REL = ".agents/skills/tbd/SKILL.md";
|
|
6145
|
+
/**
|
|
6146
|
+
* Repository distribution copy of the skill, for skills.sh-style installers
|
|
6147
|
+
* (`npx skills add`) and direct GitHub browsing.
|
|
6148
|
+
*/
|
|
6149
|
+
const SKILLS_DIST_REL = "skills/tbd/SKILL.md";
|
|
6150
|
+
/**
|
|
5891
6151
|
* Relative path to AGENTS.md file from project root.
|
|
5892
6152
|
* Used by Codex, Factory.ai, Cursor (v1.6+), and other compatible tools.
|
|
5893
6153
|
*/
|
|
5894
6154
|
const AGENTS_MD_REL = "AGENTS.md";
|
|
5895
6155
|
/**
|
|
6156
|
+
* Codex project-local config/hook directory.
|
|
6157
|
+
*/
|
|
6158
|
+
const CODEX_DIR_REL = ".codex";
|
|
6159
|
+
/**
|
|
6160
|
+
* Codex project-local hooks file (Claude-compatible event schema).
|
|
6161
|
+
*/
|
|
6162
|
+
const CODEX_HOOKS_REL = ".codex/hooks.json";
|
|
6163
|
+
/**
|
|
6164
|
+
* Codex project-local config; may also carry an inline `[hooks]` table.
|
|
6165
|
+
*/
|
|
6166
|
+
const CODEX_CONFIG_REL = ".codex/config.toml";
|
|
6167
|
+
/**
|
|
5896
6168
|
* Global Claude Code directory in user's home.
|
|
5897
6169
|
* Used ONLY for detecting if Claude Code is installed (for agent detection).
|
|
5898
6170
|
* All installations are project-local.
|
|
@@ -5926,6 +6198,31 @@ function getAgentsMdPath(projectRoot) {
|
|
|
5926
6198
|
return join(projectRoot, AGENTS_MD_REL);
|
|
5927
6199
|
}
|
|
5928
6200
|
/**
|
|
6201
|
+
* Get the three SKILL.md targets: the portable Agent Skills install, the Claude
|
|
6202
|
+
* Code mirror, and the committed distribution copy.
|
|
6203
|
+
*
|
|
6204
|
+
* @param projectRoot - The project root directory (containing .tbd/)
|
|
6205
|
+
*/
|
|
6206
|
+
function getAgentSkillPaths(projectRoot) {
|
|
6207
|
+
return {
|
|
6208
|
+
portable: join(projectRoot, AGENTS_SKILL_REL),
|
|
6209
|
+
claudeMirror: join(projectRoot, CLAUDE_SKILL_REL),
|
|
6210
|
+
distribution: join(projectRoot, SKILLS_DIST_REL)
|
|
6211
|
+
};
|
|
6212
|
+
}
|
|
6213
|
+
/**
|
|
6214
|
+
* Get project-local Codex config/hook paths.
|
|
6215
|
+
*
|
|
6216
|
+
* @param projectRoot - The project root directory
|
|
6217
|
+
*/
|
|
6218
|
+
function getCodexPaths(projectRoot) {
|
|
6219
|
+
return {
|
|
6220
|
+
dir: join(projectRoot, CODEX_DIR_REL),
|
|
6221
|
+
hooks: join(projectRoot, CODEX_HOOKS_REL),
|
|
6222
|
+
config: join(projectRoot, CODEX_CONFIG_REL)
|
|
6223
|
+
};
|
|
6224
|
+
}
|
|
6225
|
+
/**
|
|
5929
6226
|
* Display path for Claude Code settings in status/doctor output.
|
|
5930
6227
|
*/
|
|
5931
6228
|
const CLAUDE_SETTINGS_DISPLAY = "./.claude/settings.json";
|
|
@@ -5933,6 +6230,14 @@ const CLAUDE_SETTINGS_DISPLAY = "./.claude/settings.json";
|
|
|
5933
6230
|
* Display path for AGENTS.md in status/doctor output.
|
|
5934
6231
|
*/
|
|
5935
6232
|
const AGENTS_MD_DISPLAY = "./AGENTS.md";
|
|
6233
|
+
/**
|
|
6234
|
+
* Display path for the portable Agent Skill in status/doctor output.
|
|
6235
|
+
*/
|
|
6236
|
+
const AGENTS_SKILL_DISPLAY = "./.agents/skills/tbd/SKILL.md";
|
|
6237
|
+
/**
|
|
6238
|
+
* Display path for the Codex hooks file in status/doctor output.
|
|
6239
|
+
*/
|
|
6240
|
+
const CODEX_HOOKS_DISPLAY = "./.codex/hooks.json";
|
|
5936
6241
|
|
|
5937
6242
|
//#endregion
|
|
5938
6243
|
//#region src/cli/commands/status.ts
|
|
@@ -5968,12 +6273,17 @@ var StatusHandler = class extends BaseCommand {
|
|
|
5968
6273
|
display_prefix: null,
|
|
5969
6274
|
worktree_path: null,
|
|
5970
6275
|
worktree_healthy: null,
|
|
6276
|
+
worktree_status: null,
|
|
5971
6277
|
workspaces: [],
|
|
5972
6278
|
integrations: {
|
|
6279
|
+
portable_skill: false,
|
|
6280
|
+
portable_skill_path: AGENTS_SKILL_DISPLAY,
|
|
5973
6281
|
claude_code: false,
|
|
5974
6282
|
claude_code_path: CLAUDE_SETTINGS_DISPLAY,
|
|
5975
6283
|
codex: false,
|
|
5976
|
-
codex_path: AGENTS_MD_DISPLAY
|
|
6284
|
+
codex_path: AGENTS_MD_DISPLAY,
|
|
6285
|
+
codex_hooks: false,
|
|
6286
|
+
codex_hooks_path: CODEX_HOOKS_DISPLAY
|
|
5977
6287
|
}
|
|
5978
6288
|
};
|
|
5979
6289
|
const gitInfo = await this.checkGitRepo();
|
|
@@ -6041,11 +6351,23 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6041
6351
|
const claudePaths = getClaudePaths(projectRoot);
|
|
6042
6352
|
const agentsPath = getAgentsMdPath(projectRoot);
|
|
6043
6353
|
const result = {
|
|
6354
|
+
portable_skill: false,
|
|
6355
|
+
portable_skill_path: AGENTS_SKILL_DISPLAY,
|
|
6044
6356
|
claude_code: false,
|
|
6045
6357
|
claude_code_path: CLAUDE_SETTINGS_DISPLAY,
|
|
6046
6358
|
codex: false,
|
|
6047
|
-
codex_path: AGENTS_MD_DISPLAY
|
|
6359
|
+
codex_path: AGENTS_MD_DISPLAY,
|
|
6360
|
+
codex_hooks: false,
|
|
6361
|
+
codex_hooks_path: CODEX_HOOKS_DISPLAY
|
|
6048
6362
|
};
|
|
6363
|
+
try {
|
|
6364
|
+
await access(getAgentSkillPaths(projectRoot).portable);
|
|
6365
|
+
result.portable_skill = true;
|
|
6366
|
+
} catch {}
|
|
6367
|
+
try {
|
|
6368
|
+
await access(getCodexPaths(projectRoot).hooks);
|
|
6369
|
+
result.codex_hooks = true;
|
|
6370
|
+
} catch {}
|
|
6049
6371
|
try {
|
|
6050
6372
|
await access(claudePaths.settings);
|
|
6051
6373
|
const content = await readFile(claudePaths.settings, "utf-8");
|
|
@@ -6065,10 +6387,11 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6065
6387
|
data.remote = config.sync.remote;
|
|
6066
6388
|
data.display_prefix = config.display.id_prefix;
|
|
6067
6389
|
} catch {}
|
|
6068
|
-
const
|
|
6069
|
-
const worktreeHealth = await checkWorktreeHealth(cwd);
|
|
6070
|
-
data.worktree_path =
|
|
6390
|
+
const sharedPaths = await resolveSharedTbdPaths(cwd);
|
|
6391
|
+
const worktreeHealth = await checkWorktreeHealth(cwd, data.sync_branch ?? void 0);
|
|
6392
|
+
data.worktree_path = sharedPaths.sharedWorktreePath;
|
|
6071
6393
|
data.worktree_healthy = worktreeHealth.valid;
|
|
6394
|
+
data.worktree_status = worktreeHealth.status;
|
|
6072
6395
|
try {
|
|
6073
6396
|
data.workspaces = await listWorkspaces(cwd);
|
|
6074
6397
|
} catch {}
|
|
@@ -6094,19 +6417,32 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6094
6417
|
remote: data.remote,
|
|
6095
6418
|
displayPrefix: data.display_prefix
|
|
6096
6419
|
}, colors);
|
|
6097
|
-
if (renderIntegrationsSection([
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6420
|
+
if (renderIntegrationsSection([
|
|
6421
|
+
{
|
|
6422
|
+
name: "Portable Agent Skill",
|
|
6423
|
+
installed: data.integrations.portable_skill,
|
|
6424
|
+
path: data.integrations.portable_skill_path
|
|
6425
|
+
},
|
|
6426
|
+
{
|
|
6427
|
+
name: "Claude Code hooks",
|
|
6428
|
+
installed: data.integrations.claude_code,
|
|
6429
|
+
path: data.integrations.claude_code_path
|
|
6430
|
+
},
|
|
6431
|
+
{
|
|
6432
|
+
name: "Codex AGENTS.md",
|
|
6433
|
+
installed: data.integrations.codex,
|
|
6434
|
+
path: data.integrations.codex_path
|
|
6435
|
+
},
|
|
6436
|
+
{
|
|
6437
|
+
name: "Codex hooks",
|
|
6438
|
+
installed: data.integrations.codex_hooks,
|
|
6439
|
+
path: data.integrations.codex_hooks_path
|
|
6440
|
+
}
|
|
6441
|
+
], colors)) {
|
|
6106
6442
|
console.log("");
|
|
6107
6443
|
console.log(`Run ${colors.bold("tbd setup auto")} to configure detected agents`);
|
|
6108
6444
|
}
|
|
6109
|
-
if (data.
|
|
6445
|
+
if (data.worktree_status !== null && data.worktree_path) renderWorktreeStatus(data.worktree_path, data.worktree_status, colors);
|
|
6110
6446
|
if (data.workspaces.length > 0) {
|
|
6111
6447
|
console.log("");
|
|
6112
6448
|
console.log(colors.bold("WORKSPACES"));
|
|
@@ -6204,7 +6540,8 @@ var StatsHandler = class extends BaseCommand {
|
|
|
6204
6540
|
const tbdRoot = await requireInit();
|
|
6205
6541
|
let issues;
|
|
6206
6542
|
try {
|
|
6207
|
-
|
|
6543
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
6544
|
+
issues = await listIssues(dataSyncDir);
|
|
6208
6545
|
} catch {
|
|
6209
6546
|
throw new NotInitializedError("No issue store found. Run `tbd init` first.");
|
|
6210
6547
|
}
|
|
@@ -6397,7 +6734,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6397
6734
|
async run(options) {
|
|
6398
6735
|
const tbdRoot = await requireInit();
|
|
6399
6736
|
this.cwd = tbdRoot;
|
|
6400
|
-
this.dataSyncDir = await
|
|
6737
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(tbdRoot)).sharedDataSyncDir;
|
|
6401
6738
|
try {
|
|
6402
6739
|
this.config = await readConfig(this.cwd);
|
|
6403
6740
|
} catch {}
|
|
@@ -6421,10 +6758,11 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6421
6758
|
healthChecks.push(await this.checkTempFiles(options.fix));
|
|
6422
6759
|
healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
|
|
6423
6760
|
healthChecks.push(await this.checkWorktree(options.fix));
|
|
6761
|
+
healthChecks.push(await this.checkCommonDirLayout(options.fix));
|
|
6424
6762
|
const dataLocationResult = await this.checkDataLocation(options.fix);
|
|
6425
6763
|
healthChecks.push(dataLocationResult);
|
|
6426
6764
|
if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
|
|
6427
|
-
this.dataSyncDir = await
|
|
6765
|
+
this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
|
|
6428
6766
|
try {
|
|
6429
6767
|
this.invalidIssueFiles = [];
|
|
6430
6768
|
this.issues = await listIssues(this.dataSyncDir, {
|
|
@@ -6442,10 +6780,13 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6442
6780
|
healthChecks.push(await this.checkCloneScenarios());
|
|
6443
6781
|
healthChecks.push(await this.checkSyncConsistency());
|
|
6444
6782
|
const integrationChecks = [];
|
|
6783
|
+
integrationChecks.push(await this.checkPortableSkill());
|
|
6445
6784
|
integrationChecks.push(await this.checkClaudeSkill());
|
|
6446
6785
|
integrationChecks.push(await this.checkCodexAgents());
|
|
6786
|
+
integrationChecks.push(await this.checkCodexHooks());
|
|
6447
6787
|
const allChecks = [...healthChecks, ...integrationChecks];
|
|
6448
6788
|
const allOk = allChecks.every((c) => c.status === "ok");
|
|
6789
|
+
const hasErrors = allChecks.some((c) => c.status === "error");
|
|
6449
6790
|
const hasFixable = allChecks.some((c) => c.fixable && c.status !== "ok");
|
|
6450
6791
|
this.output.data({
|
|
6451
6792
|
statusInfo,
|
|
@@ -6481,11 +6822,12 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6481
6822
|
else if (hasFixable && !options.fix) this.output.warn("Issues found. Run with --fix to repair.");
|
|
6482
6823
|
else this.output.warn("Issues found that may require manual intervention.");
|
|
6483
6824
|
});
|
|
6825
|
+
if (hasErrors) process.exitCode = 1;
|
|
6484
6826
|
}
|
|
6485
6827
|
async gatherStatusInfo() {
|
|
6486
6828
|
let gitBranch = null;
|
|
6487
6829
|
try {
|
|
6488
|
-
gitBranch = await getCurrentBranch();
|
|
6830
|
+
gitBranch = await getCurrentBranch(this.cwd);
|
|
6489
6831
|
} catch {}
|
|
6490
6832
|
const worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
6491
6833
|
return {
|
|
@@ -6512,7 +6854,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6512
6854
|
if (issue.status === "open" && !blockedIds.has(issue.id)) readyCount++;
|
|
6513
6855
|
}
|
|
6514
6856
|
let remoteTotal = null;
|
|
6515
|
-
if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync");
|
|
6857
|
+
if (this.issues.length === 0 && this.config) remoteTotal = await countRemoteIssues(this.config.sync.remote ?? "origin", this.config.sync.branch ?? "tbd-sync", this.cwd);
|
|
6516
6858
|
return {
|
|
6517
6859
|
total: this.issues.length,
|
|
6518
6860
|
ready: readyCount,
|
|
@@ -6570,6 +6912,13 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6570
6912
|
path: configPath,
|
|
6571
6913
|
suggestion: "Run: tbd init"
|
|
6572
6914
|
};
|
|
6915
|
+
if (error instanceof IncompatibleFormatError) return {
|
|
6916
|
+
name: "Config file",
|
|
6917
|
+
status: "error",
|
|
6918
|
+
message: `requires newer tbd (found ${error.foundFormat}, supported ${error.supportedFormat})`,
|
|
6919
|
+
path: configPath,
|
|
6920
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
6921
|
+
};
|
|
6573
6922
|
return {
|
|
6574
6923
|
name: "Config file",
|
|
6575
6924
|
status: "error",
|
|
@@ -6661,7 +7010,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6661
7010
|
status: "ok"
|
|
6662
7011
|
};
|
|
6663
7012
|
if (fix && !this.checkDryRun("Resolve merge conflicts in ids.yml")) try {
|
|
6664
|
-
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-
|
|
7013
|
+
const { resolveIdMappingConflicts, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6665
7014
|
const resolved = resolveIdMappingConflicts(content);
|
|
6666
7015
|
await saveIdMapping(this.dataSyncDir, resolved);
|
|
6667
7016
|
return {
|
|
@@ -6710,7 +7059,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6710
7059
|
status: "ok"
|
|
6711
7060
|
};
|
|
6712
7061
|
if (fix && !this.checkDryRun("Fix duplicate ID mapping keys")) try {
|
|
6713
|
-
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-
|
|
7062
|
+
const { loadIdMapping, saveIdMapping } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6714
7063
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6715
7064
|
await saveIdMapping(this.dataSyncDir, mapping);
|
|
6716
7065
|
return {
|
|
@@ -6849,7 +7198,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6849
7198
|
name: "ID mapping coverage",
|
|
6850
7199
|
status: "ok"
|
|
6851
7200
|
};
|
|
6852
|
-
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-
|
|
7201
|
+
const { loadIdMapping, saveIdMapping, reconcileMappings } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6853
7202
|
const mapping = await loadIdMapping(this.dataSyncDir);
|
|
6854
7203
|
const missingIds = [];
|
|
6855
7204
|
for (const issue of this.issues) {
|
|
@@ -6861,10 +7210,10 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6861
7210
|
status: "ok"
|
|
6862
7211
|
};
|
|
6863
7212
|
if (fix && !this.checkDryRun("Create missing ID mappings")) {
|
|
6864
|
-
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-
|
|
7213
|
+
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-CFoPVinz.mjs");
|
|
6865
7214
|
let historicalMapping;
|
|
6866
7215
|
try {
|
|
6867
|
-
const syncBranch = (await import("./config-
|
|
7216
|
+
const syncBranch = (await import("./config-DlCUMyCG.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
|
|
6868
7217
|
const logArgs = ["log", "--format=%H"];
|
|
6869
7218
|
if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
|
|
6870
7219
|
logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
@@ -6907,6 +7256,25 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6907
7256
|
suggestion: "Run: tbd doctor --fix to create missing mappings"
|
|
6908
7257
|
};
|
|
6909
7258
|
}
|
|
7259
|
+
async checkPortableSkill() {
|
|
7260
|
+
const { portable } = getAgentSkillPaths(this.cwd);
|
|
7261
|
+
try {
|
|
7262
|
+
await access(portable);
|
|
7263
|
+
return {
|
|
7264
|
+
name: "Portable Agent Skill",
|
|
7265
|
+
status: "ok",
|
|
7266
|
+
path: AGENTS_SKILL_REL
|
|
7267
|
+
};
|
|
7268
|
+
} catch {
|
|
7269
|
+
return {
|
|
7270
|
+
name: "Portable Agent Skill",
|
|
7271
|
+
status: "warn",
|
|
7272
|
+
message: "not installed",
|
|
7273
|
+
path: AGENTS_SKILL_REL,
|
|
7274
|
+
suggestion: "Run: tbd setup --auto"
|
|
7275
|
+
};
|
|
7276
|
+
}
|
|
7277
|
+
}
|
|
6910
7278
|
async checkClaudeSkill() {
|
|
6911
7279
|
const claudePaths = getClaudePaths(this.cwd);
|
|
6912
7280
|
try {
|
|
@@ -6926,6 +7294,25 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6926
7294
|
};
|
|
6927
7295
|
}
|
|
6928
7296
|
}
|
|
7297
|
+
async checkCodexHooks() {
|
|
7298
|
+
const codexPaths = getCodexPaths(this.cwd);
|
|
7299
|
+
try {
|
|
7300
|
+
await access(codexPaths.hooks);
|
|
7301
|
+
return {
|
|
7302
|
+
name: "Codex hooks",
|
|
7303
|
+
status: "ok",
|
|
7304
|
+
path: CODEX_HOOKS_REL
|
|
7305
|
+
};
|
|
7306
|
+
} catch {
|
|
7307
|
+
return {
|
|
7308
|
+
name: "Codex hooks",
|
|
7309
|
+
status: "warn",
|
|
7310
|
+
message: "not installed",
|
|
7311
|
+
path: CODEX_HOOKS_REL,
|
|
7312
|
+
suggestion: "Run: tbd setup --auto"
|
|
7313
|
+
};
|
|
7314
|
+
}
|
|
7315
|
+
}
|
|
6929
7316
|
async checkCodexAgents() {
|
|
6930
7317
|
const agentsPath = getAgentsMdPath(this.cwd);
|
|
6931
7318
|
try {
|
|
@@ -6957,24 +7344,51 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
6957
7344
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §4
|
|
6958
7345
|
*/
|
|
6959
7346
|
async checkWorktree(fix) {
|
|
6960
|
-
const worktreePath =
|
|
6961
|
-
const
|
|
7347
|
+
const worktreePath = (await resolveSharedTbdPaths(this.cwd)).sharedWorktreePath;
|
|
7348
|
+
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7349
|
+
const remote = this.config?.sync.remote ?? "origin";
|
|
7350
|
+
const worktreeHealth = await checkWorktreeHealth(this.cwd, syncBranch);
|
|
6962
7351
|
switch (worktreeHealth.status) {
|
|
6963
7352
|
case "valid": return {
|
|
6964
7353
|
name: "Worktree",
|
|
6965
7354
|
status: "ok",
|
|
6966
7355
|
path: worktreePath
|
|
6967
7356
|
};
|
|
6968
|
-
case "missing":
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
7357
|
+
case "missing":
|
|
7358
|
+
if (fix && !this.checkDryRun("Initialize shared data-sync worktree")) {
|
|
7359
|
+
try {
|
|
7360
|
+
await withSharedDataSyncLock(this.cwd, async () => {
|
|
7361
|
+
await prepareDataSyncContext(this.cwd);
|
|
7362
|
+
});
|
|
7363
|
+
} catch (error) {
|
|
7364
|
+
return {
|
|
7365
|
+
name: "Worktree",
|
|
7366
|
+
status: "error",
|
|
7367
|
+
message: `initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
7368
|
+
path: worktreePath
|
|
7369
|
+
};
|
|
7370
|
+
}
|
|
7371
|
+
try {
|
|
7372
|
+
this.config = await readConfig(this.cwd);
|
|
7373
|
+
} catch {}
|
|
7374
|
+
return {
|
|
7375
|
+
name: "Worktree",
|
|
7376
|
+
status: "ok",
|
|
7377
|
+
message: "initialized",
|
|
7378
|
+
path: worktreePath
|
|
7379
|
+
};
|
|
7380
|
+
}
|
|
7381
|
+
return {
|
|
7382
|
+
name: "Worktree",
|
|
7383
|
+
status: "ok",
|
|
7384
|
+
message: "not created yet",
|
|
7385
|
+
path: worktreePath
|
|
7386
|
+
};
|
|
6974
7387
|
case "prunable":
|
|
6975
7388
|
case "corrupted":
|
|
6976
7389
|
if (fix && !this.checkDryRun("Repair worktree")) {
|
|
6977
|
-
const
|
|
7390
|
+
const repairStatus = worktreeHealth.status;
|
|
7391
|
+
const result = await withSharedDataSyncLock(this.cwd, async () => repairWorktree(this.cwd, repairStatus, remote, syncBranch));
|
|
6978
7392
|
if (result.success) return {
|
|
6979
7393
|
name: "Worktree",
|
|
6980
7394
|
status: "ok",
|
|
@@ -7017,10 +7431,80 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7017
7431
|
}
|
|
7018
7432
|
}
|
|
7019
7433
|
/**
|
|
7434
|
+
* Check $GIT_COMMON_DIR/tbd/layout.yml against the checkout config.
|
|
7435
|
+
*
|
|
7436
|
+
* Reports missing (initialized on next mutating command), mismatched (rewrite
|
|
7437
|
+
* from config under --fix), or future-format (requires newer tbd, no fix).
|
|
7438
|
+
*
|
|
7439
|
+
* See: plan-2026-05-17-shared-common-dir-sync-worktree.md §Format And Layout
|
|
7440
|
+
* Versioning.
|
|
7441
|
+
*/
|
|
7442
|
+
async checkCommonDirLayout(fix) {
|
|
7443
|
+
if (!this.config) return {
|
|
7444
|
+
name: "Common-dir layout",
|
|
7445
|
+
status: "ok",
|
|
7446
|
+
message: "skipped (no config)"
|
|
7447
|
+
};
|
|
7448
|
+
const sharedPaths = await resolveSharedTbdPaths(this.cwd);
|
|
7449
|
+
const layoutPath = sharedPaths.sharedLayoutPath;
|
|
7450
|
+
let layout;
|
|
7451
|
+
try {
|
|
7452
|
+
layout = await readCommonDirLayout(layoutPath);
|
|
7453
|
+
} catch (error) {
|
|
7454
|
+
return {
|
|
7455
|
+
name: "Common-dir layout",
|
|
7456
|
+
status: "error",
|
|
7457
|
+
message: error instanceof Error ? error.message : String(error),
|
|
7458
|
+
path: layoutPath
|
|
7459
|
+
};
|
|
7460
|
+
}
|
|
7461
|
+
if (!layout) return {
|
|
7462
|
+
name: "Common-dir layout",
|
|
7463
|
+
status: "ok",
|
|
7464
|
+
message: "not initialized yet (created on first sync)",
|
|
7465
|
+
path: layoutPath
|
|
7466
|
+
};
|
|
7467
|
+
if (!isCompatibleFormat(layout.tbd_format)) return {
|
|
7468
|
+
name: "Common-dir layout",
|
|
7469
|
+
status: "error",
|
|
7470
|
+
message: `requires newer tbd (found ${layout.tbd_format})`,
|
|
7471
|
+
path: layoutPath,
|
|
7472
|
+
suggestion: "Upgrade: npm install -g get-tbd@latest"
|
|
7473
|
+
};
|
|
7474
|
+
try {
|
|
7475
|
+
validateCommonDirLayout(layout, this.config);
|
|
7476
|
+
return {
|
|
7477
|
+
name: "Common-dir layout",
|
|
7478
|
+
status: "ok",
|
|
7479
|
+
path: layoutPath
|
|
7480
|
+
};
|
|
7481
|
+
} catch (error) {
|
|
7482
|
+
if (!(error instanceof CommonDirLayoutError)) throw error;
|
|
7483
|
+
if (fix && !this.checkDryRun("Repair common-dir layout")) {
|
|
7484
|
+
const configRef = this.config;
|
|
7485
|
+
await withSharedDataSyncLock(this.cwd, async () => writeCommonDirLayout(sharedPaths, configRef, layout));
|
|
7486
|
+
return {
|
|
7487
|
+
name: "Common-dir layout",
|
|
7488
|
+
status: "ok",
|
|
7489
|
+
message: "rewritten from config",
|
|
7490
|
+
path: layoutPath
|
|
7491
|
+
};
|
|
7492
|
+
}
|
|
7493
|
+
return {
|
|
7494
|
+
name: "Common-dir layout",
|
|
7495
|
+
status: "error",
|
|
7496
|
+
message: "mismatched with config",
|
|
7497
|
+
path: layoutPath,
|
|
7498
|
+
fixable: true,
|
|
7499
|
+
suggestion: "Run: tbd doctor --fix"
|
|
7500
|
+
};
|
|
7501
|
+
}
|
|
7502
|
+
}
|
|
7503
|
+
/**
|
|
7020
7504
|
* Check for issues in wrong location.
|
|
7021
7505
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
|
|
7022
7506
|
*
|
|
7023
|
-
* Issues should be in
|
|
7507
|
+
* Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/issues/
|
|
7024
7508
|
* If they're in .tbd/data-sync/issues/ on main branch, the worktree was missing
|
|
7025
7509
|
* and data was written to the fallback path - this is a bug requiring migration.
|
|
7026
7510
|
*/
|
|
@@ -7038,7 +7522,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7038
7522
|
if (fix && !this.checkDryRun("Migrate data to worktree")) {
|
|
7039
7523
|
let worktreeHealth = await checkWorktreeHealth(this.cwd);
|
|
7040
7524
|
if (worktreeHealth.status === "missing") {
|
|
7041
|
-
const initResult = await initWorktree(this.cwd);
|
|
7525
|
+
const initResult = await withSharedDataSyncLock(this.cwd, async () => initWorktree(this.cwd));
|
|
7042
7526
|
if (!initResult.success) return {
|
|
7043
7527
|
name: "Data location",
|
|
7044
7528
|
status: "error",
|
|
@@ -7081,7 +7565,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7081
7565
|
path: wrongIssuesPath,
|
|
7082
7566
|
details: [
|
|
7083
7567
|
`Found ${wrongPathIssues.length} issues in .tbd/data-sync/ (wrong)`,
|
|
7084
|
-
"Issues should be in
|
|
7568
|
+
"Issues should be in $GIT_COMMON_DIR/tbd/data-sync-worktree/.tbd/data-sync/",
|
|
7085
7569
|
"This indicates the worktree was missing when issues were created"
|
|
7086
7570
|
],
|
|
7087
7571
|
fixable: true,
|
|
@@ -7094,14 +7578,14 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7094
7578
|
*/
|
|
7095
7579
|
async checkLocalSyncBranch() {
|
|
7096
7580
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7097
|
-
const localHealth = await checkLocalBranchHealth(syncBranch);
|
|
7581
|
+
const localHealth = await checkLocalBranchHealth(syncBranch, this.cwd);
|
|
7098
7582
|
if (localHealth.exists && !localHealth.orphaned) return {
|
|
7099
7583
|
name: "Local sync branch",
|
|
7100
7584
|
status: "ok",
|
|
7101
7585
|
message: syncBranch
|
|
7102
7586
|
};
|
|
7103
7587
|
if (!localHealth.exists) {
|
|
7104
|
-
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7588
|
+
if ((await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7105
7589
|
name: "Local sync branch",
|
|
7106
7590
|
status: "warn",
|
|
7107
7591
|
message: `${syncBranch} not found (remote exists)`,
|
|
@@ -7127,7 +7611,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7127
7611
|
async checkRemoteSyncBranch() {
|
|
7128
7612
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7129
7613
|
const remote = this.config?.sync.remote ?? "origin";
|
|
7130
|
-
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch);
|
|
7614
|
+
const remoteHealth = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
7131
7615
|
if (remoteHealth.exists) {
|
|
7132
7616
|
if (remoteHealth.diverged) return {
|
|
7133
7617
|
name: "Remote sync branch",
|
|
@@ -7141,7 +7625,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7141
7625
|
message: `${remote}/${syncBranch}`
|
|
7142
7626
|
};
|
|
7143
7627
|
}
|
|
7144
|
-
if ((await checkLocalBranchHealth(syncBranch)).exists) return {
|
|
7628
|
+
if ((await checkLocalBranchHealth(syncBranch, this.cwd)).exists) return {
|
|
7145
7629
|
name: "Remote sync branch",
|
|
7146
7630
|
status: "warn",
|
|
7147
7631
|
message: `${remote}/${syncBranch} not found`,
|
|
@@ -7170,7 +7654,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7170
7654
|
status: "ok"
|
|
7171
7655
|
};
|
|
7172
7656
|
const syncBranch = this.config?.sync.branch ?? "tbd-sync";
|
|
7173
|
-
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch)).exists) return {
|
|
7657
|
+
if (!(await checkRemoteBranchHealth(this.config?.sync.remote ?? "origin", syncBranch, this.cwd)).exists) return {
|
|
7174
7658
|
name: "Sync status",
|
|
7175
7659
|
status: "warn",
|
|
7176
7660
|
message: `${localIssueCount} local issues, remote branch not found`,
|
|
@@ -7316,6 +7800,7 @@ var ConfigShowHandler = class extends BaseCommand {
|
|
|
7316
7800
|
console.log(`${colors.dim("sync:")}`);
|
|
7317
7801
|
console.log(` ${colors.dim("branch:")} ${config.sync.branch}`);
|
|
7318
7802
|
console.log(` ${colors.dim("remote:")} ${config.sync.remote}`);
|
|
7803
|
+
console.log(` ${colors.dim("storage:")} ${config.sync.storage}`);
|
|
7319
7804
|
console.log(`${colors.dim("display:")}`);
|
|
7320
7805
|
console.log(` ${colors.dim("id_prefix:")} ${config.display.id_prefix}`);
|
|
7321
7806
|
console.log(`${colors.dim("settings:")}`);
|
|
@@ -7451,9 +7936,9 @@ async function listAtticEntries(tbdRoot, filterById) {
|
|
|
7451
7936
|
var AtticListHandler = class extends BaseCommand {
|
|
7452
7937
|
async run(id) {
|
|
7453
7938
|
const tbdRoot = await requireInit();
|
|
7939
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7454
7940
|
const entries = await listAtticEntries(tbdRoot, id ? normalizeIssueId(id) : void 0);
|
|
7455
|
-
const
|
|
7456
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7941
|
+
const prefix = config.display.id_prefix;
|
|
7457
7942
|
const showDebug = this.ctx.debug;
|
|
7458
7943
|
const output = entries.map((e) => ({
|
|
7459
7944
|
id: showDebug ? formatDebugId(e.entity_id, mapping, prefix) : formatDisplayId(e.entity_id, mapping, prefix),
|
|
@@ -7478,10 +7963,10 @@ var AtticListHandler = class extends BaseCommand {
|
|
|
7478
7963
|
var AtticShowHandler = class extends BaseCommand {
|
|
7479
7964
|
async run(id, timestamp) {
|
|
7480
7965
|
const tbdRoot = await requireInit();
|
|
7966
|
+
const { mapping, config } = await loadDataContext(tbdRoot);
|
|
7481
7967
|
const entry = (await listAtticEntries(tbdRoot, normalizeIssueId(id))).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7482
7968
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
7483
|
-
const
|
|
7484
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7969
|
+
const prefix = config.display.id_prefix;
|
|
7485
7970
|
const displayId = this.ctx.debug ? formatDebugId(entry.entity_id, mapping, prefix) : formatDisplayId(entry.entity_id, mapping, prefix);
|
|
7486
7971
|
this.output.data(entry, () => {
|
|
7487
7972
|
const colors = this.output.getColors();
|
|
@@ -7507,6 +7992,7 @@ var AtticShowHandler = class extends BaseCommand {
|
|
|
7507
7992
|
var AtticRestoreHandler = class extends BaseCommand {
|
|
7508
7993
|
async run(id, timestamp) {
|
|
7509
7994
|
const tbdRoot = await requireInit();
|
|
7995
|
+
await loadDataContext(tbdRoot);
|
|
7510
7996
|
const normalizedId = normalizeIssueId(id);
|
|
7511
7997
|
const entry = (await listAtticEntries(tbdRoot, normalizedId)).find((e) => e.timestamp === timestamp || e.timestamp.replace(/:/g, "-") === timestamp);
|
|
7512
7998
|
if (!entry) throw new NotFoundError("Attic entry", `${id} at ${timestamp}`);
|
|
@@ -7514,24 +8000,24 @@ var AtticRestoreHandler = class extends BaseCommand {
|
|
|
7514
8000
|
id: normalizedId,
|
|
7515
8001
|
field: entry.field
|
|
7516
8002
|
})) return;
|
|
7517
|
-
|
|
7518
|
-
let issue;
|
|
7519
|
-
try {
|
|
7520
|
-
issue = await readIssue(dataSyncDir, normalizedId);
|
|
7521
|
-
} catch {
|
|
7522
|
-
throw new NotFoundError("Issue", id);
|
|
7523
|
-
}
|
|
7524
|
-
const field = entry.field;
|
|
7525
|
-
if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
|
|
7526
|
-
else throw new ValidationError(`Cannot restore field: ${entry.field}`);
|
|
7527
|
-
issue.version += 1;
|
|
7528
|
-
issue.updated_at = now();
|
|
8003
|
+
let displayId = id;
|
|
7529
8004
|
await this.execute(async () => {
|
|
7530
|
-
await
|
|
8005
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, mapping, config }) => {
|
|
8006
|
+
let issue;
|
|
8007
|
+
try {
|
|
8008
|
+
issue = await readIssue(dataSyncDir, normalizedId);
|
|
8009
|
+
} catch {
|
|
8010
|
+
throw new NotFoundError("Issue", id);
|
|
8011
|
+
}
|
|
8012
|
+
const field = entry.field;
|
|
8013
|
+
if (field === "description" || field === "notes" || field === "title") issue[field] = entry.lost_value;
|
|
8014
|
+
else throw new ValidationError(`Cannot restore field: ${entry.field}`);
|
|
8015
|
+
issue.version += 1;
|
|
8016
|
+
issue.updated_at = now();
|
|
8017
|
+
await writeIssue(dataSyncDir, issue);
|
|
8018
|
+
displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, config.display.id_prefix) : formatDisplayId(normalizedId, mapping, config.display.id_prefix);
|
|
8019
|
+
});
|
|
7531
8020
|
}, "Failed to restore from attic");
|
|
7532
|
-
const mapping = await loadIdMapping(dataSyncDir);
|
|
7533
|
-
const prefix = (await readConfig(tbdRoot)).display.id_prefix;
|
|
7534
|
-
const displayId = this.ctx.debug ? formatDebugId(normalizedId, mapping, prefix) : formatDisplayId(normalizedId, mapping, prefix);
|
|
7535
8021
|
this.output.success(`Restored ${entry.field} for ${displayId} from attic entry ${timestamp}`);
|
|
7536
8022
|
}
|
|
7537
8023
|
};
|
|
@@ -7651,14 +8137,18 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7651
8137
|
if (!file && !options.validate) throw new ValidationError("Provide a JSONL file path to import.\n\nFor Beads migration, use: tbd setup --from-beads\nFor workspace import, use: tbd import --workspace=<name> or --outbox");
|
|
7652
8138
|
if (options.validate) {
|
|
7653
8139
|
this.tbdRoot = await requireInit();
|
|
7654
|
-
this.
|
|
7655
|
-
|
|
8140
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8141
|
+
this.dataSyncDir = dataSyncDir;
|
|
8142
|
+
await this.validateImport(options);
|
|
8143
|
+
});
|
|
7656
8144
|
return;
|
|
7657
8145
|
}
|
|
7658
8146
|
if (file) {
|
|
7659
8147
|
this.tbdRoot = await requireInit();
|
|
7660
|
-
this.
|
|
7661
|
-
|
|
8148
|
+
await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8149
|
+
this.dataSyncDir = dataSyncDir;
|
|
8150
|
+
await this.importFromFile(file, options);
|
|
8151
|
+
});
|
|
7662
8152
|
}
|
|
7663
8153
|
}
|
|
7664
8154
|
/**
|
|
@@ -7666,7 +8156,6 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7666
8156
|
*/
|
|
7667
8157
|
async importFromWorkspaceCmd(options) {
|
|
7668
8158
|
this.tbdRoot = await requireInit();
|
|
7669
|
-
this.dataSyncDir = await resolveDataSyncDir(this.tbdRoot);
|
|
7670
8159
|
const wsOptions = {
|
|
7671
8160
|
workspace: options.workspace,
|
|
7672
8161
|
dir: options.dir,
|
|
@@ -7677,7 +8166,10 @@ var ImportHandler = class extends BaseCommand {
|
|
|
7677
8166
|
const spinner = this.output.spinner("Importing from workspace...");
|
|
7678
8167
|
wsOptions.logger = this.output.logger(spinner);
|
|
7679
8168
|
const result = await this.execute(async () => {
|
|
7680
|
-
return await
|
|
8169
|
+
return await withDataSyncContext(this.tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
8170
|
+
this.dataSyncDir = dataSyncDir;
|
|
8171
|
+
return await importFromWorkspace(this.tbdRoot, this.dataSyncDir, wsOptions);
|
|
8172
|
+
});
|
|
7681
8173
|
}, "Failed to import from workspace");
|
|
7682
8174
|
spinner.stop();
|
|
7683
8175
|
if (!result) return;
|
|
@@ -8323,7 +8815,12 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8323
8815
|
const syncBranch = config?.sync.branch ?? SYNC_BRANCH;
|
|
8324
8816
|
const remote = config?.sync.remote ?? "origin";
|
|
8325
8817
|
const tbdDir = join(tbdRoot, ".tbd");
|
|
8326
|
-
const
|
|
8818
|
+
const sharedPaths = await resolveSharedTbdPaths(tbdRoot).catch((error) => {
|
|
8819
|
+
this.output.debug(`resolveSharedTbdPaths failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8820
|
+
return null;
|
|
8821
|
+
});
|
|
8822
|
+
const worktreePath = sharedPaths?.sharedWorktreePath ?? join(tbdDir, "data-sync-worktree");
|
|
8823
|
+
const legacyWorktreePath = join(tbdDir, "data-sync-worktree");
|
|
8327
8824
|
const displayPath = (p) => relative(process.cwd(), p) || p;
|
|
8328
8825
|
const items = [];
|
|
8329
8826
|
let worktreeExists = false;
|
|
@@ -8333,6 +8830,13 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8333
8830
|
const worktreeStats = await this.getDirectoryStats(worktreePath);
|
|
8334
8831
|
items.push(` - Worktree: ${displayPath(worktreePath)} (${worktreeStats.files} files)`);
|
|
8335
8832
|
} catch {}
|
|
8833
|
+
let legacyWorktreeExists = false;
|
|
8834
|
+
if (legacyWorktreePath !== worktreePath) try {
|
|
8835
|
+
await access(legacyWorktreePath);
|
|
8836
|
+
legacyWorktreeExists = true;
|
|
8837
|
+
const legacyStats = await this.getDirectoryStats(legacyWorktreePath);
|
|
8838
|
+
items.push(` - Legacy worktree: ${displayPath(legacyWorktreePath)} (${legacyStats.files} files)`);
|
|
8839
|
+
} catch {}
|
|
8336
8840
|
let localBranchExists = false;
|
|
8337
8841
|
try {
|
|
8338
8842
|
execSync(`git rev-parse --verify ${syncBranch}`, {
|
|
@@ -8396,6 +8900,23 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8396
8900
|
console.log(` ${colors.warn("⚠")} Could not remove worktree directory`);
|
|
8397
8901
|
}
|
|
8398
8902
|
}
|
|
8903
|
+
if (legacyWorktreeExists) try {
|
|
8904
|
+
execSync(`git worktree remove --force "${legacyWorktreePath}"`, {
|
|
8905
|
+
encoding: "utf-8",
|
|
8906
|
+
stdio: [
|
|
8907
|
+
"ignore",
|
|
8908
|
+
"pipe",
|
|
8909
|
+
"ignore"
|
|
8910
|
+
]
|
|
8911
|
+
});
|
|
8912
|
+
console.log(` ${colors.success("✓")} Removed legacy git worktree`);
|
|
8913
|
+
} catch {
|
|
8914
|
+
await rm(legacyWorktreePath, {
|
|
8915
|
+
recursive: true,
|
|
8916
|
+
force: true
|
|
8917
|
+
});
|
|
8918
|
+
console.log(` ${colors.success("✓")} Removed legacy worktree directory`);
|
|
8919
|
+
}
|
|
8399
8920
|
if (localBranchExists && !options.keepBranch) try {
|
|
8400
8921
|
execSync(`git branch -D ${syncBranch}`, {
|
|
8401
8922
|
encoding: "utf-8",
|
|
@@ -8441,6 +8962,15 @@ var UninstallHandler = class extends BaseCommand {
|
|
|
8441
8962
|
} catch (error) {
|
|
8442
8963
|
throw new CLIError(`Failed to remove .tbd directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
8443
8964
|
}
|
|
8965
|
+
if (sharedPaths) try {
|
|
8966
|
+
await rm(sharedPaths.sharedTbdDir, {
|
|
8967
|
+
recursive: true,
|
|
8968
|
+
force: true
|
|
8969
|
+
});
|
|
8970
|
+
console.log(` ${colors.success("✓")} Removed shared common-dir metadata`);
|
|
8971
|
+
} catch {
|
|
8972
|
+
console.log(` ${colors.warn("⚠")} Could not remove shared common-dir metadata`);
|
|
8973
|
+
}
|
|
8444
8974
|
console.log("");
|
|
8445
8975
|
this.output.success("tbd has been uninstalled from this repository.");
|
|
8446
8976
|
if (options.keepBranch && localBranchExists) {
|
|
@@ -9011,7 +9541,8 @@ var PrimeHandler = class extends BaseCommand {
|
|
|
9011
9541
|
*/
|
|
9012
9542
|
async getIssueStats(tbdRoot) {
|
|
9013
9543
|
try {
|
|
9014
|
-
const
|
|
9544
|
+
const { dataSyncDir } = await loadDataContext(tbdRoot);
|
|
9545
|
+
const issues = await listIssues(dataSyncDir);
|
|
9015
9546
|
let open = 0;
|
|
9016
9547
|
let inProgress = 0;
|
|
9017
9548
|
const blockedIds = /* @__PURE__ */ new Set();
|
|
@@ -9863,16 +10394,89 @@ async function getShortcutDirectory(quiet = false) {
|
|
|
9863
10394
|
return generateShortcutDirectory(shortcuts, guidelines);
|
|
9864
10395
|
}
|
|
9865
10396
|
/**
|
|
9866
|
-
*
|
|
9867
|
-
*
|
|
10397
|
+
* DO NOT EDIT marker inserted after the frontmatter of every generated SKILL.md.
|
|
10398
|
+
* Formatted to match flowmark output.
|
|
10399
|
+
*/
|
|
10400
|
+
const SKILL_DO_NOT_EDIT_MARKER = "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->";
|
|
10401
|
+
/**
|
|
10402
|
+
* Build the full generated SKILL.md payload: the bundled skill content with the
|
|
10403
|
+
* shortcut/guideline directory appended and a DO NOT EDIT marker after the
|
|
10404
|
+
* frontmatter. This is the single source for every skill surface (the portable
|
|
10405
|
+
* .agents/skills install and the .claude/skills mirror) so they stay identical.
|
|
9868
10406
|
*
|
|
9869
|
-
* @param quiet - If true, suppress auto-sync output
|
|
10407
|
+
* @param quiet - If true, suppress auto-sync output.
|
|
9870
10408
|
*/
|
|
9871
|
-
async function
|
|
9872
|
-
let
|
|
10409
|
+
async function buildSkillPayload(quiet = false) {
|
|
10410
|
+
let skillContent = await loadSkillContent();
|
|
9873
10411
|
const directory = await getShortcutDirectory(quiet);
|
|
9874
|
-
if (directory)
|
|
9875
|
-
|
|
10412
|
+
if (directory) skillContent = skillContent.trimEnd() + "\n\n" + directory;
|
|
10413
|
+
skillContent = insertAfterFrontmatter(skillContent, SKILL_DO_NOT_EDIT_MARKER);
|
|
10414
|
+
return skillContent.trimEnd() + "\n";
|
|
10415
|
+
}
|
|
10416
|
+
/**
|
|
10417
|
+
* Write a generated SKILL.md payload to a target path, creating parent dirs.
|
|
10418
|
+
*/
|
|
10419
|
+
async function writeSkillFile(targetPath, payload) {
|
|
10420
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
10421
|
+
await writeFile(targetPath, payload);
|
|
10422
|
+
}
|
|
10423
|
+
/**
|
|
10424
|
+
* AGENTS.md managed-block markers. `CODEX_BEGIN_MARKER` is the stable PREFIX of
|
|
10425
|
+
* the begin line — the metadata (`format=fNN surface=agents-md`) follows it on the
|
|
10426
|
+
* same line, e.g. `<!-- BEGIN TBD INTEGRATION format=f02 surface=agents-md -->`.
|
|
10427
|
+
* Cleanup/upgrade code matches on this prefix so it finds both legacy
|
|
10428
|
+
* (`<!-- BEGIN TBD INTEGRATION -->`) and current marked blocks.
|
|
10429
|
+
*/
|
|
10430
|
+
const CODEX_BEGIN_MARKER = "<!-- BEGIN TBD INTEGRATION";
|
|
10431
|
+
const CODEX_END_MARKER = "<!-- END TBD INTEGRATION -->";
|
|
10432
|
+
/** The full begin marker line for newly generated blocks. */
|
|
10433
|
+
const CODEX_BEGIN_LINE = `${CODEX_BEGIN_MARKER} format=${AGENT_INTEGRATION_FORMAT} surface=agents-md -->`;
|
|
10434
|
+
/** Numeric value of an `fNN` format string, for ordering comparisons. */
|
|
10435
|
+
function formatToNumber(format) {
|
|
10436
|
+
return Number.parseInt(format.replace(/^f/, ""), 10);
|
|
10437
|
+
}
|
|
10438
|
+
/**
|
|
10439
|
+
* Read the integration format (`fNN`) stamped in a generated artifact's begin
|
|
10440
|
+
* marker. Returns `f01` for a legacy marked block with no `format=` field, or
|
|
10441
|
+
* null when there is no tbd-managed block at all.
|
|
10442
|
+
*/
|
|
10443
|
+
function parseIntegrationFormat(content) {
|
|
10444
|
+
const match = /format=f(\d+)/.exec(content);
|
|
10445
|
+
if (match?.[1]) return `f${match[1]}`;
|
|
10446
|
+
return content.includes(CODEX_BEGIN_MARKER) ? "f01" : null;
|
|
10447
|
+
}
|
|
10448
|
+
/**
|
|
10449
|
+
* Forward-compatibility guard. If a generated artifact was written by a NEWER
|
|
10450
|
+
* tbd than this one understands, refuse to rewrite it and tell the user to
|
|
10451
|
+
* upgrade tbd — overwriting would downgrade a newer managed format. This is what
|
|
10452
|
+
* makes pinning safe on a team: an older tbd fails loudly instead of clobbering.
|
|
10453
|
+
*/
|
|
10454
|
+
function assertNotNewerFormat(content, artifact) {
|
|
10455
|
+
const format = parseIntegrationFormat(content);
|
|
10456
|
+
if (format !== null && formatToNumber(format) > formatToNumber(AGENT_INTEGRATION_FORMAT)) throw new CLIError(`${artifact} was generated by a newer tbd (integration format ${format}; this tbd supports up to ${AGENT_INTEGRATION_FORMAT}).\nUpgrade tbd to manage it: npm install -g get-tbd@latest`);
|
|
10457
|
+
}
|
|
10458
|
+
/**
|
|
10459
|
+
* Get the tbd managed block for AGENTS.md.
|
|
10460
|
+
*
|
|
10461
|
+
* This is a COMPACT always-on bootstrap, not a copy of the full skill: it names
|
|
10462
|
+
* tbd, states the operating rule, and points to the CLI commands that provide
|
|
10463
|
+
* progressive disclosure (`tbd prime`, `tbd skill`, `tbd shortcut/guidelines
|
|
10464
|
+
* --list`). The full skill body lives in the SKILL.md surfaces, not here, so the
|
|
10465
|
+
* block stays well under the AGENTS.md prompt-budget guidance.
|
|
10466
|
+
*/
|
|
10467
|
+
function getCodexTbdSection() {
|
|
10468
|
+
return `${CODEX_BEGIN_LINE}\n## tbd
|
|
10469
|
+
|
|
10470
|
+
This repository uses **tbd** for git-native issue tracking (beads), spec-driven
|
|
10471
|
+
planning, and on-demand engineering guidelines.
|
|
10472
|
+
As the agent, you operate tbd on the user’s behalf — translate their requests into tbd
|
|
10473
|
+
actions rather than telling them to run commands.
|
|
10474
|
+
|
|
10475
|
+
- Run \`tbd prime\` to load current project state and the full tbd workflow.
|
|
10476
|
+
- Run \`tbd skill\` for the complete reusable tbd skill instructions.
|
|
10477
|
+
- Run \`tbd shortcut --list\` and \`tbd guidelines --list\` for on-demand resources.
|
|
10478
|
+
- Track all work as beads: \`tbd create\`, \`tbd ready\`, \`tbd close\`, and \`tbd sync\`.
|
|
10479
|
+
\n${CODEX_END_MARKER}\n`;
|
|
9876
10480
|
}
|
|
9877
10481
|
/**
|
|
9878
10482
|
* Script to ensure tbd CLI is installed and run tbd prime.
|
|
@@ -9884,82 +10488,32 @@ async function getCodexTbdSection(quiet = false) {
|
|
|
9884
10488
|
* tbd-session.sh --brief # Ensure tbd + run tbd prime --brief (for PreCompact)
|
|
9885
10489
|
*/
|
|
9886
10490
|
const TBD_SESSION_SCRIPT = `#!/bin/bash
|
|
9887
|
-
# Ensure tbd CLI is
|
|
9888
|
-
# Installed by: tbd setup --auto
|
|
9889
|
-
#
|
|
9890
|
-
|
|
9891
|
-
#
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
NPM_PREFIX=$(npm config get prefix 2>/dev/null)
|
|
9895
|
-
if [ -n "$NPM_PREFIX" ] && [ -d "$NPM_PREFIX/bin" ]; then
|
|
9896
|
-
NPM_GLOBAL_BIN="$NPM_PREFIX/bin"
|
|
9897
|
-
fi
|
|
9898
|
-
fi
|
|
9899
|
-
|
|
9900
|
-
# Add common binary locations to PATH (persists for entire script)
|
|
9901
|
-
# Include npm global bin if found
|
|
9902
|
-
export PATH="$NPM_GLOBAL_BIN:$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH"
|
|
9903
|
-
|
|
9904
|
-
# Function to ensure tbd is available
|
|
9905
|
-
ensure_tbd() {
|
|
9906
|
-
# Check if tbd is already installed
|
|
9907
|
-
if command -v tbd &> /dev/null; then
|
|
9908
|
-
return 0
|
|
9909
|
-
fi
|
|
9910
|
-
|
|
9911
|
-
echo "[tbd] CLI not found, installing..."
|
|
10491
|
+
# Ensure the tbd CLI is available and run \`tbd prime\`.
|
|
10492
|
+
# Installed by: tbd setup --auto. Runs on SessionStart and PreCompact.
|
|
10493
|
+
#
|
|
10494
|
+
# Local-first, then a VERSION-PINNED zero-install fallback. Pinning is both a
|
|
10495
|
+
# supply-chain control (an unpinned runner re-resolves to latest on every run
|
|
10496
|
+
# and bypasses any cool-off) and a consistency control (every teammate and agent
|
|
10497
|
+
# runs the same tbd version).
|
|
9912
10498
|
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
echo "[tbd] Installing via npm..."
|
|
9916
|
-
npm install -g get-tbd 2>/dev/null || {
|
|
9917
|
-
# If global install fails (permissions), try local install
|
|
9918
|
-
echo "[tbd] Global npm install failed, trying user install..."
|
|
9919
|
-
mkdir -p ~/.local/bin
|
|
9920
|
-
npm install --prefix ~/.local get-tbd
|
|
9921
|
-
# Create symlink if needed
|
|
9922
|
-
if [ -f ~/.local/node_modules/.bin/tbd ]; then
|
|
9923
|
-
ln -sf ~/.local/node_modules/.bin/tbd ~/.local/bin/tbd
|
|
9924
|
-
fi
|
|
9925
|
-
}
|
|
9926
|
-
elif command -v pnpm &> /dev/null; then
|
|
9927
|
-
echo "[tbd] Installing via pnpm..."
|
|
9928
|
-
pnpm add -g get-tbd
|
|
9929
|
-
elif command -v yarn &> /dev/null; then
|
|
9930
|
-
echo "[tbd] Installing via yarn..."
|
|
9931
|
-
yarn global add get-tbd
|
|
9932
|
-
else
|
|
9933
|
-
echo "[tbd] ERROR: No package manager found (npm, pnpm, or yarn required)"
|
|
9934
|
-
echo "[tbd] Please install Node.js and npm, then run: npm install -g get-tbd"
|
|
9935
|
-
return 1
|
|
9936
|
-
fi
|
|
10499
|
+
# Prefer common local bin locations.
|
|
10500
|
+
export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH"
|
|
9937
10501
|
|
|
9938
|
-
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
9943
|
-
echo "[tbd] WARNING: tbd installed but not found in PATH"
|
|
9944
|
-
echo "[tbd] Checking common locations..."
|
|
9945
|
-
# Try to find and add to path (include npm global bin)
|
|
9946
|
-
for dir in "$NPM_GLOBAL_BIN" ~/.local/bin ~/.local/node_modules/.bin /usr/local/bin; do
|
|
9947
|
-
if [ -n "$dir" ] && [ -x "$dir/tbd" ]; then
|
|
9948
|
-
export PATH="$dir:$PATH"
|
|
9949
|
-
echo "[tbd] Found at $dir/tbd"
|
|
9950
|
-
return 0
|
|
9951
|
-
fi
|
|
9952
|
-
done
|
|
9953
|
-
echo "[tbd] Could not locate tbd after installation"
|
|
9954
|
-
return 1
|
|
9955
|
-
fi
|
|
9956
|
-
}
|
|
10502
|
+
# Local-first: use tbd if it is already on PATH.
|
|
10503
|
+
if command -v tbd &> /dev/null; then
|
|
10504
|
+
tbd prime "$@"
|
|
10505
|
+
exit $?
|
|
10506
|
+
fi
|
|
9957
10507
|
|
|
9958
|
-
#
|
|
9959
|
-
|
|
10508
|
+
# Pinned zero-install fallback. Never use an unpinned runner here.
|
|
10509
|
+
if command -v npx &> /dev/null; then
|
|
10510
|
+
npx --yes get-tbd@${VERSION} prime "$@"
|
|
10511
|
+
exit $?
|
|
10512
|
+
fi
|
|
9960
10513
|
|
|
9961
|
-
|
|
9962
|
-
tbd
|
|
10514
|
+
echo "[tbd] tbd CLI not found and npx is unavailable."
|
|
10515
|
+
echo "[tbd] Install it with: npm install -g get-tbd@${VERSION}"
|
|
10516
|
+
exit 1
|
|
9963
10517
|
`;
|
|
9964
10518
|
/**
|
|
9965
10519
|
* Claude Code session hooks configuration.
|
|
@@ -10049,22 +10603,14 @@ async function loadBundledScript(name) {
|
|
|
10049
10603
|
throw new Error(`Bundled script not found: ${name}`);
|
|
10050
10604
|
}
|
|
10051
10605
|
/**
|
|
10052
|
-
* AGENTS.md integration markers for Codex/Factory.ai
|
|
10053
|
-
* Content is now generated dynamically from SKILL.md via getCodexTbdSection()
|
|
10054
|
-
*/
|
|
10055
|
-
const CODEX_BEGIN_MARKER = "<!-- BEGIN TBD INTEGRATION -->";
|
|
10056
|
-
const CODEX_END_MARKER = "<!-- END TBD INTEGRATION -->";
|
|
10057
|
-
/**
|
|
10058
10606
|
* Generate a new AGENTS.md file with tbd integration.
|
|
10059
|
-
*
|
|
10060
|
-
* @param quiet - If true, suppress auto-sync output (default: false)
|
|
10061
10607
|
*/
|
|
10062
|
-
|
|
10608
|
+
function getCodexNewAgentsFile() {
|
|
10063
10609
|
return `# Project Instructions for AI Agents
|
|
10064
10610
|
|
|
10065
10611
|
This file provides instructions and context for AI coding agents working on this project.
|
|
10066
10612
|
|
|
10067
|
-
${
|
|
10613
|
+
${getCodexTbdSection()}
|
|
10068
10614
|
## Build & Test
|
|
10069
10615
|
|
|
10070
10616
|
_Add your build and test commands here_
|
|
@@ -10085,6 +10631,54 @@ _Add your project-specific conventions here_
|
|
|
10085
10631
|
`;
|
|
10086
10632
|
}
|
|
10087
10633
|
/**
|
|
10634
|
+
* Codex project-local script paths (relative to repo root). Codex hooks
|
|
10635
|
+
* reference ONLY these `.codex/` paths, never `.claude/`, so Codex setup stays
|
|
10636
|
+
* independent of Claude Code setup.
|
|
10637
|
+
*/
|
|
10638
|
+
const CODEX_SESSION_SCRIPT_REL = ".codex/tbd-session.sh";
|
|
10639
|
+
const CODEX_CLOSING_REMINDER_REL = ".codex/tbd-closing-reminder.sh";
|
|
10640
|
+
const CODEX_GH_CLI_SCRIPT_REL = ".codex/ensure-gh-cli.sh";
|
|
10641
|
+
/**
|
|
10642
|
+
* Build the Codex hooks.json content. Codex uses the same lifecycle event
|
|
10643
|
+
* schema as Claude Code (command handlers), so tbd's hooks map almost 1:1:
|
|
10644
|
+
* SessionStart and PreCompact run `tbd prime`, PostToolUse reminds about sync
|
|
10645
|
+
* after `git push`, and (when enabled) a second SessionStart entry ensures gh.
|
|
10646
|
+
*/
|
|
10647
|
+
function getCodexHooksConfig(useGhCli) {
|
|
10648
|
+
const sessionStart = [{
|
|
10649
|
+
matcher: "",
|
|
10650
|
+
hooks: [{
|
|
10651
|
+
type: "command",
|
|
10652
|
+
command: `bash ${CODEX_SESSION_SCRIPT_REL}`
|
|
10653
|
+
}]
|
|
10654
|
+
}];
|
|
10655
|
+
if (useGhCli) sessionStart.push({
|
|
10656
|
+
matcher: "",
|
|
10657
|
+
hooks: [{
|
|
10658
|
+
type: "command",
|
|
10659
|
+
command: `bash ${CODEX_GH_CLI_SCRIPT_REL}`,
|
|
10660
|
+
timeout: 120
|
|
10661
|
+
}]
|
|
10662
|
+
});
|
|
10663
|
+
return { hooks: {
|
|
10664
|
+
SessionStart: sessionStart,
|
|
10665
|
+
PreCompact: [{
|
|
10666
|
+
matcher: "",
|
|
10667
|
+
hooks: [{
|
|
10668
|
+
type: "command",
|
|
10669
|
+
command: `bash ${CODEX_SESSION_SCRIPT_REL} --brief`
|
|
10670
|
+
}]
|
|
10671
|
+
}],
|
|
10672
|
+
PostToolUse: [{
|
|
10673
|
+
matcher: "Bash",
|
|
10674
|
+
hooks: [{
|
|
10675
|
+
type: "command",
|
|
10676
|
+
command: `bash ${CODEX_CLOSING_REMINDER_REL}`
|
|
10677
|
+
}]
|
|
10678
|
+
}]
|
|
10679
|
+
} };
|
|
10680
|
+
}
|
|
10681
|
+
/**
|
|
10088
10682
|
* Legacy script patterns to clean up from .claude/scripts/
|
|
10089
10683
|
* These were used in older versions of tbd before hooks moved to `tbd prime`
|
|
10090
10684
|
*/
|
|
@@ -10345,13 +10939,7 @@ var SetupClaudeHandler = class extends BaseCommand {
|
|
|
10345
10939
|
await writeFile(claudePaths.closingReminder, TBD_CLOSE_PROTOCOL_SCRIPT);
|
|
10346
10940
|
await chmod(claudePaths.closingReminder, 493);
|
|
10347
10941
|
this.output.success("Installed sync reminder hook script");
|
|
10348
|
-
await
|
|
10349
|
-
let skillContent = await loadSkillContent();
|
|
10350
|
-
const directory = await getShortcutDirectory(this.ctx.quiet);
|
|
10351
|
-
if (directory) skillContent = skillContent.trimEnd() + "\n\n" + directory;
|
|
10352
|
-
skillContent = insertAfterFrontmatter(skillContent, "<!-- DO NOT EDIT: Generated by tbd setup.\nRun 'tbd setup' to update.\n-->");
|
|
10353
|
-
skillContent = skillContent.trimEnd() + "\n";
|
|
10354
|
-
await writeFile(skillPath, skillContent);
|
|
10942
|
+
await writeSkillFile(skillPath, await buildSkillPayload(this.ctx.quiet));
|
|
10355
10943
|
this.output.success("Installed skill file");
|
|
10356
10944
|
this.output.info(` ${skillPath}`);
|
|
10357
10945
|
this.output.info("");
|
|
@@ -10384,16 +10972,93 @@ var SetupCodexHandler = class extends BaseCommand {
|
|
|
10384
10972
|
this.projectDir = dir;
|
|
10385
10973
|
}
|
|
10386
10974
|
async run(options) {
|
|
10387
|
-
const
|
|
10975
|
+
const cwd = this.projectDir ?? process.cwd();
|
|
10976
|
+
const agentsPath = join(cwd, "AGENTS.md");
|
|
10388
10977
|
if (options.check) {
|
|
10389
10978
|
await this.checkCodexSetup(agentsPath);
|
|
10390
10979
|
return;
|
|
10391
10980
|
}
|
|
10392
10981
|
if (options.remove) {
|
|
10393
10982
|
await this.removeCodexSection(agentsPath);
|
|
10983
|
+
await this.removeCodexHooks(cwd);
|
|
10394
10984
|
return;
|
|
10395
10985
|
}
|
|
10396
10986
|
await this.installCodexSection(agentsPath);
|
|
10987
|
+
await this.installCodexHooks(cwd);
|
|
10988
|
+
}
|
|
10989
|
+
/**
|
|
10990
|
+
* Read the use_gh_cli setting; defaults to true (so fresh setup installs it).
|
|
10991
|
+
*/
|
|
10992
|
+
async getUseGhCliSetting(cwd) {
|
|
10993
|
+
try {
|
|
10994
|
+
const tbdRoot = await findTbdRoot(cwd);
|
|
10995
|
+
if (!tbdRoot) return true;
|
|
10996
|
+
return (await readConfig(tbdRoot)).settings.use_gh_cli ?? true;
|
|
10997
|
+
} catch {
|
|
10998
|
+
return true;
|
|
10999
|
+
}
|
|
11000
|
+
}
|
|
11001
|
+
/**
|
|
11002
|
+
* Install Codex lifecycle hooks: writes .codex/ scripts and a .codex/hooks.json
|
|
11003
|
+
* (merged idempotently with any user hooks). Scripts reuse the same bodies as
|
|
11004
|
+
* the Claude install but live under .codex/ so Codex never references .claude/.
|
|
11005
|
+
*/
|
|
11006
|
+
async installCodexHooks(cwd) {
|
|
11007
|
+
if (this.checkDryRun("Would install Codex hooks", { path: "./.codex/hooks.json" })) return;
|
|
11008
|
+
const codexPaths = getCodexPaths(cwd);
|
|
11009
|
+
await mkdir(codexPaths.dir, { recursive: true });
|
|
11010
|
+
const useGhCli = await this.getUseGhCliSetting(cwd);
|
|
11011
|
+
await writeFile(join(cwd, CODEX_SESSION_SCRIPT_REL), TBD_SESSION_SCRIPT);
|
|
11012
|
+
await chmod(join(cwd, CODEX_SESSION_SCRIPT_REL), 493);
|
|
11013
|
+
await writeFile(join(cwd, CODEX_CLOSING_REMINDER_REL), TBD_CLOSE_PROTOCOL_SCRIPT);
|
|
11014
|
+
await chmod(join(cwd, CODEX_CLOSING_REMINDER_REL), 493);
|
|
11015
|
+
if (useGhCli) {
|
|
11016
|
+
const ghScriptContent = await loadBundledScript("ensure-gh-cli.sh");
|
|
11017
|
+
await writeFile(join(cwd, CODEX_GH_CLI_SCRIPT_REL), ghScriptContent);
|
|
11018
|
+
await chmod(join(cwd, CODEX_GH_CLI_SCRIPT_REL), 493);
|
|
11019
|
+
} else await rm(join(cwd, CODEX_GH_CLI_SCRIPT_REL), { force: true });
|
|
11020
|
+
let existing = {};
|
|
11021
|
+
try {
|
|
11022
|
+
existing = JSON.parse(await readFile(codexPaths.hooks, "utf-8"));
|
|
11023
|
+
} catch {}
|
|
11024
|
+
const isTbdOwned = (entry) => {
|
|
11025
|
+
return (entry.hooks ?? []).some((h) => h.command?.includes(".codex/"));
|
|
11026
|
+
};
|
|
11027
|
+
const merged = { ...existing.hooks ?? {} };
|
|
11028
|
+
const tbdHooks = getCodexHooksConfig(useGhCli).hooks;
|
|
11029
|
+
for (const [event, entries] of Object.entries(tbdHooks)) merged[event] = [...(merged[event] ?? []).filter((e) => !isTbdOwned(e)), ...entries];
|
|
11030
|
+
await writeFile(codexPaths.hooks, JSON.stringify({
|
|
11031
|
+
...existing,
|
|
11032
|
+
hooks: merged
|
|
11033
|
+
}, null, 2) + "\n");
|
|
11034
|
+
this.output.success("Installed Codex hooks (.codex/hooks.json)");
|
|
11035
|
+
}
|
|
11036
|
+
/**
|
|
11037
|
+
* Remove tbd-owned Codex hook entries and scripts, preserving user hooks.
|
|
11038
|
+
*/
|
|
11039
|
+
async removeCodexHooks(cwd) {
|
|
11040
|
+
const codexPaths = getCodexPaths(cwd);
|
|
11041
|
+
try {
|
|
11042
|
+
const existing = JSON.parse(await readFile(codexPaths.hooks, "utf-8"));
|
|
11043
|
+
const hooks = existing.hooks ?? {};
|
|
11044
|
+
for (const event of Object.keys(hooks)) {
|
|
11045
|
+
const kept = (hooks[event] ?? []).filter((entry) => {
|
|
11046
|
+
return !(entry.hooks ?? []).some((h) => h.command?.includes(".codex/"));
|
|
11047
|
+
});
|
|
11048
|
+
if (kept.length === 0) delete hooks[event];
|
|
11049
|
+
else hooks[event] = kept;
|
|
11050
|
+
}
|
|
11051
|
+
if (Object.keys(hooks).length === 0) await rm(codexPaths.hooks, { force: true });
|
|
11052
|
+
else await writeFile(codexPaths.hooks, JSON.stringify({
|
|
11053
|
+
...existing,
|
|
11054
|
+
hooks
|
|
11055
|
+
}, null, 2) + "\n");
|
|
11056
|
+
} catch {}
|
|
11057
|
+
for (const rel of [
|
|
11058
|
+
CODEX_SESSION_SCRIPT_REL,
|
|
11059
|
+
CODEX_CLOSING_REMINDER_REL,
|
|
11060
|
+
CODEX_GH_CLI_SCRIPT_REL
|
|
11061
|
+
]) await rm(join(cwd, rel), { force: true });
|
|
10397
11062
|
}
|
|
10398
11063
|
async checkCodexSetup(agentsPath) {
|
|
10399
11064
|
const agentsRelPath = "./AGENTS.md";
|
|
@@ -10478,8 +11143,9 @@ var SetupCodexHandler = class extends BaseCommand {
|
|
|
10478
11143
|
existingContent = await readFile(agentsPath, "utf-8");
|
|
10479
11144
|
} catch {}
|
|
10480
11145
|
let newContent;
|
|
10481
|
-
const tbdSection =
|
|
11146
|
+
const tbdSection = getCodexTbdSection();
|
|
10482
11147
|
if (existingContent) if (existingContent.includes(CODEX_BEGIN_MARKER)) {
|
|
11148
|
+
assertNotNewerFormat(existingContent, "AGENTS.md");
|
|
10483
11149
|
newContent = this.updatetbdSection(existingContent, tbdSection);
|
|
10484
11150
|
await writeFile(agentsPath, newContent);
|
|
10485
11151
|
this.output.success("Updated existing tbd section in AGENTS.md");
|
|
@@ -10489,7 +11155,7 @@ var SetupCodexHandler = class extends BaseCommand {
|
|
|
10489
11155
|
this.output.success("Added tbd section to existing AGENTS.md");
|
|
10490
11156
|
}
|
|
10491
11157
|
else {
|
|
10492
|
-
await writeFile(agentsPath,
|
|
11158
|
+
await writeFile(agentsPath, getCodexNewAgentsFile());
|
|
10493
11159
|
this.output.success("Created new AGENTS.md with tbd integration");
|
|
10494
11160
|
}
|
|
10495
11161
|
this.output.info(` File: ${agentsPath}`);
|
|
@@ -10552,25 +11218,26 @@ var SetupDefaultHandler = class extends BaseCommand {
|
|
|
10552
11218
|
if (!gitRoot) throw new CLIError("Could not determine git repository root.");
|
|
10553
11219
|
const projectDir = gitRoot;
|
|
10554
11220
|
const hasTbd = await isInitialized(projectDir);
|
|
10555
|
-
const hasBeads = await pathExists(join(projectDir, ".beads"));
|
|
11221
|
+
const hasBeads = await pathExists$1(join(projectDir, ".beads"));
|
|
10556
11222
|
if (options.fromBeads && !hasBeads) throw new CLIError("The --from-beads flag requires a .beads/ directory to migrate from.\nFor fresh setup, use: tbd setup --auto --prefix=<name>");
|
|
10557
11223
|
console.log("Checking repository...");
|
|
10558
11224
|
console.log(` ${colors.success("✓")} Git repository detected`);
|
|
10559
11225
|
if (hasTbd) {
|
|
10560
11226
|
const { config, migrated, changes } = await readConfigWithMigration(projectDir);
|
|
10561
11227
|
console.log(` ${colors.success("✓")} tbd initialized (prefix: ${config.display.id_prefix})`);
|
|
10562
|
-
let
|
|
11228
|
+
let ghCliChanged = false;
|
|
10563
11229
|
if (options.ghCli === false && config.settings.use_gh_cli !== false) {
|
|
10564
11230
|
config.settings.use_gh_cli = false;
|
|
10565
|
-
|
|
11231
|
+
ghCliChanged = true;
|
|
11232
|
+
}
|
|
11233
|
+
if (migrated) {
|
|
11234
|
+
await withDataSyncContext(projectDir, { lock: true }, async () => void 0);
|
|
11235
|
+
console.log(` ${colors.success("✓")} Config migrated to latest format`);
|
|
11236
|
+
for (const change of changes) console.log(` ${colors.dim(change)}`);
|
|
10566
11237
|
}
|
|
10567
|
-
if (
|
|
11238
|
+
if (ghCliChanged) {
|
|
10568
11239
|
await writeConfig(projectDir, config);
|
|
10569
|
-
|
|
10570
|
-
console.log(` ${colors.success("✓")} Config migrated to latest format`);
|
|
10571
|
-
for (const change of changes) console.log(` ${colors.dim(change)}`);
|
|
10572
|
-
}
|
|
10573
|
-
if (options.ghCli === false) console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
|
|
11240
|
+
console.log(` ${colors.success("✓")} Disabled gh CLI auto-setup`);
|
|
10574
11241
|
}
|
|
10575
11242
|
console.log("");
|
|
10576
11243
|
await this.handleAlreadyInitialized(projectDir, isAutoMode);
|
|
@@ -10718,7 +11385,7 @@ Example:
|
|
|
10718
11385
|
}
|
|
10719
11386
|
async initializeTbd(cwd, prefix) {
|
|
10720
11387
|
const colors = this.output.getColors();
|
|
10721
|
-
await initConfig(cwd, VERSION, prefix);
|
|
11388
|
+
const config = await initConfig(cwd, VERSION, prefix);
|
|
10722
11389
|
console.log(` ${colors.success("✓")} Created .tbd/config.yml`);
|
|
10723
11390
|
const tbdGitignoreResult = await ensureGitignorePatterns(join(cwd, TBD_DIR, ".gitignore"), [
|
|
10724
11391
|
"# Synced documentation cache (regenerated by tbd sync --docs)",
|
|
@@ -10749,7 +11416,10 @@ Example:
|
|
|
10749
11416
|
if (gitattributesResult.created) console.log(` ${colors.success("✓")} Created .tbd/.gitattributes (merge protection)`);
|
|
10750
11417
|
else if (gitattributesResult.added.length > 0) console.log(` ${colors.success("✓")} Updated .tbd/.gitattributes (merge protection)`);
|
|
10751
11418
|
try {
|
|
10752
|
-
await
|
|
11419
|
+
await withSharedDataSyncLock(cwd, async () => {
|
|
11420
|
+
await initWorktree(cwd);
|
|
11421
|
+
await writeCommonDirLayout(await resolveSharedTbdPaths(cwd), config);
|
|
11422
|
+
});
|
|
10753
11423
|
const health = await checkWorktreeHealth(cwd);
|
|
10754
11424
|
if (health.valid) console.log(` ${colors.success("✓")} Initialized sync branch`);
|
|
10755
11425
|
else {
|
|
@@ -10859,9 +11529,11 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
10859
11529
|
console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
|
|
10860
11530
|
}
|
|
10861
11531
|
await this.syncDocs(cwd);
|
|
10862
|
-
const
|
|
11532
|
+
const targeting = this.resolveTargeting();
|
|
11533
|
+
await this.installPortableSkill(cwd);
|
|
11534
|
+
const claudeResult = await this.setupClaudeIfDetected(cwd, targeting.claude);
|
|
10863
11535
|
results.push(claudeResult);
|
|
10864
|
-
const codexResult = await this.setupCodexIfDetected(cwd);
|
|
11536
|
+
const codexResult = await this.setupCodexIfDetected(cwd, targeting.codex);
|
|
10865
11537
|
results.push(codexResult);
|
|
10866
11538
|
const installed = results.filter((r) => r.installed && !r.alreadyInstalled);
|
|
10867
11539
|
const alreadyInstalled = results.filter((r) => r.alreadyInstalled);
|
|
@@ -10904,24 +11576,59 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
10904
11576
|
if (result.pruned.length > 0) console.log(colors.dim(`Pruned ${result.pruned.length} stale config entry/entries`));
|
|
10905
11577
|
if (result.errors.length > 0) for (const { path, error } of result.errors) console.log(colors.warn(`Warning: ${path}: ${error}`));
|
|
10906
11578
|
}
|
|
10907
|
-
|
|
11579
|
+
/**
|
|
11580
|
+
* Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
|
|
11581
|
+
* Runs for every initialized repo, independent of agent detection, so the
|
|
11582
|
+
* skill is portable across Codex, Gemini CLI, Cursor, and other clients.
|
|
11583
|
+
*/
|
|
11584
|
+
async installPortableSkill(cwd) {
|
|
11585
|
+
const colors = this.output.getColors();
|
|
11586
|
+
if (this.checkDryRun("Would install portable Agent Skill", { path: AGENTS_SKILL_DISPLAY })) return;
|
|
11587
|
+
const { portable } = getAgentSkillPaths(cwd);
|
|
11588
|
+
await writeSkillFile(portable, await buildSkillPayload(this.ctx.quiet));
|
|
11589
|
+
console.log(` ${colors.success("✓")} Portable Agent Skill (${AGENTS_SKILL_DISPLAY})`);
|
|
11590
|
+
}
|
|
11591
|
+
/**
|
|
11592
|
+
* Resolve which agent surfaces to install from the explicit targeting flags.
|
|
11593
|
+
* `--all`/`--claude`/`--codex` force surfaces on (and suppress auto-detection
|
|
11594
|
+
* of untargeted surfaces); `--skip-claude`/`--skip-codex` force them off; with
|
|
11595
|
+
* no targeting flag each surface falls back to detection-based auto behavior.
|
|
11596
|
+
*/
|
|
11597
|
+
resolveTargeting() {
|
|
11598
|
+
const opts = this.cmd.optsWithGlobals();
|
|
11599
|
+
const all = opts.all === true;
|
|
11600
|
+
const anyPositive = all || opts.claude === true || opts.codex === true;
|
|
11601
|
+
const resolve = (on, skip) => {
|
|
11602
|
+
if (skip === true) return "off";
|
|
11603
|
+
if (on === true || all) return "on";
|
|
11604
|
+
return anyPositive ? "off" : "auto";
|
|
11605
|
+
};
|
|
11606
|
+
return {
|
|
11607
|
+
claude: resolve(opts.claude, opts.skipClaude),
|
|
11608
|
+
codex: resolve(opts.codex, opts.skipCodex)
|
|
11609
|
+
};
|
|
11610
|
+
}
|
|
11611
|
+
async setupClaudeIfDetected(cwd, mode) {
|
|
10908
11612
|
const result = {
|
|
10909
11613
|
name: "Claude Code",
|
|
10910
11614
|
detected: false,
|
|
10911
11615
|
installed: false,
|
|
10912
11616
|
alreadyInstalled: false
|
|
10913
11617
|
};
|
|
10914
|
-
|
|
10915
|
-
|
|
10916
|
-
|
|
11618
|
+
if (mode === "off") return result;
|
|
11619
|
+
if (mode === "auto") {
|
|
11620
|
+
const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
|
|
11621
|
+
const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
|
|
11622
|
+
if (!hasClaudeDir && !hasClaudeEnv) return result;
|
|
11623
|
+
}
|
|
10917
11624
|
result.detected = true;
|
|
10918
11625
|
const claudePaths = getClaudePaths(cwd);
|
|
10919
11626
|
try {
|
|
10920
|
-
if (await pathExists(claudePaths.settings)) {
|
|
11627
|
+
if (await pathExists$1(claudePaths.settings)) {
|
|
10921
11628
|
const content = await readFile(claudePaths.settings, "utf-8");
|
|
10922
11629
|
const hooks = JSON.parse(content).hooks;
|
|
10923
11630
|
if (hooks) {
|
|
10924
|
-
if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists(claudePaths.skill)) result.alreadyInstalled = true;
|
|
11631
|
+
if (hooks.SessionStart?.some((h) => h.hooks?.some((hook) => (hook.command?.includes("tbd prime") ?? false) || (hook.command?.includes("tbd-session.sh") ?? false))) && await pathExists$1(claudePaths.skill)) result.alreadyInstalled = true;
|
|
10925
11632
|
}
|
|
10926
11633
|
}
|
|
10927
11634
|
const handler = new SetupClaudeHandler(this.cmd);
|
|
@@ -10933,17 +11640,20 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
10933
11640
|
}
|
|
10934
11641
|
return result;
|
|
10935
11642
|
}
|
|
10936
|
-
async setupCodexIfDetected(cwd) {
|
|
11643
|
+
async setupCodexIfDetected(cwd, mode) {
|
|
10937
11644
|
const result = {
|
|
10938
11645
|
name: "Codex/AGENTS.md",
|
|
10939
11646
|
detected: false,
|
|
10940
11647
|
installed: false,
|
|
10941
11648
|
alreadyInstalled: false
|
|
10942
11649
|
};
|
|
11650
|
+
if (mode === "off") return result;
|
|
10943
11651
|
const agentsPath = getAgentsMdPath(cwd);
|
|
10944
|
-
const hasAgentsMd = await pathExists(agentsPath);
|
|
10945
|
-
|
|
10946
|
-
|
|
11652
|
+
const hasAgentsMd = await pathExists$1(agentsPath);
|
|
11653
|
+
if (mode === "auto") {
|
|
11654
|
+
const hasCodexEnv = Object.keys(process.env).some((k) => k.startsWith("CODEX_"));
|
|
11655
|
+
if (!hasAgentsMd && !hasCodexEnv) return result;
|
|
11656
|
+
}
|
|
10947
11657
|
result.detected = true;
|
|
10948
11658
|
if (hasAgentsMd) {
|
|
10949
11659
|
if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) result.alreadyInstalled = true;
|
|
@@ -10954,12 +11664,13 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
10954
11664
|
await handler.run({});
|
|
10955
11665
|
result.installed = true;
|
|
10956
11666
|
} catch (error) {
|
|
11667
|
+
if (error instanceof CLIError) throw error;
|
|
10957
11668
|
result.error = error.message;
|
|
10958
11669
|
}
|
|
10959
11670
|
return result;
|
|
10960
11671
|
}
|
|
10961
11672
|
};
|
|
10962
|
-
const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").action(async (options, command) => {
|
|
11673
|
+
const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--all", "Install every supported agent surface (Claude + Codex)").option("--claude", "Install the Claude Code surface (skill mirror + hooks)").option("--codex", "Install the Codex surface (AGENTS.md block + .codex hooks)").option("--skip-claude", "Skip the Claude Code surface even if detected").option("--skip-codex", "Skip the Codex surface even if detected").action(async (options, command) => {
|
|
10963
11674
|
if (options.auto || options.interactive) {
|
|
10964
11675
|
await new SetupDefaultHandler(command).run(options);
|
|
10965
11676
|
return;
|
|
@@ -11008,7 +11719,6 @@ const setupCommand = new Command("setup").description("Configure tbd integration
|
|
|
11008
11719
|
var SaveHandler = class extends BaseCommand {
|
|
11009
11720
|
async run(options) {
|
|
11010
11721
|
const tbdRoot = await requireInit();
|
|
11011
|
-
const dataSyncDir = await resolveDataSyncDir(tbdRoot);
|
|
11012
11722
|
if (!options.workspace && !options.dir && !options.outbox) throw new ValidationError("One of --workspace, --dir, or --outbox is required");
|
|
11013
11723
|
const saveOptions = {
|
|
11014
11724
|
workspace: options.workspace,
|
|
@@ -11020,7 +11730,9 @@ var SaveHandler = class extends BaseCommand {
|
|
|
11020
11730
|
const spinner = this.output.spinner("Saving issues...");
|
|
11021
11731
|
saveOptions.logger = this.output.logger(spinner);
|
|
11022
11732
|
const result = await this.execute(async () => {
|
|
11023
|
-
return await
|
|
11733
|
+
return await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir }) => {
|
|
11734
|
+
return await saveToWorkspace(tbdRoot, dataSyncDir, saveOptions);
|
|
11735
|
+
});
|
|
11024
11736
|
}, "Failed to save issues");
|
|
11025
11737
|
spinner.stop();
|
|
11026
11738
|
if (!result) return;
|