get-tbd 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +653 -229
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +644 -232
- package/dist/cli.mjs.map +1 -1
- package/dist/{config-BJz1m9eN.mjs → config-1ouUTKQr.mjs} +15 -4
- package/dist/config-1ouUTKQr.mjs.map +1 -0
- package/dist/{config-DlCUMyCG.mjs → config-YRRW9l89.mjs} +1 -1
- package/dist/docs/SKILL.md +8 -1
- package/dist/docs/guidelines/bun-monorepo-patterns.md +65 -66
- package/dist/docs/guidelines/cli-agent-skill-patterns.md +415 -169
- package/dist/docs/guidelines/common-doc-guidelines.md +2 -2
- package/dist/docs/guidelines/convex-limits-best-practices.md +39 -39
- package/dist/docs/guidelines/convex-rules.md +13 -13
- package/dist/docs/guidelines/electron-app-development-patterns.md +18 -18
- package/dist/docs/guidelines/error-handling-rules.md +3 -0
- package/dist/docs/guidelines/general-coding-rules.md +2 -1
- package/dist/docs/guidelines/general-comment-rules.md +3 -2
- package/dist/docs/guidelines/general-eng-agent-principles.md +126 -0
- package/dist/docs/guidelines/general-tdd-guidelines.md +10 -4
- package/dist/docs/guidelines/general-testing-rules.md +4 -0
- package/dist/docs/guidelines/golden-testing-guidelines.md +9 -9
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +49 -49
- package/dist/docs/guidelines/python-cli-patterns.md +5 -1
- package/dist/docs/guidelines/python-modern-guidelines.md +7 -4
- package/dist/docs/guidelines/python-rules.md +6 -0
- package/dist/docs/guidelines/release-notes-guidelines.md +18 -2
- package/dist/docs/guidelines/supply-chain-hardening.md +84 -29
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +27 -5
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +18 -18
- package/dist/docs/guidelines/typescript-code-coverage.md +8 -6
- package/dist/docs/guidelines/typescript-rules.md +9 -11
- package/dist/docs/guidelines/typescript-sorting-patterns.md +1 -1
- package/dist/docs/guidelines/typescript-yaml-handling-rules.md +6 -6
- package/dist/docs/shortcuts/standard/new-shortcut.md +14 -0
- package/dist/docs/shortcuts/standard/setup-github-cli.md +4 -1
- package/dist/docs/shortcuts/system/shortcut-explanation.md +16 -1
- package/dist/docs/shortcuts/system/skill-baseline.md +8 -1
- package/dist/docs/tbd-design.md +43 -43
- package/dist/docs/tbd-docs.md +1 -1
- package/dist/docs/tbd-prime.md +3 -3
- package/dist/index.mjs +1 -1
- package/dist/{src-CtZIHxYM.mjs → src-DTyyuaG_.mjs} +2 -2
- package/dist/{src-CtZIHxYM.mjs.map → src-DTyyuaG_.mjs.map} +1 -1
- package/dist/tbd +653 -229
- package/package.json +1 -1
- package/dist/config-BJz1m9eN.mjs.map +0 -1
- package/dist/docs/guidelines/general-eng-assistant-rules.md +0 -59
package/dist/bin.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path, { basename, dirname, isAbsolute, join, normalize, parse, relative,
|
|
|
6
6
|
import fs, { createWriteStream } from "node:fs";
|
|
7
7
|
import process$1 from "node:process";
|
|
8
8
|
import matter from "gray-matter";
|
|
9
|
-
import os
|
|
9
|
+
import os from "node:os";
|
|
10
10
|
import tty from "node:tty";
|
|
11
11
|
import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
@@ -14104,7 +14104,7 @@ function serializeIssue(issue) {
|
|
|
14104
14104
|
* Package version, derived from git at build time.
|
|
14105
14105
|
* Format: X.Y.Z for releases, X.Y.Z-dev.N.hash for dev builds.
|
|
14106
14106
|
*/
|
|
14107
|
-
const VERSION$1 = "0.2.
|
|
14107
|
+
const VERSION$1 = "0.2.3";
|
|
14108
14108
|
|
|
14109
14109
|
//#endregion
|
|
14110
14110
|
//#region src/cli/lib/version.ts
|
|
@@ -14119,18 +14119,38 @@ const VERSION$1 = "0.2.1";
|
|
|
14119
14119
|
* No git dependency at runtime - git version is computed at build time
|
|
14120
14120
|
* or by the dev script wrapper.
|
|
14121
14121
|
*/
|
|
14122
|
+
/** Read the package.json version (dev fallback, when running from source). */
|
|
14123
|
+
function readPackageVersion() {
|
|
14124
|
+
return createRequire(import.meta.url)("../../../package.json").version;
|
|
14125
|
+
}
|
|
14122
14126
|
/**
|
|
14123
14127
|
* Get the CLI version.
|
|
14124
14128
|
*/
|
|
14125
14129
|
function getVersion() {
|
|
14126
14130
|
if (VERSION$1 !== "development") return VERSION$1;
|
|
14127
14131
|
if (process.env.TBD_DEV_VERSION) return process.env.TBD_DEV_VERSION;
|
|
14128
|
-
return
|
|
14132
|
+
return readPackageVersion();
|
|
14133
|
+
}
|
|
14134
|
+
/**
|
|
14135
|
+
* Clean published npm version for pinned `get-tbd@<version>` fallbacks (the
|
|
14136
|
+
* session script and any other generated install hint). Always the package.json
|
|
14137
|
+
* semver — NOT the git-describe display version, which for a local/dirty build
|
|
14138
|
+
* is an unpublished string like `0.2.3-dev.2.abc1234-dirty` that npm cannot
|
|
14139
|
+
* install and that would churn generated files on every build.
|
|
14140
|
+
*/
|
|
14141
|
+
function getPinnedNpmVersion() {
|
|
14142
|
+
return "0.2.3";
|
|
14129
14143
|
}
|
|
14130
14144
|
/**
|
|
14131
14145
|
* CLI version - use this instead of importing VERSION directly from index.ts
|
|
14132
14146
|
*/
|
|
14133
14147
|
const VERSION = getVersion();
|
|
14148
|
+
/**
|
|
14149
|
+
* Pinned npm version for `get-tbd@<version>` install fallbacks. Use this (not
|
|
14150
|
+
* VERSION) anywhere a version is written into a generated file as an installable
|
|
14151
|
+
* pin, so dev/dirty builds do not stamp unpublished, churn-prone strings.
|
|
14152
|
+
*/
|
|
14153
|
+
const PINNED_NPM_VERSION = getPinnedNpmVersion();
|
|
14134
14154
|
|
|
14135
14155
|
//#endregion
|
|
14136
14156
|
//#region ../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js
|
|
@@ -97643,6 +97663,18 @@ function buildSharedTbdPaths(gitCommonDir) {
|
|
|
97643
97663
|
async function resolveSharedTbdPaths(baseDir) {
|
|
97644
97664
|
return buildSharedTbdPaths(await resolveGitCommonDir(baseDir));
|
|
97645
97665
|
}
|
|
97666
|
+
/**
|
|
97667
|
+
* True when the Git common dir lives outside the project checkout — the linked
|
|
97668
|
+
* worktree shape where the checkout can be writable while `$GIT_COMMON_DIR/tbd`
|
|
97669
|
+
* (shared sync state + lock) is not. This is the generic signal behind the
|
|
97670
|
+
* Codex-sandbox case in #164; it does not depend on any `CODEX_*` env var, which
|
|
97671
|
+
* that sandbox did not expose. Used by both the `doctor` lock-writability finding
|
|
97672
|
+
* and the write-side `SharedLockUnwritableError` so their wording matches.
|
|
97673
|
+
*/
|
|
97674
|
+
function isCommonDirOutsideProject(gitCommonDir, projectRoot) {
|
|
97675
|
+
const rel = relative(resolve(projectRoot), resolve(gitCommonDir));
|
|
97676
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
97677
|
+
}
|
|
97646
97678
|
/** The workspaces directory name within .tbd/ */
|
|
97647
97679
|
const WORKSPACES_DIR_NAME = "workspaces";
|
|
97648
97680
|
/** Full path to workspaces directory: .tbd/workspaces/ */
|
|
@@ -99934,7 +99966,7 @@ const FIELD_STRATEGIES = {
|
|
|
99934
99966
|
priority: "lww",
|
|
99935
99967
|
assignee: "lww",
|
|
99936
99968
|
parent_id: "lww",
|
|
99937
|
-
child_order_hints: "
|
|
99969
|
+
child_order_hints: "union",
|
|
99938
99970
|
updated_at: "max",
|
|
99939
99971
|
closed_at: "lww",
|
|
99940
99972
|
close_reason: "lww",
|
|
@@ -100066,7 +100098,7 @@ function mergeIssues(base, local, remote) {
|
|
|
100066
100098
|
}
|
|
100067
100099
|
break;
|
|
100068
100100
|
case "union":
|
|
100069
|
-
merged[key] = unionArrays(localVal, remoteVal);
|
|
100101
|
+
merged[key] = unionArrays(Array.isArray(localVal) ? localVal : [], Array.isArray(remoteVal) ? remoteVal : []);
|
|
100070
100102
|
break;
|
|
100071
100103
|
case "max":
|
|
100072
100104
|
merged[key] = Math.max(localVal, remoteVal);
|
|
@@ -100135,30 +100167,29 @@ function isNonFastForward(error) {
|
|
|
100135
100167
|
async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
|
|
100136
100168
|
const refspec = `refs/heads/${syncBranch}:refs/heads/${syncBranch}`;
|
|
100137
100169
|
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
100170
|
+
const allConflicts = [];
|
|
100138
100171
|
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) try {
|
|
100139
100172
|
await git(...dirArgs, "push", remote, refspec);
|
|
100140
100173
|
return {
|
|
100141
100174
|
success: true,
|
|
100142
|
-
attempt
|
|
100175
|
+
attempt,
|
|
100176
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
100143
100177
|
};
|
|
100144
100178
|
} catch (error) {
|
|
100145
100179
|
if (!isNonFastForward(error)) return {
|
|
100146
100180
|
success: false,
|
|
100147
100181
|
attempt,
|
|
100148
|
-
error: error instanceof Error ? error.message : String(error)
|
|
100182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100183
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
100149
100184
|
};
|
|
100150
100185
|
if (attempt === MAX_PUSH_RETRIES) return {
|
|
100151
100186
|
success: false,
|
|
100152
100187
|
attempt,
|
|
100153
|
-
error: `Push failed after ${MAX_PUSH_RETRIES} attempts. Remote has conflicting changes
|
|
100188
|
+
error: `Push failed after ${MAX_PUSH_RETRIES} attempts. Remote has conflicting changes.`,
|
|
100189
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
100154
100190
|
};
|
|
100155
100191
|
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
100156
|
-
|
|
100157
|
-
if (conflicts.length > 0) return {
|
|
100158
|
-
success: false,
|
|
100159
|
-
attempt,
|
|
100160
|
-
conflicts
|
|
100161
|
-
};
|
|
100192
|
+
allConflicts.push(...await onMergeNeeded());
|
|
100162
100193
|
}
|
|
100163
100194
|
return {
|
|
100164
100195
|
success: false,
|
|
@@ -100692,6 +100723,52 @@ async function readBranchMapping(baseDir, ref) {
|
|
|
100692
100723
|
};
|
|
100693
100724
|
}
|
|
100694
100725
|
}
|
|
100726
|
+
/**
|
|
100727
|
+
* Three-way merge a single bead read directly from git refs.
|
|
100728
|
+
*
|
|
100729
|
+
* Resolves the common ancestor with `git merge-base` and reads base/ours/theirs
|
|
100730
|
+
* from committed blobs (never the working tree), so a conflict-marker-corrupted
|
|
100731
|
+
* working file is never parsed. Feeds the field-level {@link mergeIssues} engine
|
|
100732
|
+
* a real base, which is what lets `union` fields (e.g. child_order_hints) combine
|
|
100733
|
+
* both sides instead of one side winning.
|
|
100734
|
+
*
|
|
100735
|
+
* Returns `null` when the bead does not exist on the `theirsRef` side (nothing to
|
|
100736
|
+
* merge — keep ours). Used by sync's conflict paths in place of git's line-based
|
|
100737
|
+
* text merge. See issue #155.
|
|
100738
|
+
*
|
|
100739
|
+
* @param repoDir - Directory to run git in (the data-sync worktree). `oursRef` and
|
|
100740
|
+
* `theirsRef` (e.g. `HEAD`/`MERGE_HEAD`, or a branch and `origin/<branch>`) must
|
|
100741
|
+
* resolve there.
|
|
100742
|
+
*/
|
|
100743
|
+
async function mergeBeadAcrossRefs(repoDir, issueId, oursRef, theirsRef) {
|
|
100744
|
+
const path = `${DATA_SYNC_DIR}/issues/${issueId}.md`;
|
|
100745
|
+
const ours = parseIssue(await git("-C", repoDir, "show", `${oursRef}:${path}`));
|
|
100746
|
+
let theirsContent;
|
|
100747
|
+
try {
|
|
100748
|
+
theirsContent = await git("-C", repoDir, "show", `${theirsRef}:${path}`);
|
|
100749
|
+
} catch (err) {
|
|
100750
|
+
if (err instanceof GitError) return null;
|
|
100751
|
+
throw err;
|
|
100752
|
+
}
|
|
100753
|
+
const theirs = parseIssue(theirsContent);
|
|
100754
|
+
let baseSha = "";
|
|
100755
|
+
try {
|
|
100756
|
+
baseSha = (await git("-C", repoDir, "merge-base", oursRef, theirsRef)).trim();
|
|
100757
|
+
} catch (err) {
|
|
100758
|
+
if (exitCodeOf(err) !== 1) throw err;
|
|
100759
|
+
}
|
|
100760
|
+
let base = null;
|
|
100761
|
+
if (baseSha) {
|
|
100762
|
+
let baseContent = null;
|
|
100763
|
+
try {
|
|
100764
|
+
baseContent = await git("-C", repoDir, "show", `${baseSha}:${path}`);
|
|
100765
|
+
} catch (err) {
|
|
100766
|
+
if (!(err instanceof GitError)) throw err;
|
|
100767
|
+
}
|
|
100768
|
+
if (baseContent !== null) base = parseIssue(baseContent);
|
|
100769
|
+
}
|
|
100770
|
+
return mergeIssues(base, ours, theirs);
|
|
100771
|
+
}
|
|
100695
100772
|
/** Preserve a losing issue version explicitly under attic/conflicts/. */
|
|
100696
100773
|
async function preserveLosingVersion(dataSyncPath, loser) {
|
|
100697
100774
|
const conflictsDir = join(dataSyncPath, "attic", "conflicts");
|
|
@@ -100707,13 +100784,20 @@ async function preserveLosingVersion(dataSyncPath, loser) {
|
|
|
100707
100784
|
* backup branch is created, so the pre-rescue HEAD is always recoverable and
|
|
100708
100785
|
* the rescue is restartable.
|
|
100709
100786
|
*
|
|
100710
|
-
* MUST be called while holding `withSharedDataSyncLock`.
|
|
100711
|
-
*
|
|
100787
|
+
* MUST be called while holding `withSharedDataSyncLock`. A merge in progress
|
|
100788
|
+
* aborts the rescue; a merely-dirty worktree does not — its uncommitted
|
|
100789
|
+
* tbd-owned data-sync changes are committed first (so the backup branch captures
|
|
100790
|
+
* them), while any dirty path outside the data-sync tree aborts. See issue #158.
|
|
100712
100791
|
*/
|
|
100713
100792
|
async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
100714
100793
|
const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
|
|
100715
|
-
if (
|
|
100716
|
-
if (await git("-C", worktreePath, "
|
|
100794
|
+
if (await git("-C", worktreePath, "rev-parse", "-q", "--verify", "MERGE_HEAD").then(() => true).catch(() => false)) throw new Error(`Refusing to rescue: a merge is in progress in the tbd-sync worktree at ${worktreePath}. Run \`git -C "${worktreePath}" merge --abort\`, then re-run \`tbd doctor --fix\`.`);
|
|
100795
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) {
|
|
100796
|
+
const foreign = (await git("-C", worktreePath, "status", "--porcelain", "--", ".", `:!${DATA_SYNC_DIR}`)).trim();
|
|
100797
|
+
if (foreign) throw new Error(`Refusing to rescue: the tbd-sync worktree has changes outside the data-sync tree:\n${foreign}\nRemove or commit them, then re-run \`tbd doctor --fix\`.`);
|
|
100798
|
+
await git("-C", worktreePath, "add", "-A");
|
|
100799
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd rescue: snapshot uncommitted data-sync state before rescue");
|
|
100800
|
+
}
|
|
100717
100801
|
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
100718
100802
|
const localIssues = await listIssues(dataSyncPath);
|
|
100719
100803
|
const localMapping = await loadIdMapping(dataSyncPath);
|
|
@@ -100981,12 +101065,59 @@ async function ensureCommonDirLayout(paths, config) {
|
|
|
100981
101065
|
return writeCommonDirLayout(paths, config);
|
|
100982
101066
|
}
|
|
100983
101067
|
/**
|
|
101068
|
+
* Error thrown when the shared data-sync lock cannot be created because the lock
|
|
101069
|
+
* path is not writable by this process (EPERM/EACCES).
|
|
101070
|
+
*
|
|
101071
|
+
* The common trigger is an agent sandbox (e.g. Codex) where the *checkout* is
|
|
101072
|
+
* writable but `$GIT_COMMON_DIR/tbd` — which holds the shared sync worktree and
|
|
101073
|
+
* the lock — lives in the user's original repo directory, outside the sandbox's
|
|
101074
|
+
* writable boundary. Read-only commands work, but any write needs the lock and
|
|
101075
|
+
* fails here. This is fatal for the command: without the lock we cannot safely
|
|
101076
|
+
* mutate shared state. See issue #164.
|
|
101077
|
+
*/
|
|
101078
|
+
var SharedLockUnwritableError = class extends Error {
|
|
101079
|
+
constructor(code, paths, projectRoot) {
|
|
101080
|
+
const outside = isCommonDirOutsideProject(paths.gitCommonDir, projectRoot);
|
|
101081
|
+
const cause = outside ? `The checkout is writable, but the shared tbd state under ${paths.sharedTbdDir} is outside this process's writable area (a common agent-sandbox shape, e.g. Codex worktrees), so write commands cannot proceed.` : `The shared tbd state under ${paths.sharedTbdDir} is not writable by this process, so write commands cannot proceed.`;
|
|
101082
|
+
const fix = outside ? `Fix: grant write access to ${paths.sharedTbdDir} — in an agent sandbox such as Codex add it to the writable roots, or re-run with sandbox escalation.` : `Fix: ensure ${paths.sharedTbdDir} is writable by this user (check filesystem permissions).`;
|
|
101083
|
+
super(`Cannot acquire the shared tbd data-sync lock (${code}): ${paths.sharedLockPath}\n${cause}\n${fix} Run \`tbd doctor\` to confirm the diagnosis.`);
|
|
101084
|
+
this.code = code;
|
|
101085
|
+
this.name = "SharedLockUnwritableError";
|
|
101086
|
+
}
|
|
101087
|
+
};
|
|
101088
|
+
/**
|
|
101089
|
+
* Return the EPERM/EACCES code if `error` is a filesystem permission error,
|
|
101090
|
+
* otherwise undefined. Used to translate raw lock-creation failures into a
|
|
101091
|
+
* clear, actionable error.
|
|
101092
|
+
*/
|
|
101093
|
+
function lockPermissionCode(error) {
|
|
101094
|
+
const code = error?.code;
|
|
101095
|
+
return code === "EPERM" || code === "EACCES" ? code : void 0;
|
|
101096
|
+
}
|
|
101097
|
+
/**
|
|
100984
101098
|
* Run a critical section while holding the repo-scoped data-sync lock.
|
|
101099
|
+
*
|
|
101100
|
+
* A permission failure creating either the locks directory or the lock itself
|
|
101101
|
+
* is rethrown as a `SharedLockUnwritableError` with remediation. The lock-path
|
|
101102
|
+
* match keeps an unrelated EPERM thrown by `fn` (e.g. writing issue data) from
|
|
101103
|
+
* being misreported as a lock-writability problem.
|
|
100985
101104
|
*/
|
|
100986
101105
|
async function withSharedDataSyncLock(tbdRoot, fn) {
|
|
100987
101106
|
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
100988
|
-
|
|
100989
|
-
|
|
101107
|
+
try {
|
|
101108
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
101109
|
+
} catch (error) {
|
|
101110
|
+
const code = lockPermissionCode(error);
|
|
101111
|
+
if (code) throw new SharedLockUnwritableError(code, paths, tbdRoot);
|
|
101112
|
+
throw error;
|
|
101113
|
+
}
|
|
101114
|
+
try {
|
|
101115
|
+
return await withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
|
|
101116
|
+
} catch (error) {
|
|
101117
|
+
const code = lockPermissionCode(error);
|
|
101118
|
+
if (code && error?.path === paths.sharedLockPath) throw new SharedLockUnwritableError(code, paths, tbdRoot);
|
|
101119
|
+
throw error;
|
|
101120
|
+
}
|
|
100990
101121
|
}
|
|
100991
101122
|
|
|
100992
101123
|
//#endregion
|
|
@@ -101354,6 +101485,20 @@ function notifyConfigMigrated(fromFormat, toFormat) {
|
|
|
101354
101485
|
const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
|
|
101355
101486
|
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`);
|
|
101356
101487
|
}
|
|
101488
|
+
/**
|
|
101489
|
+
* Emit a one-line stderr notice when a data command auto-materialized a missing
|
|
101490
|
+
* sync worktree at the point of use.
|
|
101491
|
+
*
|
|
101492
|
+
* The shared worktree can be absent in a fresh/ephemeral clone (or if it was
|
|
101493
|
+
* deleted). `withDataSyncContext` heals it transparently, but a silent heal on a
|
|
101494
|
+
* read/write command looks indistinguishable from "the tracker is empty" or "my
|
|
101495
|
+
* issue was saved normally" — the exact confusion reported in #135. A terse
|
|
101496
|
+
* stderr note keeps stdout (and JSON) clean while making the heal visible.
|
|
101497
|
+
*/
|
|
101498
|
+
function notifyWorktreeRepaired(status) {
|
|
101499
|
+
if (status !== "missing" && status !== "prunable") return;
|
|
101500
|
+
process.stderr.write(`• tbd-sync worktree was ${status} — auto-materialized it (fresh clone, or the worktree was removed).\n`);
|
|
101501
|
+
}
|
|
101357
101502
|
async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
|
|
101358
101503
|
const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
|
|
101359
101504
|
return {
|
|
@@ -101402,7 +101547,10 @@ async function withDataSyncContext(tbdRoot, options, fn) {
|
|
|
101402
101547
|
* migration or worktree repair.
|
|
101403
101548
|
*/
|
|
101404
101549
|
async function loadDataContext(tbdRoot) {
|
|
101405
|
-
return withDataSyncContext(tbdRoot, { lock: false }, async (context) =>
|
|
101550
|
+
return withDataSyncContext(tbdRoot, { lock: false }, async (context) => {
|
|
101551
|
+
notifyWorktreeRepaired(context.repairedWorktreeStatus);
|
|
101552
|
+
return context;
|
|
101553
|
+
});
|
|
101406
101554
|
}
|
|
101407
101555
|
/**
|
|
101408
101556
|
* Load unified command context with CLI options, data, and helper methods.
|
|
@@ -101492,7 +101640,8 @@ var CreateHandler = class extends BaseCommand {
|
|
|
101492
101640
|
let prefix;
|
|
101493
101641
|
let issue;
|
|
101494
101642
|
await this.execute(async () => {
|
|
101495
|
-
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
|
|
101643
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping, repairedWorktreeStatus }) => {
|
|
101644
|
+
notifyWorktreeRepaired(repairedWorktreeStatus);
|
|
101496
101645
|
prefix = config.display.id_prefix;
|
|
101497
101646
|
shortId = generateUniqueShortId(mapping);
|
|
101498
101647
|
addIdMapping(mapping, ulid, shortId);
|
|
@@ -104073,6 +104222,19 @@ async function workspaceExists(tbdRoot, name) {
|
|
|
104073
104222
|
*
|
|
104074
104223
|
* See: tbd-design.md §4.7 Sync Commands
|
|
104075
104224
|
*/
|
|
104225
|
+
/**
|
|
104226
|
+
* List bead files in a data-sync directory that fail to parse. Used as a
|
|
104227
|
+
* post-merge guard so sync never reports success over a corrupted store (e.g. a
|
|
104228
|
+
* bead left holding git conflict markers). See issue #155.
|
|
104229
|
+
*/
|
|
104230
|
+
async function findInvalidBeads(dataSyncDir) {
|
|
104231
|
+
const invalid = [];
|
|
104232
|
+
await listIssues(dataSyncDir, {
|
|
104233
|
+
warnOnInvalid: false,
|
|
104234
|
+
onInvalidIssue: (entry) => invalid.push(entry)
|
|
104235
|
+
});
|
|
104236
|
+
return invalid;
|
|
104237
|
+
}
|
|
104076
104238
|
var SyncHandler = class extends BaseCommand {
|
|
104077
104239
|
dataSyncDir = "";
|
|
104078
104240
|
tbdRoot = "";
|
|
@@ -104218,7 +104380,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104218
104380
|
this.output.debug("Remote branch does not exist");
|
|
104219
104381
|
}
|
|
104220
104382
|
if (behind > 0) {
|
|
104221
|
-
const logOutput = await git("log", "--oneline", `${syncBranch}..${remote}/${syncBranch}`, "--
|
|
104383
|
+
const logOutput = await git("log", "--oneline", `${syncBranch}..${remote}/${syncBranch}`, "--max-count=10");
|
|
104222
104384
|
for (const line of logOutput.split("\n")) if (line) remoteChanges.push(line);
|
|
104223
104385
|
}
|
|
104224
104386
|
} catch {
|
|
@@ -104282,6 +104444,15 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104282
104444
|
throw error;
|
|
104283
104445
|
}
|
|
104284
104446
|
}
|
|
104447
|
+
/**
|
|
104448
|
+
* Abort the sync if any bead file fails to parse, naming the offending
|
|
104449
|
+
* file(s). Prevents the silent "received N updated" success over a store left
|
|
104450
|
+
* corrupted by a merge. See issue #155.
|
|
104451
|
+
*/
|
|
104452
|
+
async assertNoCorruptBeads() {
|
|
104453
|
+
const invalid = await findInvalidBeads(this.dataSyncDir);
|
|
104454
|
+
if (invalid.length > 0) throw new SyncError(`Sync left ${invalid.length} unreadable bead file(s):\n` + invalid.map((i) => ` - ${i.file}: ${i.reason}`).join("\n") + `\n\nThis is a bug in tbd sync. Please report it and resolve the file(s) in:\n ${this.worktreePath}`);
|
|
104455
|
+
}
|
|
104285
104456
|
async pushChanges(syncBranch, remote) {
|
|
104286
104457
|
const spinner = this.output.spinner("Pushing to remote...");
|
|
104287
104458
|
try {
|
|
@@ -104311,8 +104482,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104311
104482
|
}
|
|
104312
104483
|
const result = await this.doPushWithRetry(syncBranch, remote);
|
|
104313
104484
|
spinner.stop();
|
|
104314
|
-
if (result.success) this.output.success(`Pushed
|
|
104315
|
-
else
|
|
104485
|
+
if (result.success) if (result.conflicts && result.conflicts.length > 0) this.output.success(`Pushed to ${remote}/${syncBranch} (${result.conflicts.length} conflict(s) preserved in attic)`);
|
|
104486
|
+
else this.output.success(`Pushed ${ahead} commit(s) to ${remote}/${syncBranch}`);
|
|
104316
104487
|
else throw new SyncError(`Failed to push: ${result.error}`);
|
|
104317
104488
|
} catch (error) {
|
|
104318
104489
|
spinner.stop();
|
|
@@ -104320,20 +104491,104 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104320
104491
|
throw new SyncError(`Failed to push: ${error.message}`);
|
|
104321
104492
|
}
|
|
104322
104493
|
}
|
|
104323
|
-
|
|
104324
|
-
|
|
104325
|
-
|
|
104326
|
-
|
|
104327
|
-
|
|
104328
|
-
|
|
104329
|
-
|
|
104494
|
+
/**
|
|
104495
|
+
* Integrate the remote sync branch into the local worktree with a real git
|
|
104496
|
+
* merge, resolving bead conflicts through the structured field-level engine
|
|
104497
|
+
* (never git's line-based text merge), reconciling ID mappings, committing the
|
|
104498
|
+
* result, and asserting no bead was left corrupt.
|
|
104499
|
+
*
|
|
104500
|
+
* Shared by the pull/full-sync path and the push-retry path so both integrate
|
|
104501
|
+
* the remote identically — and so a rejected push actually advances local
|
|
104502
|
+
* `tbd-sync` to include the remote before retrying, instead of looping on the
|
|
104503
|
+
* same non-fast-forward commit. See issue #155.
|
|
104504
|
+
*
|
|
104505
|
+
* @returns field-level conflict entries (the caller preserves them in attic).
|
|
104506
|
+
*/
|
|
104507
|
+
async mergeRemoteIntoSyncBranch(syncBranch, remote) {
|
|
104508
|
+
const worktreePath = this.worktreePath;
|
|
104509
|
+
const conflicts = [];
|
|
104510
|
+
let headBeforeMerge = "";
|
|
104511
|
+
try {
|
|
104512
|
+
headBeforeMerge = (await git("-C", worktreePath, "rev-parse", "HEAD")).trim();
|
|
104513
|
+
} catch {}
|
|
104514
|
+
try {
|
|
104515
|
+
await git("-C", worktreePath, "merge", `${remote}/${syncBranch}`, "-m", "tbd sync: merge remote changes");
|
|
104516
|
+
this.output.debug("Merged remote changes");
|
|
104517
|
+
if (headBeforeMerge) await this.showGitLogDebug("Commits received", `${headBeforeMerge}..${syncBranch}`);
|
|
104518
|
+
const postMergeIssues = await listIssues(this.dataSyncDir);
|
|
104519
|
+
const postMergeMapping = await loadIdMapping(this.dataSyncDir);
|
|
104520
|
+
let historicalMapping;
|
|
104521
|
+
try {
|
|
104522
|
+
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
104523
|
+
if (remoteIdsContent) historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
104524
|
+
} catch {}
|
|
104525
|
+
const reconcileResult = reconcileMappings(postMergeIssues.map((i) => i.id), postMergeMapping, historicalMapping);
|
|
104526
|
+
const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
|
|
104527
|
+
if (totalReconciled > 0) {
|
|
104528
|
+
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
104529
|
+
await git("-C", worktreePath, "add", "-A");
|
|
104530
|
+
try {
|
|
104531
|
+
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
104532
|
+
} catch {}
|
|
104533
|
+
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
104534
|
+
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
104535
|
+
}
|
|
104536
|
+
} catch {
|
|
104537
|
+
this.output.info(`Merge conflict, attempting file-level resolution`);
|
|
104538
|
+
const conflictedIds = (await git("-C", worktreePath, "diff", "--name-only", "--diff-filter=U", "--", `${DATA_SYNC_DIR}/issues`)).split("\n").map((f) => f.trim()).filter((f) => f.endsWith(".md")).map((f) => basename(f, ".md"));
|
|
104539
|
+
for (const id of conflictedIds) {
|
|
104540
|
+
let result;
|
|
104541
|
+
try {
|
|
104542
|
+
result = await mergeBeadAcrossRefs(worktreePath, id, "HEAD", "MERGE_HEAD");
|
|
104543
|
+
} catch (error) {
|
|
104544
|
+
throw new SyncError(`Failed to merge bead ${id} during sync: ${error.message}`);
|
|
104545
|
+
}
|
|
104546
|
+
if (result) {
|
|
104330
104547
|
await writeIssue(this.dataSyncDir, result.merged);
|
|
104331
104548
|
conflicts.push(...result.conflicts);
|
|
104332
104549
|
}
|
|
104550
|
+
}
|
|
104551
|
+
let conflictRemoteMapping;
|
|
104552
|
+
try {
|
|
104553
|
+
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
104554
|
+
if (remoteIdsContent) {
|
|
104555
|
+
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
104556
|
+
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
104557
|
+
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
104558
|
+
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
104559
|
+
this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
|
|
104560
|
+
}
|
|
104561
|
+
} catch (error) {
|
|
104562
|
+
this.output.debug(`Could not merge ids.yml: ${error.message}`);
|
|
104563
|
+
}
|
|
104564
|
+
{
|
|
104565
|
+
const allIssues = await listIssues(this.dataSyncDir);
|
|
104566
|
+
const currentMapping = await loadIdMapping(this.dataSyncDir);
|
|
104567
|
+
const reconcileResult = reconcileMappings(allIssues.map((i) => i.id), currentMapping, conflictRemoteMapping);
|
|
104568
|
+
if (reconcileResult.created.length + reconcileResult.recovered.length > 0) {
|
|
104569
|
+
await saveIdMapping(this.dataSyncDir, currentMapping);
|
|
104570
|
+
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`);
|
|
104571
|
+
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`);
|
|
104572
|
+
}
|
|
104573
|
+
}
|
|
104574
|
+
await git("-C", worktreePath, "add", "-A");
|
|
104575
|
+
const conflictCheck = await git("-C", worktreePath, "diff", "--cached", "-S<<<<<<< ", "--name-only");
|
|
104576
|
+
if (conflictCheck.trim()) {
|
|
104577
|
+
const conflictedFiles = conflictCheck.trim().split("\n");
|
|
104578
|
+
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}`);
|
|
104579
|
+
}
|
|
104580
|
+
try {
|
|
104581
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
104333
104582
|
} catch {
|
|
104334
|
-
this.output.debug(
|
|
104583
|
+
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
104335
104584
|
}
|
|
104336
|
-
|
|
104585
|
+
}
|
|
104586
|
+
await this.assertNoCorruptBeads();
|
|
104587
|
+
return conflicts;
|
|
104588
|
+
}
|
|
104589
|
+
async doPushWithRetry(syncBranch, remote) {
|
|
104590
|
+
return pushWithRetry(syncBranch, remote, async () => {
|
|
104591
|
+
return this.mergeRemoteIntoSyncBranch(syncBranch, remote);
|
|
104337
104592
|
}, this.tbdRoot);
|
|
104338
104593
|
}
|
|
104339
104594
|
/**
|
|
@@ -104398,79 +104653,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104398
104653
|
}
|
|
104399
104654
|
}
|
|
104400
104655
|
if (behindCommits > 0) {
|
|
104401
|
-
|
|
104402
|
-
|
|
104403
|
-
headBeforeMerge = (await git("-C", worktreePath, "rev-parse", "HEAD")).trim();
|
|
104404
|
-
} catch {}
|
|
104405
|
-
try {
|
|
104406
|
-
await git("-C", worktreePath, "merge", `${remote}/${syncBranch}`, "-m", "tbd sync: merge remote changes");
|
|
104407
|
-
this.output.debug(`Merged ${behindCommits} commit(s) from remote`);
|
|
104408
|
-
if (headBeforeMerge) await this.showGitLogDebug("Commits received", `${headBeforeMerge}..${syncBranch}`);
|
|
104409
|
-
const postMergeIssues = await listIssues(this.dataSyncDir);
|
|
104410
|
-
const postMergeMapping = await loadIdMapping(this.dataSyncDir);
|
|
104411
|
-
let historicalMapping;
|
|
104412
|
-
try {
|
|
104413
|
-
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
104414
|
-
if (remoteIdsContent) historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
104415
|
-
} catch {}
|
|
104416
|
-
const reconcileResult = reconcileMappings(postMergeIssues.map((i) => i.id), postMergeMapping, historicalMapping);
|
|
104417
|
-
const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
|
|
104418
|
-
if (totalReconciled > 0) {
|
|
104419
|
-
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
104420
|
-
await git("-C", worktreePath, "add", "-A");
|
|
104421
|
-
try {
|
|
104422
|
-
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
104423
|
-
} catch {}
|
|
104424
|
-
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
104425
|
-
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
104426
|
-
}
|
|
104427
|
-
} catch {
|
|
104428
|
-
this.output.info(`Merge conflict, attempting file-level resolution`);
|
|
104429
|
-
const localIssues = await listIssues(this.dataSyncDir);
|
|
104430
|
-
for (const localIssue of localIssues) try {
|
|
104431
|
-
if (await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/issues/${localIssue.id}.md`)) {
|
|
104432
|
-
const result = mergeIssues(null, localIssue, await readIssue(this.dataSyncDir, localIssue.id));
|
|
104433
|
-
await writeIssue(this.dataSyncDir, result.merged);
|
|
104434
|
-
conflicts.push(...result.conflicts);
|
|
104435
|
-
}
|
|
104436
|
-
} catch {
|
|
104437
|
-
this.output.debug(`Issue ${localIssue.id} not on remote, keeping local`);
|
|
104438
|
-
}
|
|
104439
|
-
let conflictRemoteMapping;
|
|
104440
|
-
try {
|
|
104441
|
-
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
104442
|
-
if (remoteIdsContent) {
|
|
104443
|
-
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
104444
|
-
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
104445
|
-
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
104446
|
-
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
104447
|
-
this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
|
|
104448
|
-
}
|
|
104449
|
-
} catch (error) {
|
|
104450
|
-
this.output.debug(`Could not merge ids.yml: ${error.message}`);
|
|
104451
|
-
}
|
|
104452
|
-
{
|
|
104453
|
-
const allIssues = await listIssues(this.dataSyncDir);
|
|
104454
|
-
const currentMapping = await loadIdMapping(this.dataSyncDir);
|
|
104455
|
-
const reconcileResult = reconcileMappings(allIssues.map((i) => i.id), currentMapping, conflictRemoteMapping);
|
|
104456
|
-
if (reconcileResult.created.length + reconcileResult.recovered.length > 0) {
|
|
104457
|
-
await saveIdMapping(this.dataSyncDir, currentMapping);
|
|
104458
|
-
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`);
|
|
104459
|
-
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`);
|
|
104460
|
-
}
|
|
104461
|
-
}
|
|
104462
|
-
await git("-C", worktreePath, "add", "-A");
|
|
104463
|
-
const conflictCheck = await git("-C", worktreePath, "diff", "--cached", "-S<<<<<<< ", "--name-only");
|
|
104464
|
-
if (conflictCheck.trim()) {
|
|
104465
|
-
const conflictedFiles = conflictCheck.trim().split("\n");
|
|
104466
|
-
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}`);
|
|
104467
|
-
}
|
|
104468
|
-
try {
|
|
104469
|
-
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
104470
|
-
} catch {
|
|
104471
|
-
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
104472
|
-
}
|
|
104473
|
-
}
|
|
104656
|
+
const mergeConflicts = await this.mergeRemoteIntoSyncBranch(syncBranch, remote);
|
|
104657
|
+
conflicts.push(...mergeConflicts);
|
|
104474
104658
|
}
|
|
104475
104659
|
} catch (error) {
|
|
104476
104660
|
if (error instanceof SyncError) throw error;
|
|
@@ -104524,7 +104708,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104524
104708
|
this.output.error(`Push failed: ${displayError}`);
|
|
104525
104709
|
console.log(` ${aheadCommits} commit(s) not pushed to remote.`);
|
|
104526
104710
|
});
|
|
104527
|
-
|
|
104711
|
+
let recovered = false;
|
|
104712
|
+
if (errorType === "permanent" && options.autoSave !== false) recovered = await this.handlePermanentFailure();
|
|
104528
104713
|
else if (!this.ctx.json) if (errorType === "transient") {
|
|
104529
104714
|
console.log("");
|
|
104530
104715
|
console.log(" This appears to be a temporary issue. Options:");
|
|
@@ -104537,6 +104722,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104537
104722
|
console.log(" • Run 'tbd sync --status' to check status");
|
|
104538
104723
|
console.log(" • Save for later: tbd save --outbox");
|
|
104539
104724
|
}
|
|
104725
|
+
if (!recovered) process.exitCode = 1;
|
|
104540
104726
|
return;
|
|
104541
104727
|
}
|
|
104542
104728
|
if (options.outbox !== false) await this.maybeImportOutbox(syncBranch, remote);
|
|
@@ -104552,12 +104738,16 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104552
104738
|
/**
|
|
104553
104739
|
* Handle permanent push failure by auto-saving to outbox.
|
|
104554
104740
|
* Called when push fails with a permanent error (e.g., HTTP 403).
|
|
104741
|
+
*
|
|
104742
|
+
* @returns true if the local changes are durably safe (saved to the outbox, or
|
|
104743
|
+
* nothing needed saving); false if the auto-save itself failed. The caller
|
|
104744
|
+
* uses this to decide the process exit code. (#158)
|
|
104555
104745
|
*/
|
|
104556
104746
|
async handlePermanentFailure() {
|
|
104557
104747
|
if ((await listIssues(this.dataSyncDir)).length === 0) {
|
|
104558
104748
|
console.log("");
|
|
104559
104749
|
console.log(" No unsynced issues to save (already in sync with remote).");
|
|
104560
|
-
return;
|
|
104750
|
+
return true;
|
|
104561
104751
|
}
|
|
104562
104752
|
let existingOutboxCount = 0;
|
|
104563
104753
|
if (await workspaceExists(this.tbdRoot, "outbox")) {
|
|
@@ -104596,12 +104786,14 @@ var SyncHandler = class extends BaseCommand {
|
|
|
104596
104786
|
console.log("");
|
|
104597
104787
|
console.log(" WARNING: Do NOT add .tbd/workspaces/ to .gitignore -- that would cause data loss.");
|
|
104598
104788
|
}
|
|
104789
|
+
return true;
|
|
104599
104790
|
} catch (saveError) {
|
|
104600
104791
|
const saveErrorMsg = saveError instanceof Error ? saveError.message : String(saveError);
|
|
104601
104792
|
console.log("");
|
|
104602
104793
|
this.output.error(`Auto-save to outbox also failed: ${saveErrorMsg}`);
|
|
104603
104794
|
console.log("");
|
|
104604
104795
|
console.log(" Run 'tbd save --outbox' manually, or 'tbd doctor' to diagnose.");
|
|
104796
|
+
return false;
|
|
104605
104797
|
}
|
|
104606
104798
|
}
|
|
104607
104799
|
/**
|
|
@@ -105084,12 +105276,6 @@ const CODEX_HOOKS_REL = ".codex/hooks.json";
|
|
|
105084
105276
|
*/
|
|
105085
105277
|
const CODEX_CONFIG_REL = ".codex/config.toml";
|
|
105086
105278
|
/**
|
|
105087
|
-
* Global Claude Code directory in user's home.
|
|
105088
|
-
* Used ONLY for detecting if Claude Code is installed (for agent detection).
|
|
105089
|
-
* All installations are project-local.
|
|
105090
|
-
*/
|
|
105091
|
-
const GLOBAL_CLAUDE_DIR = join(homedir(), ".claude");
|
|
105092
|
-
/**
|
|
105093
105279
|
* Get project-local Claude Code paths.
|
|
105094
105280
|
*
|
|
105095
105281
|
* @param projectRoot - The project root directory (containing .tbd/)
|
|
@@ -105348,7 +105534,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
105348
105534
|
path: data.integrations.claude_code_path
|
|
105349
105535
|
},
|
|
105350
105536
|
{
|
|
105351
|
-
name: "
|
|
105537
|
+
name: "AGENTS.md",
|
|
105352
105538
|
installed: data.integrations.codex,
|
|
105353
105539
|
path: data.integrations.codex_path
|
|
105354
105540
|
},
|
|
@@ -105672,6 +105858,71 @@ function classifyRemoteSyncHealth(health, remote, syncBranch) {
|
|
|
105672
105858
|
message: `${remote}/${syncBranch}`
|
|
105673
105859
|
};
|
|
105674
105860
|
}
|
|
105861
|
+
/**
|
|
105862
|
+
* The "Sync consistency" finding for a branch that is both ahead and behind.
|
|
105863
|
+
*
|
|
105864
|
+
* When the histories are unrelated, the remediation is the rescue
|
|
105865
|
+
* (`tbd doctor --fix`), NOT `tbd sync` — otherwise the unrelated-history error
|
|
105866
|
+
* (which says "run doctor --fix") and this warning (which would say "run sync")
|
|
105867
|
+
* point at each other in a loop with no terminating instruction. See #158.
|
|
105868
|
+
*/
|
|
105869
|
+
function divergenceFinding(ahead, behind, unrelated) {
|
|
105870
|
+
if (unrelated) return {
|
|
105871
|
+
name: "Sync consistency",
|
|
105872
|
+
status: "warn",
|
|
105873
|
+
message: `diverged (${ahead} ahead, ${behind} behind) — unrelated histories`,
|
|
105874
|
+
suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
|
|
105875
|
+
};
|
|
105876
|
+
return {
|
|
105877
|
+
name: "Sync consistency",
|
|
105878
|
+
status: "warn",
|
|
105879
|
+
message: `diverged (${ahead} ahead, ${behind} behind)`,
|
|
105880
|
+
suggestion: "Run: tbd sync to reconcile"
|
|
105881
|
+
};
|
|
105882
|
+
}
|
|
105883
|
+
/**
|
|
105884
|
+
* Build the "Shared lock writability" finding from a probe result.
|
|
105885
|
+
*
|
|
105886
|
+
* `code` is the errno from attempting to create a directory under the shared
|
|
105887
|
+
* locks dir, or undefined on success. EPERM/EACCES is a hard error: every write
|
|
105888
|
+
* command must acquire this lock, so an unwritable lock path breaks all writes
|
|
105889
|
+
* (the #164 Codex-sandbox case) — and a lock tbd needs but cannot take is a
|
|
105890
|
+
* fatal condition, not a soft warning. Any other probe failure is reported as a
|
|
105891
|
+
* warning since it cannot be positively interpreted. Never `fixable`: tbd cannot
|
|
105892
|
+
* widen a sandbox or change filesystem permissions itself.
|
|
105893
|
+
*/
|
|
105894
|
+
function buildLockWritabilityFinding(params) {
|
|
105895
|
+
const { code, sharedLockPath, sharedLocksDir, sharedTbdDir, gitCommonDir, projectRoot } = params;
|
|
105896
|
+
if (!code) return {
|
|
105897
|
+
name: "Shared lock writability",
|
|
105898
|
+
status: "ok",
|
|
105899
|
+
path: sharedLocksDir
|
|
105900
|
+
};
|
|
105901
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
105902
|
+
const outsideProject = isCommonDirOutsideProject(gitCommonDir, projectRoot);
|
|
105903
|
+
const details = [
|
|
105904
|
+
`Cannot create the shared data-sync lock (${code}): ${sharedLockPath}`,
|
|
105905
|
+
"Read-only commands work, but every write command (create, update, sync) needs",
|
|
105906
|
+
"this lock, so they will fail until the lock path is writable."
|
|
105907
|
+
];
|
|
105908
|
+
if (outsideProject) details.push(`The checkout (${projectRoot}) is writable, but the shared tbd state under`, `${sharedTbdDir} lives in the Git common dir (${gitCommonDir}) outside it —`, "a common situation in agent sandboxes such as Codex worktrees.");
|
|
105909
|
+
const suggestion = outsideProject ? `Grant write access to ${sharedTbdDir} (in an agent sandbox such as Codex, add it to the writable roots), or re-run the write command with sandbox escalation.` : `Ensure ${sharedTbdDir} is writable by this user (check filesystem permissions).`;
|
|
105910
|
+
return {
|
|
105911
|
+
name: "Shared lock writability",
|
|
105912
|
+
status: "error",
|
|
105913
|
+
message: `lock path not writable (${code})`,
|
|
105914
|
+
path: sharedLockPath,
|
|
105915
|
+
details,
|
|
105916
|
+
suggestion
|
|
105917
|
+
};
|
|
105918
|
+
}
|
|
105919
|
+
return {
|
|
105920
|
+
name: "Shared lock writability",
|
|
105921
|
+
status: "warn",
|
|
105922
|
+
message: `unable to verify (${code})`,
|
|
105923
|
+
path: sharedLocksDir
|
|
105924
|
+
};
|
|
105925
|
+
}
|
|
105675
105926
|
const CONFIG_DIR = TBD_DIR;
|
|
105676
105927
|
var DoctorHandler = class extends BaseCommand {
|
|
105677
105928
|
dataSyncDir = "";
|
|
@@ -105696,18 +105947,19 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
105696
105947
|
const statusInfo = await this.gatherStatusInfo();
|
|
105697
105948
|
const statsInfo = await this.gatherStatsInfo();
|
|
105698
105949
|
const healthChecks = [];
|
|
105699
|
-
healthChecks.push(await this.checkGitVersion());
|
|
105700
|
-
healthChecks.push(await this.checkConfig());
|
|
105701
|
-
healthChecks.push(await this.checkIssuesDirectory());
|
|
105702
|
-
healthChecks.push(this.checkOrphanedDependencies(this.issues));
|
|
105703
|
-
healthChecks.push(this.checkDuplicateIds(this.issues));
|
|
105704
|
-
healthChecks.push(await this.checkIdMappingConflicts(options.fix));
|
|
105705
|
-
healthChecks.push(await this.checkIdMappingDuplicates(options.fix));
|
|
105706
|
-
healthChecks.push(await this.checkTempFiles(options.fix));
|
|
105707
|
-
healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
|
|
105708
|
-
healthChecks.push(await this.checkWorktree(options.fix));
|
|
105709
|
-
healthChecks.push(await this.checkCommonDirLayout(options.fix));
|
|
105710
|
-
|
|
105950
|
+
healthChecks.push(await this.safeCheck("Git version", () => this.checkGitVersion()));
|
|
105951
|
+
healthChecks.push(await this.safeCheck("Config file", () => this.checkConfig()));
|
|
105952
|
+
healthChecks.push(await this.safeCheck("Issues directory", () => this.checkIssuesDirectory()));
|
|
105953
|
+
healthChecks.push(await this.safeCheck("Dependencies", async () => this.checkOrphanedDependencies(this.issues)));
|
|
105954
|
+
healthChecks.push(await this.safeCheck("Unique IDs", async () => this.checkDuplicateIds(this.issues)));
|
|
105955
|
+
healthChecks.push(await this.safeCheck("ID mapping conflicts", () => this.checkIdMappingConflicts(options.fix)));
|
|
105956
|
+
healthChecks.push(await this.safeCheck("ID mapping keys", () => this.checkIdMappingDuplicates(options.fix)));
|
|
105957
|
+
healthChecks.push(await this.safeCheck("Temp files", () => this.checkTempFiles(options.fix)));
|
|
105958
|
+
healthChecks.push(await this.safeCheck("Issue validity", async () => this.checkIssueValidity(this.issues, this.invalidIssueFiles)));
|
|
105959
|
+
healthChecks.push(await this.safeCheck("Worktree", () => this.checkWorktree(options.fix)));
|
|
105960
|
+
healthChecks.push(await this.safeCheck("Common-dir layout", () => this.checkCommonDirLayout(options.fix)));
|
|
105961
|
+
healthChecks.push(await this.safeCheck("Shared lock writability", () => this.checkSharedLockWritability()));
|
|
105962
|
+
const dataLocationResult = await this.safeCheck("Data location", () => this.checkDataLocation(options.fix));
|
|
105711
105963
|
healthChecks.push(dataLocationResult);
|
|
105712
105964
|
if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
|
|
105713
105965
|
this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
|
|
@@ -105721,17 +105973,17 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
105721
105973
|
}
|
|
105722
105974
|
const parsedMaxHistory = options.maxHistory ? parseInt(options.maxHistory, 10) : 50;
|
|
105723
105975
|
const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
|
|
105724
|
-
healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
|
|
105725
|
-
healthChecks.push(await this.checkLocalSyncBranch());
|
|
105726
|
-
healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
|
|
105727
|
-
healthChecks.push(await this.checkLocalVsRemoteData());
|
|
105728
|
-
healthChecks.push(await this.checkCloneScenarios());
|
|
105729
|
-
healthChecks.push(await this.checkSyncConsistency());
|
|
105976
|
+
healthChecks.push(await this.safeCheck("ID mapping coverage", () => this.checkMissingMappings(options.fix, maxHistory)));
|
|
105977
|
+
healthChecks.push(await this.safeCheck("Local sync branch", () => this.checkLocalSyncBranch()));
|
|
105978
|
+
healthChecks.push(await this.safeCheck("Remote sync branch", () => this.checkRemoteSyncBranch(options.fix)));
|
|
105979
|
+
healthChecks.push(await this.safeCheck("Sync status", () => this.checkLocalVsRemoteData()));
|
|
105980
|
+
healthChecks.push(await this.safeCheck("Clone status", () => this.checkCloneScenarios()));
|
|
105981
|
+
healthChecks.push(await this.safeCheck("Sync consistency", () => this.checkSyncConsistency()));
|
|
105730
105982
|
const integrationChecks = [];
|
|
105731
|
-
integrationChecks.push(await this.checkPortableSkill());
|
|
105732
|
-
integrationChecks.push(await this.checkClaudeSkill());
|
|
105733
|
-
integrationChecks.push(await this.checkCodexAgents());
|
|
105734
|
-
integrationChecks.push(await this.checkCodexHooks());
|
|
105983
|
+
integrationChecks.push(await this.safeCheck("Portable Agent Skill", () => this.checkPortableSkill()));
|
|
105984
|
+
integrationChecks.push(await this.safeCheck("Claude Code skill", () => this.checkClaudeSkill()));
|
|
105985
|
+
integrationChecks.push(await this.safeCheck("AGENTS.md", () => this.checkCodexAgents()));
|
|
105986
|
+
integrationChecks.push(await this.safeCheck("Codex hooks", () => this.checkCodexHooks()));
|
|
105735
105987
|
const allChecks = [...healthChecks, ...integrationChecks];
|
|
105736
105988
|
const allOk = allChecks.every((c) => c.status === "ok");
|
|
105737
105989
|
const hasErrors = allChecks.some((c) => c.status === "error");
|
|
@@ -106266,12 +106518,12 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
106266
106518
|
try {
|
|
106267
106519
|
await access(agentsPath);
|
|
106268
106520
|
if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) return {
|
|
106269
|
-
name: "
|
|
106521
|
+
name: "AGENTS.md",
|
|
106270
106522
|
status: "ok",
|
|
106271
106523
|
path: AGENTS_MD_REL
|
|
106272
106524
|
};
|
|
106273
106525
|
return {
|
|
106274
|
-
name: "
|
|
106526
|
+
name: "AGENTS.md",
|
|
106275
106527
|
status: "warn",
|
|
106276
106528
|
message: "exists but missing tbd integration",
|
|
106277
106529
|
path: AGENTS_MD_REL,
|
|
@@ -106279,7 +106531,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
106279
106531
|
};
|
|
106280
106532
|
} catch {
|
|
106281
106533
|
return {
|
|
106282
|
-
name: "
|
|
106534
|
+
name: "AGENTS.md",
|
|
106283
106535
|
status: "warn",
|
|
106284
106536
|
message: "not installed",
|
|
106285
106537
|
path: AGENTS_MD_REL,
|
|
@@ -106449,6 +106701,63 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
106449
106701
|
}
|
|
106450
106702
|
}
|
|
106451
106703
|
/**
|
|
106704
|
+
* Probe whether this process can create the shared data-sync lock.
|
|
106705
|
+
*
|
|
106706
|
+
* Read-only diagnostics never take the lock, so a checkout whose
|
|
106707
|
+
* `$GIT_COMMON_DIR/tbd` is outside the writable sandbox (e.g. a Codex
|
|
106708
|
+
* worktree) looks healthy here while every write command fails with EPERM on
|
|
106709
|
+
* the lock mkdir. This probe mirrors `withSharedDataSyncLock`: ensure the
|
|
106710
|
+
* locks dir, then create and remove a uniquely named probe directory inside
|
|
106711
|
+
* it. It is fully self-contained and never throws, so it cannot abort the
|
|
106712
|
+
* doctor run. See issue #164.
|
|
106713
|
+
*/
|
|
106714
|
+
async checkSharedLockWritability() {
|
|
106715
|
+
let paths;
|
|
106716
|
+
try {
|
|
106717
|
+
paths = await resolveSharedTbdPaths(this.cwd);
|
|
106718
|
+
} catch (error) {
|
|
106719
|
+
return {
|
|
106720
|
+
name: "Shared lock writability",
|
|
106721
|
+
status: "warn",
|
|
106722
|
+
message: `unable to resolve shared paths: ${error instanceof Error ? error.message : String(error)}`
|
|
106723
|
+
};
|
|
106724
|
+
}
|
|
106725
|
+
const probeDir = join(paths.sharedLocksDir, `.tbd-doctor-probe-${randomUUID()}.lock`);
|
|
106726
|
+
let code;
|
|
106727
|
+
try {
|
|
106728
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
106729
|
+
await mkdir(probeDir);
|
|
106730
|
+
} catch (error) {
|
|
106731
|
+
code = error?.code ?? "UNKNOWN";
|
|
106732
|
+
} finally {
|
|
106733
|
+
await rmdir(probeDir).catch(() => {});
|
|
106734
|
+
}
|
|
106735
|
+
return buildLockWritabilityFinding({
|
|
106736
|
+
code,
|
|
106737
|
+
sharedLockPath: paths.sharedLockPath,
|
|
106738
|
+
sharedLocksDir: paths.sharedLocksDir,
|
|
106739
|
+
sharedTbdDir: paths.sharedTbdDir,
|
|
106740
|
+
gitCommonDir: paths.gitCommonDir,
|
|
106741
|
+
projectRoot: this.cwd
|
|
106742
|
+
});
|
|
106743
|
+
}
|
|
106744
|
+
/**
|
|
106745
|
+
* Run a single diagnostic check, converting an unexpected throw into an error
|
|
106746
|
+
* finding instead of letting it abort the whole `tbd doctor` run. Doctor must
|
|
106747
|
+
* surface every issue it can find, not just the first failure. (issue #164)
|
|
106748
|
+
*/
|
|
106749
|
+
async safeCheck(name, fn) {
|
|
106750
|
+
try {
|
|
106751
|
+
return await fn();
|
|
106752
|
+
} catch (error) {
|
|
106753
|
+
return {
|
|
106754
|
+
name,
|
|
106755
|
+
status: "error",
|
|
106756
|
+
message: `check could not complete: ${error instanceof Error ? error.message : String(error)}`
|
|
106757
|
+
};
|
|
106758
|
+
}
|
|
106759
|
+
}
|
|
106760
|
+
/**
|
|
106452
106761
|
* Check for issues in wrong location.
|
|
106453
106762
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
|
|
106454
106763
|
*
|
|
@@ -106691,12 +107000,10 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
106691
107000
|
fixable: true,
|
|
106692
107001
|
suggestion: "Run: tbd doctor --fix to synchronize"
|
|
106693
107002
|
};
|
|
106694
|
-
if (consistency.localAhead > 0 && consistency.localBehind > 0)
|
|
106695
|
-
|
|
106696
|
-
|
|
106697
|
-
|
|
106698
|
-
suggestion: "Run: tbd sync to reconcile"
|
|
106699
|
-
};
|
|
107003
|
+
if (consistency.localAhead > 0 && consistency.localBehind > 0) {
|
|
107004
|
+
const { unrelated } = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
107005
|
+
return divergenceFinding(consistency.localAhead, consistency.localBehind, unrelated);
|
|
107006
|
+
}
|
|
106700
107007
|
if (consistency.localAhead > 0) return {
|
|
106701
107008
|
name: "Sync consistency",
|
|
106702
107009
|
status: "ok",
|
|
@@ -108272,6 +108579,32 @@ var DocCache = class {
|
|
|
108272
108579
|
*/
|
|
108273
108580
|
const SHORTCUT_DIRECTORY_BEGIN = "<!-- BEGIN SHORTCUT DIRECTORY -->";
|
|
108274
108581
|
const SHORTCUT_DIRECTORY_END = "<!-- END SHORTCUT DIRECTORY -->";
|
|
108582
|
+
const GUIDELINE_GROUPS = [
|
|
108583
|
+
{
|
|
108584
|
+
heading: "General engineering",
|
|
108585
|
+
note: "Read all of these for any engineering work (writing or reviewing code).",
|
|
108586
|
+
match: (n) => n.startsWith("general-") || n === "error-handling-rules" || n === "backward-compatibility-rules" || n === "commit-conventions" || n.includes("tdd") || n.includes("testing") || n.includes("golden")
|
|
108587
|
+
},
|
|
108588
|
+
{
|
|
108589
|
+
heading: "TypeScript & JS ecosystem",
|
|
108590
|
+
note: "Also load these when working in TypeScript or JavaScript.",
|
|
108591
|
+
match: (n) => n.startsWith("typescript-") || n.endsWith("monorepo-patterns") || n.startsWith("electron-")
|
|
108592
|
+
},
|
|
108593
|
+
{
|
|
108594
|
+
heading: "Python",
|
|
108595
|
+
note: "Also load these when working in Python.",
|
|
108596
|
+
match: (n) => n.startsWith("python-")
|
|
108597
|
+
},
|
|
108598
|
+
{
|
|
108599
|
+
heading: "Convex",
|
|
108600
|
+
note: "Also load these when working with Convex.",
|
|
108601
|
+
match: (n) => n.startsWith("convex-")
|
|
108602
|
+
},
|
|
108603
|
+
{
|
|
108604
|
+
heading: "Docs, process & tooling",
|
|
108605
|
+
match: () => true
|
|
108606
|
+
}
|
|
108607
|
+
];
|
|
108275
108608
|
/**
|
|
108276
108609
|
* Build table rows from docs (shared helper for shortcuts and guidelines).
|
|
108277
108610
|
*/
|
|
@@ -108292,7 +108625,8 @@ function buildTableRows(docs, skipNames = []) {
|
|
|
108292
108625
|
* The output includes:
|
|
108293
108626
|
* 1. Marker comments for incremental updates
|
|
108294
108627
|
* 2. Available Shortcuts section with name and description
|
|
108295
|
-
* 3. Available Guidelines section
|
|
108628
|
+
* 3. Available Guidelines section, grouped by area (General engineering first, then
|
|
108629
|
+
* language/framework groups), with name and description (if provided)
|
|
108296
108630
|
*
|
|
108297
108631
|
* @param shortcuts - Array of shortcut CachedDoc objects
|
|
108298
108632
|
* @param guidelines - Optional array of guideline CachedDoc objects
|
|
@@ -108308,8 +108642,12 @@ function buildTableRows(docs, skipNames = []) {
|
|
|
108308
108642
|
* // | code-review-and-commit | Run pre-commit checks, review changes, and commit code |
|
|
108309
108643
|
* // ...
|
|
108310
108644
|
* // ## Available Guidelines
|
|
108645
|
+
* // ### General engineering
|
|
108311
108646
|
* // | Name | Description |
|
|
108312
108647
|
* // | --- | --- |
|
|
108648
|
+
* // | error-handling-rules | Rules for handling errors... |
|
|
108649
|
+
* // ...
|
|
108650
|
+
* // ### TypeScript & JS ecosystem
|
|
108313
108651
|
* // | typescript-rules | TypeScript coding rules and best practices |
|
|
108314
108652
|
* // ...
|
|
108315
108653
|
* // <!-- END SHORTCUT DIRECTORY -->
|
|
@@ -108333,16 +108671,32 @@ function generateShortcutDirectory(shortcuts, guidelines = []) {
|
|
|
108333
108671
|
lines.push(...shortcutRows);
|
|
108334
108672
|
}
|
|
108335
108673
|
if (guidelines.length > 0) {
|
|
108336
|
-
const
|
|
108337
|
-
|
|
108674
|
+
const grouped = GUIDELINE_GROUPS.map((group) => ({
|
|
108675
|
+
group,
|
|
108676
|
+
docs: []
|
|
108677
|
+
}));
|
|
108678
|
+
const catchAll = grouped[grouped.length - 1];
|
|
108679
|
+
for (const doc of guidelines) (grouped.find((g) => g.group.match(doc.name)) ?? catchAll)?.docs.push(doc);
|
|
108680
|
+
if (grouped.some((g) => g.docs.length > 0)) {
|
|
108338
108681
|
lines.push("");
|
|
108339
108682
|
lines.push("## Available Guidelines");
|
|
108340
108683
|
lines.push("");
|
|
108341
|
-
lines.push("Run `tbd guidelines <name>` to apply any of these guidelines
|
|
108342
|
-
lines.push("");
|
|
108343
|
-
|
|
108344
|
-
|
|
108345
|
-
|
|
108684
|
+
lines.push("Run `tbd guidelines <name>` to apply any of these guidelines.");
|
|
108685
|
+
lines.push("Load the **General engineering** group first, then the language or framework group.");
|
|
108686
|
+
for (const { group, docs } of grouped) {
|
|
108687
|
+
const rows = buildTableRows(docs);
|
|
108688
|
+
if (rows.length === 0) continue;
|
|
108689
|
+
lines.push("");
|
|
108690
|
+
lines.push(`### ${group.heading}`);
|
|
108691
|
+
if (group.note) {
|
|
108692
|
+
lines.push("");
|
|
108693
|
+
lines.push(`*${group.note}*`);
|
|
108694
|
+
}
|
|
108695
|
+
lines.push("");
|
|
108696
|
+
lines.push("| Name | Description |");
|
|
108697
|
+
lines.push("| --- | --- |");
|
|
108698
|
+
lines.push(...rows);
|
|
108699
|
+
}
|
|
108346
108700
|
}
|
|
108347
108701
|
}
|
|
108348
108702
|
lines.push("");
|
|
@@ -109546,12 +109900,12 @@ fi
|
|
|
109546
109900
|
|
|
109547
109901
|
# Pinned zero-install fallback. Never use an unpinned runner here.
|
|
109548
109902
|
if command -v npx &> /dev/null; then
|
|
109549
|
-
npx --yes get-tbd@${
|
|
109903
|
+
npx --yes get-tbd@${PINNED_NPM_VERSION} prime "$@"
|
|
109550
109904
|
exit $?
|
|
109551
109905
|
fi
|
|
109552
109906
|
|
|
109553
109907
|
echo "[tbd] tbd CLI not found and npx is unavailable."
|
|
109554
|
-
echo "[tbd] Install it with: npm install -g get-tbd@${
|
|
109908
|
+
echo "[tbd] Install it with: npm install -g get-tbd@${PINNED_NPM_VERSION}"
|
|
109555
109909
|
exit 1
|
|
109556
109910
|
`;
|
|
109557
109911
|
/**
|
|
@@ -110025,6 +110379,16 @@ var SetupCodexHandler = class extends BaseCommand {
|
|
|
110025
110379
|
await this.installCodexSection(agentsPath);
|
|
110026
110380
|
await this.installCodexHooks(cwd);
|
|
110027
110381
|
}
|
|
110382
|
+
/** Install only the AGENTS.md managed block (the `agents-md` surface). */
|
|
110383
|
+
async runAgentsMdOnly() {
|
|
110384
|
+
const cwd = this.projectDir ?? process.cwd();
|
|
110385
|
+
await this.installCodexSection(join(cwd, "AGENTS.md"));
|
|
110386
|
+
}
|
|
110387
|
+
/** Install only the Codex lifecycle hooks (the `codex` surface). */
|
|
110388
|
+
async runCodexHooksOnly() {
|
|
110389
|
+
const cwd = this.projectDir ?? process.cwd();
|
|
110390
|
+
await this.installCodexHooks(cwd);
|
|
110391
|
+
}
|
|
110028
110392
|
/**
|
|
110029
110393
|
* Read the use_gh_cli setting; defaults to true (so fresh setup installs it).
|
|
110030
110394
|
*/
|
|
@@ -110481,6 +110845,24 @@ Example:
|
|
|
110481
110845
|
}
|
|
110482
110846
|
}
|
|
110483
110847
|
};
|
|
110848
|
+
/**
|
|
110849
|
+
* Agent integration surfaces that `tbd setup` can install, in install/report
|
|
110850
|
+
* order. Each is selectable via `--surfaces=<comma-list>`; with the flag omitted,
|
|
110851
|
+
* all are installed. Adding an agent is one entry here plus a case in
|
|
110852
|
+
* `installSurface` — there is no per-agent flag to add.
|
|
110853
|
+
*/
|
|
110854
|
+
const SETUP_SURFACE_IDS = [
|
|
110855
|
+
"portable",
|
|
110856
|
+
"agents-md",
|
|
110857
|
+
"claude",
|
|
110858
|
+
"codex"
|
|
110859
|
+
];
|
|
110860
|
+
const SURFACE_DISPLAY_NAME = {
|
|
110861
|
+
portable: "Portable Agent Skill",
|
|
110862
|
+
"agents-md": "AGENTS.md",
|
|
110863
|
+
claude: "Claude Code",
|
|
110864
|
+
codex: "Codex hooks"
|
|
110865
|
+
};
|
|
110484
110866
|
var SetupAutoHandler = class extends BaseCommand {
|
|
110485
110867
|
cmd;
|
|
110486
110868
|
constructor(command) {
|
|
@@ -110500,10 +110882,16 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110500
110882
|
const entries = await readdir(scriptsDir, { withFileTypes: true });
|
|
110501
110883
|
for (const entry of entries) if (entry.isFile()) {
|
|
110502
110884
|
const filename = entry.name;
|
|
110503
|
-
if (LEGACY_TBD_SCRIPTS.includes(filename))
|
|
110504
|
-
|
|
110505
|
-
|
|
110506
|
-
|
|
110885
|
+
if (LEGACY_TBD_SCRIPTS.includes(filename)) {
|
|
110886
|
+
if (this.ctx.dryRun) {
|
|
110887
|
+
scriptsRemoved.push(filename);
|
|
110888
|
+
continue;
|
|
110889
|
+
}
|
|
110890
|
+
try {
|
|
110891
|
+
await rm(join(scriptsDir, filename));
|
|
110892
|
+
scriptsRemoved.push(filename);
|
|
110893
|
+
} catch {}
|
|
110894
|
+
}
|
|
110507
110895
|
}
|
|
110508
110896
|
} catch {}
|
|
110509
110897
|
return scriptsRemoved;
|
|
@@ -110547,7 +110935,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110547
110935
|
modified = true;
|
|
110548
110936
|
}
|
|
110549
110937
|
}
|
|
110550
|
-
if (modified) {
|
|
110938
|
+
if (modified && !this.ctx.dryRun) {
|
|
110551
110939
|
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
110552
110940
|
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
110553
110941
|
}
|
|
@@ -110565,18 +110953,22 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110565
110953
|
const parts = [];
|
|
110566
110954
|
if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
|
|
110567
110955
|
if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
|
|
110568
|
-
|
|
110956
|
+
if (this.ctx.dryRun) this.output.dryRun(`Would clean up legacy ${parts.join(" and ")}`);
|
|
110957
|
+
else console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
|
|
110569
110958
|
}
|
|
110570
110959
|
await this.syncDocs(cwd);
|
|
110571
|
-
const
|
|
110572
|
-
|
|
110573
|
-
const
|
|
110574
|
-
|
|
110575
|
-
|
|
110576
|
-
|
|
110960
|
+
const selected = this.resolveSurfaces();
|
|
110961
|
+
const skippedSurfaces = [];
|
|
110962
|
+
for (const id of SETUP_SURFACE_IDS) {
|
|
110963
|
+
if (!selected.has(id)) {
|
|
110964
|
+
skippedSurfaces.push(SURFACE_DISPLAY_NAME[id]);
|
|
110965
|
+
continue;
|
|
110966
|
+
}
|
|
110967
|
+
results.push(await this.installSurface(id, cwd));
|
|
110968
|
+
}
|
|
110577
110969
|
const installed = results.filter((r) => r.installed && !r.alreadyInstalled);
|
|
110578
110970
|
const alreadyInstalled = results.filter((r) => r.alreadyInstalled);
|
|
110579
|
-
const
|
|
110971
|
+
const failed = results.filter((r) => r.error);
|
|
110580
110972
|
if (installed.length > 0) {
|
|
110581
110973
|
console.log(colors.bold("Configured integrations:"));
|
|
110582
110974
|
for (const r of installed) console.log(` ${colors.success("✓")} ${r.name}`);
|
|
@@ -110585,16 +110977,11 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110585
110977
|
console.log(colors.dim("Already configured:"));
|
|
110586
110978
|
for (const r of alreadyInstalled) console.log(` ${colors.dim("✓")} ${r.name}`);
|
|
110587
110979
|
}
|
|
110588
|
-
if (
|
|
110589
|
-
console.log(colors.dim("
|
|
110590
|
-
for (const
|
|
110591
|
-
}
|
|
110592
|
-
if (installed.length === 0 && alreadyInstalled.length === 0) {
|
|
110593
|
-
console.log(colors.dim("No coding agents detected."));
|
|
110594
|
-
console.log("");
|
|
110595
|
-
console.log("Install a coding agent (Claude Code, Codex, or any AGENTS.md-compatible tool) and re-run:");
|
|
110596
|
-
console.log(" tbd setup --auto");
|
|
110980
|
+
if (skippedSurfaces.length > 0) {
|
|
110981
|
+
console.log(colors.dim("Skipped (not in --surfaces):"));
|
|
110982
|
+
for (const name of skippedSurfaces) console.log(` ${colors.dim("-")} ${name}`);
|
|
110597
110983
|
}
|
|
110984
|
+
if (failed.length > 0) for (const r of failed) console.log(colors.warn(` ! ${r.name}: ${r.error}`));
|
|
110598
110985
|
}
|
|
110599
110986
|
/**
|
|
110600
110987
|
* Sync docs using syncDocsWithDefaults.
|
|
@@ -110616,51 +111003,72 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110616
111003
|
if (result.errors.length > 0) for (const { path, error } of result.errors) console.log(colors.warn(`Warning: ${path}: ${error}`));
|
|
110617
111004
|
}
|
|
110618
111005
|
/**
|
|
110619
|
-
*
|
|
110620
|
-
*
|
|
110621
|
-
*
|
|
111006
|
+
* Resolve the set of surfaces to install from `--surfaces=<comma-list>`. With
|
|
111007
|
+
* the flag omitted, every surface is installed. `all` is an alias for the full
|
|
111008
|
+
* set; an unknown surface ID is a hard error.
|
|
110622
111009
|
*/
|
|
110623
|
-
|
|
110624
|
-
const
|
|
110625
|
-
if (
|
|
110626
|
-
const
|
|
110627
|
-
|
|
110628
|
-
|
|
111010
|
+
resolveSurfaces() {
|
|
111011
|
+
const opts = this.cmd.optsWithGlobals();
|
|
111012
|
+
if (opts.surfaces === void 0) return new Set(SETUP_SURFACE_IDS);
|
|
111013
|
+
const valid = new Set(SETUP_SURFACE_IDS);
|
|
111014
|
+
const requested = opts.surfaces.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
111015
|
+
const selected = /* @__PURE__ */ new Set();
|
|
111016
|
+
for (const name of requested) {
|
|
111017
|
+
if (name === "all") {
|
|
111018
|
+
for (const id of SETUP_SURFACE_IDS) selected.add(id);
|
|
111019
|
+
continue;
|
|
111020
|
+
}
|
|
111021
|
+
if (valid.has(name)) {
|
|
111022
|
+
selected.add(name);
|
|
111023
|
+
continue;
|
|
111024
|
+
}
|
|
111025
|
+
throw new CLIError(`Unknown surface "${name}". Valid surfaces: ${SETUP_SURFACE_IDS.join(", ")}, all.`);
|
|
111026
|
+
}
|
|
111027
|
+
return selected;
|
|
111028
|
+
}
|
|
111029
|
+
/** Dispatch the install for a single surface by ID. */
|
|
111030
|
+
async installSurface(id, cwd) {
|
|
111031
|
+
switch (id) {
|
|
111032
|
+
case "portable": return this.installPortableSurface(cwd);
|
|
111033
|
+
case "agents-md": return this.installAgentsMdSurface(cwd);
|
|
111034
|
+
case "claude": return this.installClaudeSurface(cwd);
|
|
111035
|
+
case "codex": return this.installCodexSurface(cwd);
|
|
111036
|
+
default: {
|
|
111037
|
+
const unhandled = id;
|
|
111038
|
+
throw new Error(`Unhandled setup surface: ${String(unhandled)}`);
|
|
111039
|
+
}
|
|
111040
|
+
}
|
|
110629
111041
|
}
|
|
110630
111042
|
/**
|
|
110631
|
-
*
|
|
110632
|
-
*
|
|
110633
|
-
* of untargeted surfaces); `--skip-claude`/`--skip-codex` force them off; with
|
|
110634
|
-
* no targeting flag each surface falls back to detection-based auto behavior.
|
|
111043
|
+
* Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
|
|
111044
|
+
* Read natively by Codex, Gemini CLI, Cursor, and other Agent Skills clients.
|
|
110635
111045
|
*/
|
|
110636
|
-
|
|
110637
|
-
const
|
|
110638
|
-
|
|
110639
|
-
|
|
110640
|
-
|
|
110641
|
-
|
|
110642
|
-
if (on === true || all) return "on";
|
|
110643
|
-
return anyPositive ? "off" : "auto";
|
|
110644
|
-
};
|
|
110645
|
-
return {
|
|
110646
|
-
claude: resolve(opts.claude, opts.skipClaude),
|
|
110647
|
-
codex: resolve(opts.codex, opts.skipCodex)
|
|
111046
|
+
async installPortableSurface(cwd) {
|
|
111047
|
+
const result = {
|
|
111048
|
+
name: SURFACE_DISPLAY_NAME.portable,
|
|
111049
|
+
detected: true,
|
|
111050
|
+
installed: false,
|
|
111051
|
+
alreadyInstalled: false
|
|
110648
111052
|
};
|
|
111053
|
+
if (this.checkDryRun("Would install portable Agent Skill", { path: AGENTS_SKILL_DISPLAY })) return result;
|
|
111054
|
+
try {
|
|
111055
|
+
const { portable } = getAgentSkillPaths(cwd);
|
|
111056
|
+
await writeSkillFile(portable, await buildSkillPayload(this.ctx.quiet));
|
|
111057
|
+
result.installed = true;
|
|
111058
|
+
} catch (error) {
|
|
111059
|
+
if (error instanceof CLIError) throw error;
|
|
111060
|
+
result.error = error.message;
|
|
111061
|
+
}
|
|
111062
|
+
return result;
|
|
110649
111063
|
}
|
|
110650
|
-
|
|
111064
|
+
/** Install the Claude Code surface: skill mirror + .claude/settings.json hooks. */
|
|
111065
|
+
async installClaudeSurface(cwd) {
|
|
110651
111066
|
const result = {
|
|
110652
|
-
name:
|
|
110653
|
-
detected:
|
|
111067
|
+
name: SURFACE_DISPLAY_NAME.claude,
|
|
111068
|
+
detected: true,
|
|
110654
111069
|
installed: false,
|
|
110655
111070
|
alreadyInstalled: false
|
|
110656
111071
|
};
|
|
110657
|
-
if (mode === "off") return result;
|
|
110658
|
-
if (mode === "auto") {
|
|
110659
|
-
const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
|
|
110660
|
-
const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
|
|
110661
|
-
if (!hasClaudeDir && !hasClaudeEnv) return result;
|
|
110662
|
-
}
|
|
110663
|
-
result.detected = true;
|
|
110664
111072
|
const claudePaths = getClaudePaths(cwd);
|
|
110665
111073
|
try {
|
|
110666
111074
|
if (await pathExists$1(claudePaths.settings)) {
|
|
@@ -110675,32 +111083,47 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110675
111083
|
await handler.run({});
|
|
110676
111084
|
result.installed = true;
|
|
110677
111085
|
} catch (error) {
|
|
111086
|
+
if (error instanceof CLIError) throw error;
|
|
110678
111087
|
result.error = error.message;
|
|
110679
111088
|
}
|
|
110680
111089
|
return result;
|
|
110681
111090
|
}
|
|
110682
|
-
|
|
111091
|
+
/** Install the AGENTS.md surface: the tbd managed block only (no Codex hooks). */
|
|
111092
|
+
async installAgentsMdSurface(cwd) {
|
|
110683
111093
|
const result = {
|
|
110684
|
-
name: "
|
|
110685
|
-
detected:
|
|
111094
|
+
name: SURFACE_DISPLAY_NAME["agents-md"],
|
|
111095
|
+
detected: true,
|
|
110686
111096
|
installed: false,
|
|
110687
111097
|
alreadyInstalled: false
|
|
110688
111098
|
};
|
|
110689
|
-
|
|
110690
|
-
|
|
110691
|
-
|
|
110692
|
-
|
|
110693
|
-
|
|
110694
|
-
|
|
110695
|
-
|
|
110696
|
-
|
|
110697
|
-
|
|
110698
|
-
|
|
111099
|
+
try {
|
|
111100
|
+
const agentsPath = getAgentsMdPath(cwd);
|
|
111101
|
+
if (await pathExists$1(agentsPath)) {
|
|
111102
|
+
if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) result.alreadyInstalled = true;
|
|
111103
|
+
}
|
|
111104
|
+
const handler = new SetupCodexHandler(this.cmd);
|
|
111105
|
+
handler.setProjectDir(cwd);
|
|
111106
|
+
await handler.runAgentsMdOnly();
|
|
111107
|
+
result.installed = true;
|
|
111108
|
+
} catch (error) {
|
|
111109
|
+
if (error instanceof CLIError) throw error;
|
|
111110
|
+
result.error = error.message;
|
|
110699
111111
|
}
|
|
111112
|
+
return result;
|
|
111113
|
+
}
|
|
111114
|
+
/** Install the Codex surface: .codex/hooks.json + .codex/ lifecycle scripts. */
|
|
111115
|
+
async installCodexSurface(cwd) {
|
|
111116
|
+
const result = {
|
|
111117
|
+
name: SURFACE_DISPLAY_NAME.codex,
|
|
111118
|
+
detected: true,
|
|
111119
|
+
installed: false,
|
|
111120
|
+
alreadyInstalled: false
|
|
111121
|
+
};
|
|
110700
111122
|
try {
|
|
111123
|
+
if (await pathExists$1(getCodexPaths(cwd).hooks)) result.alreadyInstalled = true;
|
|
110701
111124
|
const handler = new SetupCodexHandler(this.cmd);
|
|
110702
111125
|
handler.setProjectDir(cwd);
|
|
110703
|
-
await handler.
|
|
111126
|
+
await handler.runCodexHooksOnly();
|
|
110704
111127
|
result.installed = true;
|
|
110705
111128
|
} catch (error) {
|
|
110706
111129
|
if (error instanceof CLIError) throw error;
|
|
@@ -110709,7 +111132,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
110709
111132
|
return result;
|
|
110710
111133
|
}
|
|
110711
111134
|
};
|
|
110712
|
-
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("--
|
|
111135
|
+
const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--surfaces <list>", "Comma-separated agent surfaces to install: portable, agents-md, claude, codex (or \"all\"). Default: all").action(async (options, command) => {
|
|
110713
111136
|
if (options.auto || options.interactive) {
|
|
110714
111137
|
await new SetupDefaultHandler(command).run(options);
|
|
110715
111138
|
return;
|
|
@@ -110736,6 +111159,7 @@ const setupCommand = new Command("setup").description("Configure tbd integration
|
|
|
110736
111159
|
console.log(" --prefix <name> Project prefix for issue IDs (2-8 alphabetic recommended)");
|
|
110737
111160
|
console.log(" --force Allow non-recommended prefix format");
|
|
110738
111161
|
console.log(" --no-gh-cli Disable automatic GitHub CLI installation hook");
|
|
111162
|
+
console.log(" --surfaces <list> Agent surfaces to install: portable,agents-md,claude,codex,all (default: all)");
|
|
110739
111163
|
console.log("");
|
|
110740
111164
|
console.log("Examples:");
|
|
110741
111165
|
console.log(" tbd setup --auto --prefix=tbd # Full automatic setup with prefix");
|