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/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { C as IssueSchema, S as IssueKind, T as IssueTitle, c as DATA_SYNC_SCHEMA_VERSION, i as COMMON_DIR_LAYOUT_FIELD_ORDER, n as AtticEntrySchema, o as CommonDirLayoutSchema, t as ATTIC_ENTRY_FIELD_ORDER, w as IssueStatus, y as ISSUE_TITLE_MAX_LENGTH } from "./schemas-f0EcuAVu.mjs";
|
|
2
|
-
import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-
|
|
2
|
+
import { a as insertAfterFrontmatter, c as noopLogger, i as serializeIssue, n as parseIssue, o as parseMarkdown, r as parseMarkdownWithFrontmatter, s as stripFrontmatter, t as VERSION$1 } from "./src-DTyyuaG_.mjs";
|
|
3
3
|
import { a as parseYamlWithConflictDetection, d as PAGINATION_LINE_THRESHOLD, f as PARENT_CONTEXT_MAX_LINES, l as comparisonChain, n as detectDuplicateYamlKeys, o as sortKeys, s as stringifyYaml, u as ordering } from "./yaml-utils-BPy991by.mjs";
|
|
4
|
-
import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as
|
|
4
|
+
import { A as WORKSPACES_DIR, C as SYNC_BRANCH, D as TBD_SHORTCUTS_STANDARD, E as TBD_GUIDELINES_DIR, F as resolveAtticDir, I as resolveDataSyncDir, L as resolveSharedTbdPaths, M as getWorkspaceDir, N as isCommonDirOutsideProject, O as TBD_SHORTCUTS_SYSTEM, P as isValidWorkspaceName, S as LEGACY_WORKTREE_DIR, T as TBD_DOCS_DIR, _ as DATA_SYNC_DIR, a as isInitialized, b as DEFAULT_SHORTCUT_PATHS, c as readConfigWithMigration, d as writeConfig, g as CHARS_PER_TOKEN, h as isCompatibleFormat, i as initConfig, j as WORKTREE_DIR_NAME, k as TBD_TEMPLATES_DIR, l as readLocalState, m as formatUpgradeMessage, n as findTbdRoot, o as markWelcomeSeen, p as CURRENT_FORMAT, r as hasSeenWelcome, s as readConfig, t as IncompatibleFormatError, u as updateLocalState, v as DATA_SYNC_DIR_NAME, w as TBD_DIR, x as DEFAULT_TEMPLATE_PATHS, y as DEFAULT_GUIDELINES_PATHS } from "./config-1ouUTKQr.mjs";
|
|
5
5
|
import { C as withLockfile, S as DATA_SYNC_LOCK_OPTIONS, _ as formatDisplayId, a as hasShortId, b as normalizeIssueId, c as parseIdMappingFromYaml, d as resolveToInternalId, f as saveIdMapping, g as formatDebugId, h as extractUlidFromInternalId, i as generateUniqueShortId, l as reconcileMappings, m as extractShortId, o as loadIdMapping, p as extractPrefix, s as mergeIdMappings, t as addIdMapping, u as resolveIdMappingConflicts, v as generateInternalId, x as validateIssueId, y as makeInternalId } from "./id-mapping-687_UEsy.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError } from "zod";
|
|
@@ -12,11 +12,11 @@ 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, realpath, rename, rm, stat, unlink } from "node:fs/promises";
|
|
15
|
+
import { access, chmod, cp, mkdir, readFile, readdir, realpath, rename, rm, rmdir, 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
|
-
import { homedir } from "node:os";
|
|
19
18
|
import { promisify } from "node:util";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import prettyBytes from "pretty-bytes";
|
|
22
22
|
import prettyMs from "pretty-ms";
|
|
@@ -34,18 +34,38 @@ import GithubSlugger from "github-slugger";
|
|
|
34
34
|
* No git dependency at runtime - git version is computed at build time
|
|
35
35
|
* or by the dev script wrapper.
|
|
36
36
|
*/
|
|
37
|
+
/** Read the package.json version (dev fallback, when running from source). */
|
|
38
|
+
function readPackageVersion() {
|
|
39
|
+
return createRequire(import.meta.url)("../../../package.json").version;
|
|
40
|
+
}
|
|
37
41
|
/**
|
|
38
42
|
* Get the CLI version.
|
|
39
43
|
*/
|
|
40
44
|
function getVersion() {
|
|
41
45
|
if (VERSION$1 !== "development") return VERSION$1;
|
|
42
46
|
if (process.env.TBD_DEV_VERSION) return process.env.TBD_DEV_VERSION;
|
|
43
|
-
return
|
|
47
|
+
return readPackageVersion();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Clean published npm version for pinned `get-tbd@<version>` fallbacks (the
|
|
51
|
+
* session script and any other generated install hint). Always the package.json
|
|
52
|
+
* semver — NOT the git-describe display version, which for a local/dirty build
|
|
53
|
+
* is an unpublished string like `0.2.3-dev.2.abc1234-dirty` that npm cannot
|
|
54
|
+
* install and that would churn generated files on every build.
|
|
55
|
+
*/
|
|
56
|
+
function getPinnedNpmVersion() {
|
|
57
|
+
return "0.2.3";
|
|
44
58
|
}
|
|
45
59
|
/**
|
|
46
60
|
* CLI version - use this instead of importing VERSION directly from index.ts
|
|
47
61
|
*/
|
|
48
62
|
const VERSION = getVersion();
|
|
63
|
+
/**
|
|
64
|
+
* Pinned npm version for `get-tbd@<version>` install fallbacks. Use this (not
|
|
65
|
+
* VERSION) anywhere a version is written into a generated file as an installable
|
|
66
|
+
* pin, so dev/dirty builds do not stamp unpublished, churn-prone strings.
|
|
67
|
+
*/
|
|
68
|
+
const PINNED_NPM_VERSION = getPinnedNpmVersion();
|
|
49
69
|
|
|
50
70
|
//#endregion
|
|
51
71
|
//#region src/cli/lib/context.ts
|
|
@@ -1294,7 +1314,7 @@ const FIELD_STRATEGIES = {
|
|
|
1294
1314
|
priority: "lww",
|
|
1295
1315
|
assignee: "lww",
|
|
1296
1316
|
parent_id: "lww",
|
|
1297
|
-
child_order_hints: "
|
|
1317
|
+
child_order_hints: "union",
|
|
1298
1318
|
updated_at: "max",
|
|
1299
1319
|
closed_at: "lww",
|
|
1300
1320
|
close_reason: "lww",
|
|
@@ -1426,7 +1446,7 @@ function mergeIssues(base, local, remote) {
|
|
|
1426
1446
|
}
|
|
1427
1447
|
break;
|
|
1428
1448
|
case "union":
|
|
1429
|
-
merged[key] = unionArrays(localVal, remoteVal);
|
|
1449
|
+
merged[key] = unionArrays(Array.isArray(localVal) ? localVal : [], Array.isArray(remoteVal) ? remoteVal : []);
|
|
1430
1450
|
break;
|
|
1431
1451
|
case "max":
|
|
1432
1452
|
merged[key] = Math.max(localVal, remoteVal);
|
|
@@ -1495,30 +1515,29 @@ function isNonFastForward(error) {
|
|
|
1495
1515
|
async function pushWithRetry(syncBranch, remote, onMergeNeeded, baseDir) {
|
|
1496
1516
|
const refspec = `refs/heads/${syncBranch}:refs/heads/${syncBranch}`;
|
|
1497
1517
|
const dirArgs = baseDir ? ["-C", baseDir] : [];
|
|
1518
|
+
const allConflicts = [];
|
|
1498
1519
|
for (let attempt = 1; attempt <= MAX_PUSH_RETRIES; attempt++) try {
|
|
1499
1520
|
await git(...dirArgs, "push", remote, refspec);
|
|
1500
1521
|
return {
|
|
1501
1522
|
success: true,
|
|
1502
|
-
attempt
|
|
1523
|
+
attempt,
|
|
1524
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
1503
1525
|
};
|
|
1504
1526
|
} catch (error) {
|
|
1505
1527
|
if (!isNonFastForward(error)) return {
|
|
1506
1528
|
success: false,
|
|
1507
1529
|
attempt,
|
|
1508
|
-
error: error instanceof Error ? error.message : String(error)
|
|
1530
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1531
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
1509
1532
|
};
|
|
1510
1533
|
if (attempt === MAX_PUSH_RETRIES) return {
|
|
1511
1534
|
success: false,
|
|
1512
1535
|
attempt,
|
|
1513
|
-
error: `Push failed after ${MAX_PUSH_RETRIES} attempts. Remote has conflicting changes
|
|
1536
|
+
error: `Push failed after ${MAX_PUSH_RETRIES} attempts. Remote has conflicting changes.`,
|
|
1537
|
+
conflicts: allConflicts.length > 0 ? allConflicts : void 0
|
|
1514
1538
|
};
|
|
1515
1539
|
await git(...dirArgs, "fetch", remote, syncBranch);
|
|
1516
|
-
|
|
1517
|
-
if (conflicts.length > 0) return {
|
|
1518
|
-
success: false,
|
|
1519
|
-
attempt,
|
|
1520
|
-
conflicts
|
|
1521
|
-
};
|
|
1540
|
+
allConflicts.push(...await onMergeNeeded());
|
|
1522
1541
|
}
|
|
1523
1542
|
return {
|
|
1524
1543
|
success: false,
|
|
@@ -2052,6 +2071,52 @@ async function readBranchMapping(baseDir, ref) {
|
|
|
2052
2071
|
};
|
|
2053
2072
|
}
|
|
2054
2073
|
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Three-way merge a single bead read directly from git refs.
|
|
2076
|
+
*
|
|
2077
|
+
* Resolves the common ancestor with `git merge-base` and reads base/ours/theirs
|
|
2078
|
+
* from committed blobs (never the working tree), so a conflict-marker-corrupted
|
|
2079
|
+
* working file is never parsed. Feeds the field-level {@link mergeIssues} engine
|
|
2080
|
+
* a real base, which is what lets `union` fields (e.g. child_order_hints) combine
|
|
2081
|
+
* both sides instead of one side winning.
|
|
2082
|
+
*
|
|
2083
|
+
* Returns `null` when the bead does not exist on the `theirsRef` side (nothing to
|
|
2084
|
+
* merge — keep ours). Used by sync's conflict paths in place of git's line-based
|
|
2085
|
+
* text merge. See issue #155.
|
|
2086
|
+
*
|
|
2087
|
+
* @param repoDir - Directory to run git in (the data-sync worktree). `oursRef` and
|
|
2088
|
+
* `theirsRef` (e.g. `HEAD`/`MERGE_HEAD`, or a branch and `origin/<branch>`) must
|
|
2089
|
+
* resolve there.
|
|
2090
|
+
*/
|
|
2091
|
+
async function mergeBeadAcrossRefs(repoDir, issueId, oursRef, theirsRef) {
|
|
2092
|
+
const path = `${DATA_SYNC_DIR}/issues/${issueId}.md`;
|
|
2093
|
+
const ours = parseIssue(await git("-C", repoDir, "show", `${oursRef}:${path}`));
|
|
2094
|
+
let theirsContent;
|
|
2095
|
+
try {
|
|
2096
|
+
theirsContent = await git("-C", repoDir, "show", `${theirsRef}:${path}`);
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
if (err instanceof GitError) return null;
|
|
2099
|
+
throw err;
|
|
2100
|
+
}
|
|
2101
|
+
const theirs = parseIssue(theirsContent);
|
|
2102
|
+
let baseSha = "";
|
|
2103
|
+
try {
|
|
2104
|
+
baseSha = (await git("-C", repoDir, "merge-base", oursRef, theirsRef)).trim();
|
|
2105
|
+
} catch (err) {
|
|
2106
|
+
if (exitCodeOf(err) !== 1) throw err;
|
|
2107
|
+
}
|
|
2108
|
+
let base = null;
|
|
2109
|
+
if (baseSha) {
|
|
2110
|
+
let baseContent = null;
|
|
2111
|
+
try {
|
|
2112
|
+
baseContent = await git("-C", repoDir, "show", `${baseSha}:${path}`);
|
|
2113
|
+
} catch (err) {
|
|
2114
|
+
if (!(err instanceof GitError)) throw err;
|
|
2115
|
+
}
|
|
2116
|
+
if (baseContent !== null) base = parseIssue(baseContent);
|
|
2117
|
+
}
|
|
2118
|
+
return mergeIssues(base, ours, theirs);
|
|
2119
|
+
}
|
|
2055
2120
|
/** Preserve a losing issue version explicitly under attic/conflicts/. */
|
|
2056
2121
|
async function preserveLosingVersion(dataSyncPath, loser) {
|
|
2057
2122
|
const conflictsDir = join(dataSyncPath, "attic", "conflicts");
|
|
@@ -2067,13 +2132,20 @@ async function preserveLosingVersion(dataSyncPath, loser) {
|
|
|
2067
2132
|
* backup branch is created, so the pre-rescue HEAD is always recoverable and
|
|
2068
2133
|
* the rescue is restartable.
|
|
2069
2134
|
*
|
|
2070
|
-
* MUST be called while holding `withSharedDataSyncLock`.
|
|
2071
|
-
*
|
|
2135
|
+
* MUST be called while holding `withSharedDataSyncLock`. A merge in progress
|
|
2136
|
+
* aborts the rescue; a merely-dirty worktree does not — its uncommitted
|
|
2137
|
+
* tbd-owned data-sync changes are committed first (so the backup branch captures
|
|
2138
|
+
* them), while any dirty path outside the data-sync tree aborts. See issue #158.
|
|
2072
2139
|
*/
|
|
2073
2140
|
async function rescueUnrelatedHistory(baseDir, remote = "origin", syncBranch = SYNC_BRANCH) {
|
|
2074
2141
|
const { sharedWorktreePath: worktreePath, sharedDataSyncDir: dataSyncPath } = await getSharedPaths(baseDir);
|
|
2075
|
-
if (
|
|
2076
|
-
if (await git("-C", worktreePath, "
|
|
2142
|
+
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\`.`);
|
|
2143
|
+
if ((await git("-C", worktreePath, "status", "--porcelain")).trim()) {
|
|
2144
|
+
const foreign = (await git("-C", worktreePath, "status", "--porcelain", "--", ".", `:!${DATA_SYNC_DIR}`)).trim();
|
|
2145
|
+
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\`.`);
|
|
2146
|
+
await git("-C", worktreePath, "add", "-A");
|
|
2147
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd rescue: snapshot uncommitted data-sync state before rescue");
|
|
2148
|
+
}
|
|
2077
2149
|
await gitNoPrompt("-C", baseDir, "fetch", remote, syncBranch);
|
|
2078
2150
|
const localIssues = await listIssues(dataSyncPath);
|
|
2079
2151
|
const localMapping = await loadIdMapping(dataSyncPath);
|
|
@@ -2341,12 +2413,59 @@ async function ensureCommonDirLayout(paths, config) {
|
|
|
2341
2413
|
return writeCommonDirLayout(paths, config);
|
|
2342
2414
|
}
|
|
2343
2415
|
/**
|
|
2416
|
+
* Error thrown when the shared data-sync lock cannot be created because the lock
|
|
2417
|
+
* path is not writable by this process (EPERM/EACCES).
|
|
2418
|
+
*
|
|
2419
|
+
* The common trigger is an agent sandbox (e.g. Codex) where the *checkout* is
|
|
2420
|
+
* writable but `$GIT_COMMON_DIR/tbd` — which holds the shared sync worktree and
|
|
2421
|
+
* the lock — lives in the user's original repo directory, outside the sandbox's
|
|
2422
|
+
* writable boundary. Read-only commands work, but any write needs the lock and
|
|
2423
|
+
* fails here. This is fatal for the command: without the lock we cannot safely
|
|
2424
|
+
* mutate shared state. See issue #164.
|
|
2425
|
+
*/
|
|
2426
|
+
var SharedLockUnwritableError = class extends Error {
|
|
2427
|
+
constructor(code, paths, projectRoot) {
|
|
2428
|
+
const outside = isCommonDirOutsideProject(paths.gitCommonDir, projectRoot);
|
|
2429
|
+
const cause = outside ? `The checkout is writable, but the shared tbd state under ${paths.sharedTbdDir} is outside this process's writable area (a common agent-sandbox shape, e.g. Codex worktrees), so write commands cannot proceed.` : `The shared tbd state under ${paths.sharedTbdDir} is not writable by this process, so write commands cannot proceed.`;
|
|
2430
|
+
const fix = outside ? `Fix: grant write access to ${paths.sharedTbdDir} — in an agent sandbox such as Codex add it to the writable roots, or re-run with sandbox escalation.` : `Fix: ensure ${paths.sharedTbdDir} is writable by this user (check filesystem permissions).`;
|
|
2431
|
+
super(`Cannot acquire the shared tbd data-sync lock (${code}): ${paths.sharedLockPath}\n${cause}\n${fix} Run \`tbd doctor\` to confirm the diagnosis.`);
|
|
2432
|
+
this.code = code;
|
|
2433
|
+
this.name = "SharedLockUnwritableError";
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
/**
|
|
2437
|
+
* Return the EPERM/EACCES code if `error` is a filesystem permission error,
|
|
2438
|
+
* otherwise undefined. Used to translate raw lock-creation failures into a
|
|
2439
|
+
* clear, actionable error.
|
|
2440
|
+
*/
|
|
2441
|
+
function lockPermissionCode(error) {
|
|
2442
|
+
const code = error?.code;
|
|
2443
|
+
return code === "EPERM" || code === "EACCES" ? code : void 0;
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2344
2446
|
* Run a critical section while holding the repo-scoped data-sync lock.
|
|
2447
|
+
*
|
|
2448
|
+
* A permission failure creating either the locks directory or the lock itself
|
|
2449
|
+
* is rethrown as a `SharedLockUnwritableError` with remediation. The lock-path
|
|
2450
|
+
* match keeps an unrelated EPERM thrown by `fn` (e.g. writing issue data) from
|
|
2451
|
+
* being misreported as a lock-writability problem.
|
|
2345
2452
|
*/
|
|
2346
2453
|
async function withSharedDataSyncLock(tbdRoot, fn) {
|
|
2347
2454
|
const paths = await resolveSharedTbdPaths(tbdRoot);
|
|
2348
|
-
|
|
2349
|
-
|
|
2455
|
+
try {
|
|
2456
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
2457
|
+
} catch (error) {
|
|
2458
|
+
const code = lockPermissionCode(error);
|
|
2459
|
+
if (code) throw new SharedLockUnwritableError(code, paths, tbdRoot);
|
|
2460
|
+
throw error;
|
|
2461
|
+
}
|
|
2462
|
+
try {
|
|
2463
|
+
return await withLockfile(paths.sharedLockPath, fn, DATA_SYNC_LOCK_OPTIONS);
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
const code = lockPermissionCode(error);
|
|
2466
|
+
if (code && error?.path === paths.sharedLockPath) throw new SharedLockUnwritableError(code, paths, tbdRoot);
|
|
2467
|
+
throw error;
|
|
2468
|
+
}
|
|
2350
2469
|
}
|
|
2351
2470
|
|
|
2352
2471
|
//#endregion
|
|
@@ -2714,6 +2833,20 @@ function notifyConfigMigrated(fromFormat, toFormat) {
|
|
|
2714
2833
|
const arrow = fromFormat ? `${fromFormat} → ${toFormat}` : `→ ${toFormat}`;
|
|
2715
2834
|
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`);
|
|
2716
2835
|
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Emit a one-line stderr notice when a data command auto-materialized a missing
|
|
2838
|
+
* sync worktree at the point of use.
|
|
2839
|
+
*
|
|
2840
|
+
* The shared worktree can be absent in a fresh/ephemeral clone (or if it was
|
|
2841
|
+
* deleted). `withDataSyncContext` heals it transparently, but a silent heal on a
|
|
2842
|
+
* read/write command looks indistinguishable from "the tracker is empty" or "my
|
|
2843
|
+
* issue was saved normally" — the exact confusion reported in #135. A terse
|
|
2844
|
+
* stderr note keeps stdout (and JSON) clean while making the heal visible.
|
|
2845
|
+
*/
|
|
2846
|
+
function notifyWorktreeRepaired(status) {
|
|
2847
|
+
if (status !== "missing" && status !== "prunable") return;
|
|
2848
|
+
process.stderr.write(`• tbd-sync worktree was ${status} — auto-materialized it (fresh clone, or the worktree was removed).\n`);
|
|
2849
|
+
}
|
|
2717
2850
|
async function assembleDataContext(tbdRoot, probe, repairedWorktreeStatus) {
|
|
2718
2851
|
const dataSyncDir = await resolveDataSyncDir(tbdRoot, { allowFallback: false });
|
|
2719
2852
|
return {
|
|
@@ -2762,7 +2895,10 @@ async function withDataSyncContext(tbdRoot, options, fn) {
|
|
|
2762
2895
|
* migration or worktree repair.
|
|
2763
2896
|
*/
|
|
2764
2897
|
async function loadDataContext(tbdRoot) {
|
|
2765
|
-
return withDataSyncContext(tbdRoot, { lock: false }, async (context) =>
|
|
2898
|
+
return withDataSyncContext(tbdRoot, { lock: false }, async (context) => {
|
|
2899
|
+
notifyWorktreeRepaired(context.repairedWorktreeStatus);
|
|
2900
|
+
return context;
|
|
2901
|
+
});
|
|
2766
2902
|
}
|
|
2767
2903
|
/**
|
|
2768
2904
|
* Load unified command context with CLI options, data, and helper methods.
|
|
@@ -2852,7 +2988,8 @@ var CreateHandler = class extends BaseCommand {
|
|
|
2852
2988
|
let prefix;
|
|
2853
2989
|
let issue;
|
|
2854
2990
|
await this.execute(async () => {
|
|
2855
|
-
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping }) => {
|
|
2991
|
+
await withDataSyncContext(tbdRoot, { lock: true }, async ({ dataSyncDir, config, mapping, repairedWorktreeStatus }) => {
|
|
2992
|
+
notifyWorktreeRepaired(repairedWorktreeStatus);
|
|
2856
2993
|
prefix = config.display.id_prefix;
|
|
2857
2994
|
shortId = generateUniqueShortId(mapping);
|
|
2858
2995
|
addIdMapping(mapping, ulid, shortId);
|
|
@@ -5433,6 +5570,19 @@ async function workspaceExists(tbdRoot, name) {
|
|
|
5433
5570
|
*
|
|
5434
5571
|
* See: tbd-design.md §4.7 Sync Commands
|
|
5435
5572
|
*/
|
|
5573
|
+
/**
|
|
5574
|
+
* List bead files in a data-sync directory that fail to parse. Used as a
|
|
5575
|
+
* post-merge guard so sync never reports success over a corrupted store (e.g. a
|
|
5576
|
+
* bead left holding git conflict markers). See issue #155.
|
|
5577
|
+
*/
|
|
5578
|
+
async function findInvalidBeads(dataSyncDir) {
|
|
5579
|
+
const invalid = [];
|
|
5580
|
+
await listIssues(dataSyncDir, {
|
|
5581
|
+
warnOnInvalid: false,
|
|
5582
|
+
onInvalidIssue: (entry) => invalid.push(entry)
|
|
5583
|
+
});
|
|
5584
|
+
return invalid;
|
|
5585
|
+
}
|
|
5436
5586
|
var SyncHandler = class extends BaseCommand {
|
|
5437
5587
|
dataSyncDir = "";
|
|
5438
5588
|
tbdRoot = "";
|
|
@@ -5578,7 +5728,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5578
5728
|
this.output.debug("Remote branch does not exist");
|
|
5579
5729
|
}
|
|
5580
5730
|
if (behind > 0) {
|
|
5581
|
-
const logOutput = await git("log", "--oneline", `${syncBranch}..${remote}/${syncBranch}`, "--
|
|
5731
|
+
const logOutput = await git("log", "--oneline", `${syncBranch}..${remote}/${syncBranch}`, "--max-count=10");
|
|
5582
5732
|
for (const line of logOutput.split("\n")) if (line) remoteChanges.push(line);
|
|
5583
5733
|
}
|
|
5584
5734
|
} catch {
|
|
@@ -5642,6 +5792,15 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5642
5792
|
throw error;
|
|
5643
5793
|
}
|
|
5644
5794
|
}
|
|
5795
|
+
/**
|
|
5796
|
+
* Abort the sync if any bead file fails to parse, naming the offending
|
|
5797
|
+
* file(s). Prevents the silent "received N updated" success over a store left
|
|
5798
|
+
* corrupted by a merge. See issue #155.
|
|
5799
|
+
*/
|
|
5800
|
+
async assertNoCorruptBeads() {
|
|
5801
|
+
const invalid = await findInvalidBeads(this.dataSyncDir);
|
|
5802
|
+
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}`);
|
|
5803
|
+
}
|
|
5645
5804
|
async pushChanges(syncBranch, remote) {
|
|
5646
5805
|
const spinner = this.output.spinner("Pushing to remote...");
|
|
5647
5806
|
try {
|
|
@@ -5671,8 +5830,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5671
5830
|
}
|
|
5672
5831
|
const result = await this.doPushWithRetry(syncBranch, remote);
|
|
5673
5832
|
spinner.stop();
|
|
5674
|
-
if (result.success) this.output.success(`Pushed
|
|
5675
|
-
else
|
|
5833
|
+
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)`);
|
|
5834
|
+
else this.output.success(`Pushed ${ahead} commit(s) to ${remote}/${syncBranch}`);
|
|
5676
5835
|
else throw new SyncError(`Failed to push: ${result.error}`);
|
|
5677
5836
|
} catch (error) {
|
|
5678
5837
|
spinner.stop();
|
|
@@ -5680,20 +5839,104 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5680
5839
|
throw new SyncError(`Failed to push: ${error.message}`);
|
|
5681
5840
|
}
|
|
5682
5841
|
}
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5842
|
+
/**
|
|
5843
|
+
* Integrate the remote sync branch into the local worktree with a real git
|
|
5844
|
+
* merge, resolving bead conflicts through the structured field-level engine
|
|
5845
|
+
* (never git's line-based text merge), reconciling ID mappings, committing the
|
|
5846
|
+
* result, and asserting no bead was left corrupt.
|
|
5847
|
+
*
|
|
5848
|
+
* Shared by the pull/full-sync path and the push-retry path so both integrate
|
|
5849
|
+
* the remote identically — and so a rejected push actually advances local
|
|
5850
|
+
* `tbd-sync` to include the remote before retrying, instead of looping on the
|
|
5851
|
+
* same non-fast-forward commit. See issue #155.
|
|
5852
|
+
*
|
|
5853
|
+
* @returns field-level conflict entries (the caller preserves them in attic).
|
|
5854
|
+
*/
|
|
5855
|
+
async mergeRemoteIntoSyncBranch(syncBranch, remote) {
|
|
5856
|
+
const worktreePath = this.worktreePath;
|
|
5857
|
+
const conflicts = [];
|
|
5858
|
+
let headBeforeMerge = "";
|
|
5859
|
+
try {
|
|
5860
|
+
headBeforeMerge = (await git("-C", worktreePath, "rev-parse", "HEAD")).trim();
|
|
5861
|
+
} catch {}
|
|
5862
|
+
try {
|
|
5863
|
+
await git("-C", worktreePath, "merge", `${remote}/${syncBranch}`, "-m", "tbd sync: merge remote changes");
|
|
5864
|
+
this.output.debug("Merged remote changes");
|
|
5865
|
+
if (headBeforeMerge) await this.showGitLogDebug("Commits received", `${headBeforeMerge}..${syncBranch}`);
|
|
5866
|
+
const postMergeIssues = await listIssues(this.dataSyncDir);
|
|
5867
|
+
const postMergeMapping = await loadIdMapping(this.dataSyncDir);
|
|
5868
|
+
let historicalMapping;
|
|
5869
|
+
try {
|
|
5870
|
+
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5871
|
+
if (remoteIdsContent) historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5872
|
+
} catch {}
|
|
5873
|
+
const reconcileResult = reconcileMappings(postMergeIssues.map((i) => i.id), postMergeMapping, historicalMapping);
|
|
5874
|
+
const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
|
|
5875
|
+
if (totalReconciled > 0) {
|
|
5876
|
+
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
5877
|
+
await git("-C", worktreePath, "add", "-A");
|
|
5878
|
+
try {
|
|
5879
|
+
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
5880
|
+
} catch {}
|
|
5881
|
+
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
5882
|
+
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
5883
|
+
}
|
|
5884
|
+
} catch {
|
|
5885
|
+
this.output.info(`Merge conflict, attempting file-level resolution`);
|
|
5886
|
+
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"));
|
|
5887
|
+
for (const id of conflictedIds) {
|
|
5888
|
+
let result;
|
|
5889
|
+
try {
|
|
5890
|
+
result = await mergeBeadAcrossRefs(worktreePath, id, "HEAD", "MERGE_HEAD");
|
|
5891
|
+
} catch (error) {
|
|
5892
|
+
throw new SyncError(`Failed to merge bead ${id} during sync: ${error.message}`);
|
|
5893
|
+
}
|
|
5894
|
+
if (result) {
|
|
5690
5895
|
await writeIssue(this.dataSyncDir, result.merged);
|
|
5691
5896
|
conflicts.push(...result.conflicts);
|
|
5692
5897
|
}
|
|
5898
|
+
}
|
|
5899
|
+
let conflictRemoteMapping;
|
|
5900
|
+
try {
|
|
5901
|
+
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5902
|
+
if (remoteIdsContent) {
|
|
5903
|
+
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5904
|
+
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
5905
|
+
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
5906
|
+
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
5907
|
+
this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
|
|
5908
|
+
}
|
|
5909
|
+
} catch (error) {
|
|
5910
|
+
this.output.debug(`Could not merge ids.yml: ${error.message}`);
|
|
5911
|
+
}
|
|
5912
|
+
{
|
|
5913
|
+
const allIssues = await listIssues(this.dataSyncDir);
|
|
5914
|
+
const currentMapping = await loadIdMapping(this.dataSyncDir);
|
|
5915
|
+
const reconcileResult = reconcileMappings(allIssues.map((i) => i.id), currentMapping, conflictRemoteMapping);
|
|
5916
|
+
if (reconcileResult.created.length + reconcileResult.recovered.length > 0) {
|
|
5917
|
+
await saveIdMapping(this.dataSyncDir, currentMapping);
|
|
5918
|
+
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`);
|
|
5919
|
+
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`);
|
|
5920
|
+
}
|
|
5921
|
+
}
|
|
5922
|
+
await git("-C", worktreePath, "add", "-A");
|
|
5923
|
+
const conflictCheck = await git("-C", worktreePath, "diff", "--cached", "-S<<<<<<< ", "--name-only");
|
|
5924
|
+
if (conflictCheck.trim()) {
|
|
5925
|
+
const conflictedFiles = conflictCheck.trim().split("\n");
|
|
5926
|
+
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}`);
|
|
5927
|
+
}
|
|
5928
|
+
try {
|
|
5929
|
+
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
5693
5930
|
} catch {
|
|
5694
|
-
this.output.debug(
|
|
5931
|
+
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
5695
5932
|
}
|
|
5696
|
-
|
|
5933
|
+
}
|
|
5934
|
+
await this.assertNoCorruptBeads();
|
|
5935
|
+
return conflicts;
|
|
5936
|
+
}
|
|
5937
|
+
async doPushWithRetry(syncBranch, remote) {
|
|
5938
|
+
return pushWithRetry(syncBranch, remote, async () => {
|
|
5939
|
+
return this.mergeRemoteIntoSyncBranch(syncBranch, remote);
|
|
5697
5940
|
}, this.tbdRoot);
|
|
5698
5941
|
}
|
|
5699
5942
|
/**
|
|
@@ -5758,79 +6001,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5758
6001
|
}
|
|
5759
6002
|
}
|
|
5760
6003
|
if (behindCommits > 0) {
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
headBeforeMerge = (await git("-C", worktreePath, "rev-parse", "HEAD")).trim();
|
|
5764
|
-
} catch {}
|
|
5765
|
-
try {
|
|
5766
|
-
await git("-C", worktreePath, "merge", `${remote}/${syncBranch}`, "-m", "tbd sync: merge remote changes");
|
|
5767
|
-
this.output.debug(`Merged ${behindCommits} commit(s) from remote`);
|
|
5768
|
-
if (headBeforeMerge) await this.showGitLogDebug("Commits received", `${headBeforeMerge}..${syncBranch}`);
|
|
5769
|
-
const postMergeIssues = await listIssues(this.dataSyncDir);
|
|
5770
|
-
const postMergeMapping = await loadIdMapping(this.dataSyncDir);
|
|
5771
|
-
let historicalMapping;
|
|
5772
|
-
try {
|
|
5773
|
-
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5774
|
-
if (remoteIdsContent) historicalMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5775
|
-
} catch {}
|
|
5776
|
-
const reconcileResult = reconcileMappings(postMergeIssues.map((i) => i.id), postMergeMapping, historicalMapping);
|
|
5777
|
-
const totalReconciled = reconcileResult.created.length + reconcileResult.recovered.length;
|
|
5778
|
-
if (totalReconciled > 0) {
|
|
5779
|
-
await saveIdMapping(this.dataSyncDir, postMergeMapping);
|
|
5780
|
-
await git("-C", worktreePath, "add", "-A");
|
|
5781
|
-
try {
|
|
5782
|
-
await gitCommit(worktreePath, "--no-verify", "-m", `tbd sync: reconcile ${totalReconciled} missing ID mapping(s)`);
|
|
5783
|
-
} catch {}
|
|
5784
|
-
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from history`);
|
|
5785
|
-
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) (no history available)`);
|
|
5786
|
-
}
|
|
5787
|
-
} catch {
|
|
5788
|
-
this.output.info(`Merge conflict, attempting file-level resolution`);
|
|
5789
|
-
const localIssues = await listIssues(this.dataSyncDir);
|
|
5790
|
-
for (const localIssue of localIssues) try {
|
|
5791
|
-
if (await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/issues/${localIssue.id}.md`)) {
|
|
5792
|
-
const result = mergeIssues(null, localIssue, await readIssue(this.dataSyncDir, localIssue.id));
|
|
5793
|
-
await writeIssue(this.dataSyncDir, result.merged);
|
|
5794
|
-
conflicts.push(...result.conflicts);
|
|
5795
|
-
}
|
|
5796
|
-
} catch {
|
|
5797
|
-
this.output.debug(`Issue ${localIssue.id} not on remote, keeping local`);
|
|
5798
|
-
}
|
|
5799
|
-
let conflictRemoteMapping;
|
|
5800
|
-
try {
|
|
5801
|
-
const remoteIdsContent = await git("show", `${remote}/${syncBranch}:${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
5802
|
-
if (remoteIdsContent) {
|
|
5803
|
-
conflictRemoteMapping = parseIdMappingFromYaml(remoteIdsContent);
|
|
5804
|
-
const localMapping = resolveIdMappingConflicts(await readFile(join(this.dataSyncDir, "mappings", "ids.yml"), "utf-8"));
|
|
5805
|
-
const mergedMapping = mergeIdMappings(localMapping, conflictRemoteMapping);
|
|
5806
|
-
await saveIdMapping(this.dataSyncDir, mergedMapping);
|
|
5807
|
-
this.output.debug(`Merged ID mappings: ${localMapping.shortToUlid.size} local + ${conflictRemoteMapping.shortToUlid.size} remote = ${mergedMapping.shortToUlid.size} total`);
|
|
5808
|
-
}
|
|
5809
|
-
} catch (error) {
|
|
5810
|
-
this.output.debug(`Could not merge ids.yml: ${error.message}`);
|
|
5811
|
-
}
|
|
5812
|
-
{
|
|
5813
|
-
const allIssues = await listIssues(this.dataSyncDir);
|
|
5814
|
-
const currentMapping = await loadIdMapping(this.dataSyncDir);
|
|
5815
|
-
const reconcileResult = reconcileMappings(allIssues.map((i) => i.id), currentMapping, conflictRemoteMapping);
|
|
5816
|
-
if (reconcileResult.created.length + reconcileResult.recovered.length > 0) {
|
|
5817
|
-
await saveIdMapping(this.dataSyncDir, currentMapping);
|
|
5818
|
-
if (reconcileResult.recovered.length > 0) this.output.debug(`Recovered ${reconcileResult.recovered.length} ID mapping(s) from remote`);
|
|
5819
|
-
if (reconcileResult.created.length > 0) this.output.debug(`Created ${reconcileResult.created.length} new ID mapping(s) after conflict resolution`);
|
|
5820
|
-
}
|
|
5821
|
-
}
|
|
5822
|
-
await git("-C", worktreePath, "add", "-A");
|
|
5823
|
-
const conflictCheck = await git("-C", worktreePath, "diff", "--cached", "-S<<<<<<< ", "--name-only");
|
|
5824
|
-
if (conflictCheck.trim()) {
|
|
5825
|
-
const conflictedFiles = conflictCheck.trim().split("\n");
|
|
5826
|
-
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}`);
|
|
5827
|
-
}
|
|
5828
|
-
try {
|
|
5829
|
-
await gitCommit(worktreePath, "--no-verify", "-m", "tbd sync: resolved merge conflicts");
|
|
5830
|
-
} catch {
|
|
5831
|
-
this.output.debug("No merge commit needed (conflicts already resolved)");
|
|
5832
|
-
}
|
|
5833
|
-
}
|
|
6004
|
+
const mergeConflicts = await this.mergeRemoteIntoSyncBranch(syncBranch, remote);
|
|
6005
|
+
conflicts.push(...mergeConflicts);
|
|
5834
6006
|
}
|
|
5835
6007
|
} catch (error) {
|
|
5836
6008
|
if (error instanceof SyncError) throw error;
|
|
@@ -5884,7 +6056,8 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5884
6056
|
this.output.error(`Push failed: ${displayError}`);
|
|
5885
6057
|
console.log(` ${aheadCommits} commit(s) not pushed to remote.`);
|
|
5886
6058
|
});
|
|
5887
|
-
|
|
6059
|
+
let recovered = false;
|
|
6060
|
+
if (errorType === "permanent" && options.autoSave !== false) recovered = await this.handlePermanentFailure();
|
|
5888
6061
|
else if (!this.ctx.json) if (errorType === "transient") {
|
|
5889
6062
|
console.log("");
|
|
5890
6063
|
console.log(" This appears to be a temporary issue. Options:");
|
|
@@ -5897,6 +6070,7 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5897
6070
|
console.log(" • Run 'tbd sync --status' to check status");
|
|
5898
6071
|
console.log(" • Save for later: tbd save --outbox");
|
|
5899
6072
|
}
|
|
6073
|
+
if (!recovered) process.exitCode = 1;
|
|
5900
6074
|
return;
|
|
5901
6075
|
}
|
|
5902
6076
|
if (options.outbox !== false) await this.maybeImportOutbox(syncBranch, remote);
|
|
@@ -5912,12 +6086,16 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5912
6086
|
/**
|
|
5913
6087
|
* Handle permanent push failure by auto-saving to outbox.
|
|
5914
6088
|
* Called when push fails with a permanent error (e.g., HTTP 403).
|
|
6089
|
+
*
|
|
6090
|
+
* @returns true if the local changes are durably safe (saved to the outbox, or
|
|
6091
|
+
* nothing needed saving); false if the auto-save itself failed. The caller
|
|
6092
|
+
* uses this to decide the process exit code. (#158)
|
|
5915
6093
|
*/
|
|
5916
6094
|
async handlePermanentFailure() {
|
|
5917
6095
|
if ((await listIssues(this.dataSyncDir)).length === 0) {
|
|
5918
6096
|
console.log("");
|
|
5919
6097
|
console.log(" No unsynced issues to save (already in sync with remote).");
|
|
5920
|
-
return;
|
|
6098
|
+
return true;
|
|
5921
6099
|
}
|
|
5922
6100
|
let existingOutboxCount = 0;
|
|
5923
6101
|
if (await workspaceExists(this.tbdRoot, "outbox")) {
|
|
@@ -5956,12 +6134,14 @@ var SyncHandler = class extends BaseCommand {
|
|
|
5956
6134
|
console.log("");
|
|
5957
6135
|
console.log(" WARNING: Do NOT add .tbd/workspaces/ to .gitignore -- that would cause data loss.");
|
|
5958
6136
|
}
|
|
6137
|
+
return true;
|
|
5959
6138
|
} catch (saveError) {
|
|
5960
6139
|
const saveErrorMsg = saveError instanceof Error ? saveError.message : String(saveError);
|
|
5961
6140
|
console.log("");
|
|
5962
6141
|
this.output.error(`Auto-save to outbox also failed: ${saveErrorMsg}`);
|
|
5963
6142
|
console.log("");
|
|
5964
6143
|
console.log(" Run 'tbd save --outbox' manually, or 'tbd doctor' to diagnose.");
|
|
6144
|
+
return false;
|
|
5965
6145
|
}
|
|
5966
6146
|
}
|
|
5967
6147
|
/**
|
|
@@ -6444,12 +6624,6 @@ const CODEX_HOOKS_REL = ".codex/hooks.json";
|
|
|
6444
6624
|
*/
|
|
6445
6625
|
const CODEX_CONFIG_REL = ".codex/config.toml";
|
|
6446
6626
|
/**
|
|
6447
|
-
* Global Claude Code directory in user's home.
|
|
6448
|
-
* Used ONLY for detecting if Claude Code is installed (for agent detection).
|
|
6449
|
-
* All installations are project-local.
|
|
6450
|
-
*/
|
|
6451
|
-
const GLOBAL_CLAUDE_DIR = join(homedir(), ".claude");
|
|
6452
|
-
/**
|
|
6453
6627
|
* Get project-local Claude Code paths.
|
|
6454
6628
|
*
|
|
6455
6629
|
* @param projectRoot - The project root directory (containing .tbd/)
|
|
@@ -6708,7 +6882,7 @@ var StatusHandler = class extends BaseCommand {
|
|
|
6708
6882
|
path: data.integrations.claude_code_path
|
|
6709
6883
|
},
|
|
6710
6884
|
{
|
|
6711
|
-
name: "
|
|
6885
|
+
name: "AGENTS.md",
|
|
6712
6886
|
installed: data.integrations.codex,
|
|
6713
6887
|
path: data.integrations.codex_path
|
|
6714
6888
|
},
|
|
@@ -7032,6 +7206,71 @@ function classifyRemoteSyncHealth(health, remote, syncBranch) {
|
|
|
7032
7206
|
message: `${remote}/${syncBranch}`
|
|
7033
7207
|
};
|
|
7034
7208
|
}
|
|
7209
|
+
/**
|
|
7210
|
+
* The "Sync consistency" finding for a branch that is both ahead and behind.
|
|
7211
|
+
*
|
|
7212
|
+
* When the histories are unrelated, the remediation is the rescue
|
|
7213
|
+
* (`tbd doctor --fix`), NOT `tbd sync` — otherwise the unrelated-history error
|
|
7214
|
+
* (which says "run doctor --fix") and this warning (which would say "run sync")
|
|
7215
|
+
* point at each other in a loop with no terminating instruction. See #158.
|
|
7216
|
+
*/
|
|
7217
|
+
function divergenceFinding(ahead, behind, unrelated) {
|
|
7218
|
+
if (unrelated) return {
|
|
7219
|
+
name: "Sync consistency",
|
|
7220
|
+
status: "warn",
|
|
7221
|
+
message: `diverged (${ahead} ahead, ${behind} behind) — unrelated histories`,
|
|
7222
|
+
suggestion: "Run: tbd doctor --fix to reconcile the unrelated histories"
|
|
7223
|
+
};
|
|
7224
|
+
return {
|
|
7225
|
+
name: "Sync consistency",
|
|
7226
|
+
status: "warn",
|
|
7227
|
+
message: `diverged (${ahead} ahead, ${behind} behind)`,
|
|
7228
|
+
suggestion: "Run: tbd sync to reconcile"
|
|
7229
|
+
};
|
|
7230
|
+
}
|
|
7231
|
+
/**
|
|
7232
|
+
* Build the "Shared lock writability" finding from a probe result.
|
|
7233
|
+
*
|
|
7234
|
+
* `code` is the errno from attempting to create a directory under the shared
|
|
7235
|
+
* locks dir, or undefined on success. EPERM/EACCES is a hard error: every write
|
|
7236
|
+
* command must acquire this lock, so an unwritable lock path breaks all writes
|
|
7237
|
+
* (the #164 Codex-sandbox case) — and a lock tbd needs but cannot take is a
|
|
7238
|
+
* fatal condition, not a soft warning. Any other probe failure is reported as a
|
|
7239
|
+
* warning since it cannot be positively interpreted. Never `fixable`: tbd cannot
|
|
7240
|
+
* widen a sandbox or change filesystem permissions itself.
|
|
7241
|
+
*/
|
|
7242
|
+
function buildLockWritabilityFinding(params) {
|
|
7243
|
+
const { code, sharedLockPath, sharedLocksDir, sharedTbdDir, gitCommonDir, projectRoot } = params;
|
|
7244
|
+
if (!code) return {
|
|
7245
|
+
name: "Shared lock writability",
|
|
7246
|
+
status: "ok",
|
|
7247
|
+
path: sharedLocksDir
|
|
7248
|
+
};
|
|
7249
|
+
if (code === "EPERM" || code === "EACCES") {
|
|
7250
|
+
const outsideProject = isCommonDirOutsideProject(gitCommonDir, projectRoot);
|
|
7251
|
+
const details = [
|
|
7252
|
+
`Cannot create the shared data-sync lock (${code}): ${sharedLockPath}`,
|
|
7253
|
+
"Read-only commands work, but every write command (create, update, sync) needs",
|
|
7254
|
+
"this lock, so they will fail until the lock path is writable."
|
|
7255
|
+
];
|
|
7256
|
+
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.");
|
|
7257
|
+
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).`;
|
|
7258
|
+
return {
|
|
7259
|
+
name: "Shared lock writability",
|
|
7260
|
+
status: "error",
|
|
7261
|
+
message: `lock path not writable (${code})`,
|
|
7262
|
+
path: sharedLockPath,
|
|
7263
|
+
details,
|
|
7264
|
+
suggestion
|
|
7265
|
+
};
|
|
7266
|
+
}
|
|
7267
|
+
return {
|
|
7268
|
+
name: "Shared lock writability",
|
|
7269
|
+
status: "warn",
|
|
7270
|
+
message: `unable to verify (${code})`,
|
|
7271
|
+
path: sharedLocksDir
|
|
7272
|
+
};
|
|
7273
|
+
}
|
|
7035
7274
|
const CONFIG_DIR = TBD_DIR;
|
|
7036
7275
|
var DoctorHandler = class extends BaseCommand {
|
|
7037
7276
|
dataSyncDir = "";
|
|
@@ -7056,18 +7295,19 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7056
7295
|
const statusInfo = await this.gatherStatusInfo();
|
|
7057
7296
|
const statsInfo = await this.gatherStatsInfo();
|
|
7058
7297
|
const healthChecks = [];
|
|
7059
|
-
healthChecks.push(await this.checkGitVersion());
|
|
7060
|
-
healthChecks.push(await this.checkConfig());
|
|
7061
|
-
healthChecks.push(await this.checkIssuesDirectory());
|
|
7062
|
-
healthChecks.push(this.checkOrphanedDependencies(this.issues));
|
|
7063
|
-
healthChecks.push(this.checkDuplicateIds(this.issues));
|
|
7064
|
-
healthChecks.push(await this.checkIdMappingConflicts(options.fix));
|
|
7065
|
-
healthChecks.push(await this.checkIdMappingDuplicates(options.fix));
|
|
7066
|
-
healthChecks.push(await this.checkTempFiles(options.fix));
|
|
7067
|
-
healthChecks.push(this.checkIssueValidity(this.issues, this.invalidIssueFiles));
|
|
7068
|
-
healthChecks.push(await this.checkWorktree(options.fix));
|
|
7069
|
-
healthChecks.push(await this.checkCommonDirLayout(options.fix));
|
|
7070
|
-
|
|
7298
|
+
healthChecks.push(await this.safeCheck("Git version", () => this.checkGitVersion()));
|
|
7299
|
+
healthChecks.push(await this.safeCheck("Config file", () => this.checkConfig()));
|
|
7300
|
+
healthChecks.push(await this.safeCheck("Issues directory", () => this.checkIssuesDirectory()));
|
|
7301
|
+
healthChecks.push(await this.safeCheck("Dependencies", async () => this.checkOrphanedDependencies(this.issues)));
|
|
7302
|
+
healthChecks.push(await this.safeCheck("Unique IDs", async () => this.checkDuplicateIds(this.issues)));
|
|
7303
|
+
healthChecks.push(await this.safeCheck("ID mapping conflicts", () => this.checkIdMappingConflicts(options.fix)));
|
|
7304
|
+
healthChecks.push(await this.safeCheck("ID mapping keys", () => this.checkIdMappingDuplicates(options.fix)));
|
|
7305
|
+
healthChecks.push(await this.safeCheck("Temp files", () => this.checkTempFiles(options.fix)));
|
|
7306
|
+
healthChecks.push(await this.safeCheck("Issue validity", async () => this.checkIssueValidity(this.issues, this.invalidIssueFiles)));
|
|
7307
|
+
healthChecks.push(await this.safeCheck("Worktree", () => this.checkWorktree(options.fix)));
|
|
7308
|
+
healthChecks.push(await this.safeCheck("Common-dir layout", () => this.checkCommonDirLayout(options.fix)));
|
|
7309
|
+
healthChecks.push(await this.safeCheck("Shared lock writability", () => this.checkSharedLockWritability()));
|
|
7310
|
+
const dataLocationResult = await this.safeCheck("Data location", () => this.checkDataLocation(options.fix));
|
|
7071
7311
|
healthChecks.push(dataLocationResult);
|
|
7072
7312
|
if (dataLocationResult.status === "ok" && dataLocationResult.message?.includes("migrated")) {
|
|
7073
7313
|
this.dataSyncDir = (await resolveSharedTbdPaths(this.cwd)).sharedDataSyncDir;
|
|
@@ -7081,17 +7321,17 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7081
7321
|
}
|
|
7082
7322
|
const parsedMaxHistory = options.maxHistory ? parseInt(options.maxHistory, 10) : 50;
|
|
7083
7323
|
const maxHistory = Number.isNaN(parsedMaxHistory) || parsedMaxHistory < 0 ? 50 : parsedMaxHistory;
|
|
7084
|
-
healthChecks.push(await this.checkMissingMappings(options.fix, maxHistory));
|
|
7085
|
-
healthChecks.push(await this.checkLocalSyncBranch());
|
|
7086
|
-
healthChecks.push(await this.checkRemoteSyncBranch(options.fix));
|
|
7087
|
-
healthChecks.push(await this.checkLocalVsRemoteData());
|
|
7088
|
-
healthChecks.push(await this.checkCloneScenarios());
|
|
7089
|
-
healthChecks.push(await this.checkSyncConsistency());
|
|
7324
|
+
healthChecks.push(await this.safeCheck("ID mapping coverage", () => this.checkMissingMappings(options.fix, maxHistory)));
|
|
7325
|
+
healthChecks.push(await this.safeCheck("Local sync branch", () => this.checkLocalSyncBranch()));
|
|
7326
|
+
healthChecks.push(await this.safeCheck("Remote sync branch", () => this.checkRemoteSyncBranch(options.fix)));
|
|
7327
|
+
healthChecks.push(await this.safeCheck("Sync status", () => this.checkLocalVsRemoteData()));
|
|
7328
|
+
healthChecks.push(await this.safeCheck("Clone status", () => this.checkCloneScenarios()));
|
|
7329
|
+
healthChecks.push(await this.safeCheck("Sync consistency", () => this.checkSyncConsistency()));
|
|
7090
7330
|
const integrationChecks = [];
|
|
7091
|
-
integrationChecks.push(await this.checkPortableSkill());
|
|
7092
|
-
integrationChecks.push(await this.checkClaudeSkill());
|
|
7093
|
-
integrationChecks.push(await this.checkCodexAgents());
|
|
7094
|
-
integrationChecks.push(await this.checkCodexHooks());
|
|
7331
|
+
integrationChecks.push(await this.safeCheck("Portable Agent Skill", () => this.checkPortableSkill()));
|
|
7332
|
+
integrationChecks.push(await this.safeCheck("Claude Code skill", () => this.checkClaudeSkill()));
|
|
7333
|
+
integrationChecks.push(await this.safeCheck("AGENTS.md", () => this.checkCodexAgents()));
|
|
7334
|
+
integrationChecks.push(await this.safeCheck("Codex hooks", () => this.checkCodexHooks()));
|
|
7095
7335
|
const allChecks = [...healthChecks, ...integrationChecks];
|
|
7096
7336
|
const allOk = allChecks.every((c) => c.status === "ok");
|
|
7097
7337
|
const hasErrors = allChecks.some((c) => c.status === "error");
|
|
@@ -7521,7 +7761,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7521
7761
|
const { parseIdMappingFromYaml, mergeIdMappings } = await import("./id-mapping-mtoSP9Qt.mjs");
|
|
7522
7762
|
let historicalMapping;
|
|
7523
7763
|
try {
|
|
7524
|
-
const syncBranch = (await import("./config-
|
|
7764
|
+
const syncBranch = (await import("./config-YRRW9l89.mjs").then((m) => m.readConfig(this.cwd))).sync.branch;
|
|
7525
7765
|
const logArgs = ["log", "--format=%H"];
|
|
7526
7766
|
if (maxHistory > 0) logArgs.push(`-${maxHistory}`);
|
|
7527
7767
|
logArgs.push(syncBranch, "--", `${DATA_SYNC_DIR}/mappings/ids.yml`);
|
|
@@ -7626,12 +7866,12 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7626
7866
|
try {
|
|
7627
7867
|
await access(agentsPath);
|
|
7628
7868
|
if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) return {
|
|
7629
|
-
name: "
|
|
7869
|
+
name: "AGENTS.md",
|
|
7630
7870
|
status: "ok",
|
|
7631
7871
|
path: AGENTS_MD_REL
|
|
7632
7872
|
};
|
|
7633
7873
|
return {
|
|
7634
|
-
name: "
|
|
7874
|
+
name: "AGENTS.md",
|
|
7635
7875
|
status: "warn",
|
|
7636
7876
|
message: "exists but missing tbd integration",
|
|
7637
7877
|
path: AGENTS_MD_REL,
|
|
@@ -7639,7 +7879,7 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7639
7879
|
};
|
|
7640
7880
|
} catch {
|
|
7641
7881
|
return {
|
|
7642
|
-
name: "
|
|
7882
|
+
name: "AGENTS.md",
|
|
7643
7883
|
status: "warn",
|
|
7644
7884
|
message: "not installed",
|
|
7645
7885
|
path: AGENTS_MD_REL,
|
|
@@ -7809,6 +8049,63 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
7809
8049
|
}
|
|
7810
8050
|
}
|
|
7811
8051
|
/**
|
|
8052
|
+
* Probe whether this process can create the shared data-sync lock.
|
|
8053
|
+
*
|
|
8054
|
+
* Read-only diagnostics never take the lock, so a checkout whose
|
|
8055
|
+
* `$GIT_COMMON_DIR/tbd` is outside the writable sandbox (e.g. a Codex
|
|
8056
|
+
* worktree) looks healthy here while every write command fails with EPERM on
|
|
8057
|
+
* the lock mkdir. This probe mirrors `withSharedDataSyncLock`: ensure the
|
|
8058
|
+
* locks dir, then create and remove a uniquely named probe directory inside
|
|
8059
|
+
* it. It is fully self-contained and never throws, so it cannot abort the
|
|
8060
|
+
* doctor run. See issue #164.
|
|
8061
|
+
*/
|
|
8062
|
+
async checkSharedLockWritability() {
|
|
8063
|
+
let paths;
|
|
8064
|
+
try {
|
|
8065
|
+
paths = await resolveSharedTbdPaths(this.cwd);
|
|
8066
|
+
} catch (error) {
|
|
8067
|
+
return {
|
|
8068
|
+
name: "Shared lock writability",
|
|
8069
|
+
status: "warn",
|
|
8070
|
+
message: `unable to resolve shared paths: ${error instanceof Error ? error.message : String(error)}`
|
|
8071
|
+
};
|
|
8072
|
+
}
|
|
8073
|
+
const probeDir = join(paths.sharedLocksDir, `.tbd-doctor-probe-${randomUUID()}.lock`);
|
|
8074
|
+
let code;
|
|
8075
|
+
try {
|
|
8076
|
+
await mkdir(paths.sharedLocksDir, { recursive: true });
|
|
8077
|
+
await mkdir(probeDir);
|
|
8078
|
+
} catch (error) {
|
|
8079
|
+
code = error?.code ?? "UNKNOWN";
|
|
8080
|
+
} finally {
|
|
8081
|
+
await rmdir(probeDir).catch(() => {});
|
|
8082
|
+
}
|
|
8083
|
+
return buildLockWritabilityFinding({
|
|
8084
|
+
code,
|
|
8085
|
+
sharedLockPath: paths.sharedLockPath,
|
|
8086
|
+
sharedLocksDir: paths.sharedLocksDir,
|
|
8087
|
+
sharedTbdDir: paths.sharedTbdDir,
|
|
8088
|
+
gitCommonDir: paths.gitCommonDir,
|
|
8089
|
+
projectRoot: this.cwd
|
|
8090
|
+
});
|
|
8091
|
+
}
|
|
8092
|
+
/**
|
|
8093
|
+
* Run a single diagnostic check, converting an unexpected throw into an error
|
|
8094
|
+
* finding instead of letting it abort the whole `tbd doctor` run. Doctor must
|
|
8095
|
+
* surface every issue it can find, not just the first failure. (issue #164)
|
|
8096
|
+
*/
|
|
8097
|
+
async safeCheck(name, fn) {
|
|
8098
|
+
try {
|
|
8099
|
+
return await fn();
|
|
8100
|
+
} catch (error) {
|
|
8101
|
+
return {
|
|
8102
|
+
name,
|
|
8103
|
+
status: "error",
|
|
8104
|
+
message: `check could not complete: ${error instanceof Error ? error.message : String(error)}`
|
|
8105
|
+
};
|
|
8106
|
+
}
|
|
8107
|
+
}
|
|
8108
|
+
/**
|
|
7812
8109
|
* Check for issues in wrong location.
|
|
7813
8110
|
* See: plan-2026-01-28-sync-worktree-recovery-and-hardening.md §5
|
|
7814
8111
|
*
|
|
@@ -8051,12 +8348,10 @@ var DoctorHandler = class extends BaseCommand {
|
|
|
8051
8348
|
fixable: true,
|
|
8052
8349
|
suggestion: "Run: tbd doctor --fix to synchronize"
|
|
8053
8350
|
};
|
|
8054
|
-
if (consistency.localAhead > 0 && consistency.localBehind > 0)
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
suggestion: "Run: tbd sync to reconcile"
|
|
8059
|
-
};
|
|
8351
|
+
if (consistency.localAhead > 0 && consistency.localBehind > 0) {
|
|
8352
|
+
const { unrelated } = await checkRemoteBranchHealth(remote, syncBranch, this.cwd);
|
|
8353
|
+
return divergenceFinding(consistency.localAhead, consistency.localBehind, unrelated);
|
|
8354
|
+
}
|
|
8060
8355
|
if (consistency.localAhead > 0) return {
|
|
8061
8356
|
name: "Sync consistency",
|
|
8062
8357
|
status: "ok",
|
|
@@ -9559,6 +9854,32 @@ var DocCache = class {
|
|
|
9559
9854
|
*/
|
|
9560
9855
|
const SHORTCUT_DIRECTORY_BEGIN = "<!-- BEGIN SHORTCUT DIRECTORY -->";
|
|
9561
9856
|
const SHORTCUT_DIRECTORY_END = "<!-- END SHORTCUT DIRECTORY -->";
|
|
9857
|
+
const GUIDELINE_GROUPS = [
|
|
9858
|
+
{
|
|
9859
|
+
heading: "General engineering",
|
|
9860
|
+
note: "Read all of these for any engineering work (writing or reviewing code).",
|
|
9861
|
+
match: (n) => n.startsWith("general-") || n === "error-handling-rules" || n === "backward-compatibility-rules" || n === "commit-conventions" || n.includes("tdd") || n.includes("testing") || n.includes("golden")
|
|
9862
|
+
},
|
|
9863
|
+
{
|
|
9864
|
+
heading: "TypeScript & JS ecosystem",
|
|
9865
|
+
note: "Also load these when working in TypeScript or JavaScript.",
|
|
9866
|
+
match: (n) => n.startsWith("typescript-") || n.endsWith("monorepo-patterns") || n.startsWith("electron-")
|
|
9867
|
+
},
|
|
9868
|
+
{
|
|
9869
|
+
heading: "Python",
|
|
9870
|
+
note: "Also load these when working in Python.",
|
|
9871
|
+
match: (n) => n.startsWith("python-")
|
|
9872
|
+
},
|
|
9873
|
+
{
|
|
9874
|
+
heading: "Convex",
|
|
9875
|
+
note: "Also load these when working with Convex.",
|
|
9876
|
+
match: (n) => n.startsWith("convex-")
|
|
9877
|
+
},
|
|
9878
|
+
{
|
|
9879
|
+
heading: "Docs, process & tooling",
|
|
9880
|
+
match: () => true
|
|
9881
|
+
}
|
|
9882
|
+
];
|
|
9562
9883
|
/**
|
|
9563
9884
|
* Build table rows from docs (shared helper for shortcuts and guidelines).
|
|
9564
9885
|
*/
|
|
@@ -9579,7 +9900,8 @@ function buildTableRows(docs, skipNames = []) {
|
|
|
9579
9900
|
* The output includes:
|
|
9580
9901
|
* 1. Marker comments for incremental updates
|
|
9581
9902
|
* 2. Available Shortcuts section with name and description
|
|
9582
|
-
* 3. Available Guidelines section
|
|
9903
|
+
* 3. Available Guidelines section, grouped by area (General engineering first, then
|
|
9904
|
+
* language/framework groups), with name and description (if provided)
|
|
9583
9905
|
*
|
|
9584
9906
|
* @param shortcuts - Array of shortcut CachedDoc objects
|
|
9585
9907
|
* @param guidelines - Optional array of guideline CachedDoc objects
|
|
@@ -9595,8 +9917,12 @@ function buildTableRows(docs, skipNames = []) {
|
|
|
9595
9917
|
* // | code-review-and-commit | Run pre-commit checks, review changes, and commit code |
|
|
9596
9918
|
* // ...
|
|
9597
9919
|
* // ## Available Guidelines
|
|
9920
|
+
* // ### General engineering
|
|
9598
9921
|
* // | Name | Description |
|
|
9599
9922
|
* // | --- | --- |
|
|
9923
|
+
* // | error-handling-rules | Rules for handling errors... |
|
|
9924
|
+
* // ...
|
|
9925
|
+
* // ### TypeScript & JS ecosystem
|
|
9600
9926
|
* // | typescript-rules | TypeScript coding rules and best practices |
|
|
9601
9927
|
* // ...
|
|
9602
9928
|
* // <!-- END SHORTCUT DIRECTORY -->
|
|
@@ -9620,16 +9946,32 @@ function generateShortcutDirectory(shortcuts, guidelines = []) {
|
|
|
9620
9946
|
lines.push(...shortcutRows);
|
|
9621
9947
|
}
|
|
9622
9948
|
if (guidelines.length > 0) {
|
|
9623
|
-
const
|
|
9624
|
-
|
|
9949
|
+
const grouped = GUIDELINE_GROUPS.map((group) => ({
|
|
9950
|
+
group,
|
|
9951
|
+
docs: []
|
|
9952
|
+
}));
|
|
9953
|
+
const catchAll = grouped[grouped.length - 1];
|
|
9954
|
+
for (const doc of guidelines) (grouped.find((g) => g.group.match(doc.name)) ?? catchAll)?.docs.push(doc);
|
|
9955
|
+
if (grouped.some((g) => g.docs.length > 0)) {
|
|
9625
9956
|
lines.push("");
|
|
9626
9957
|
lines.push("## Available Guidelines");
|
|
9627
9958
|
lines.push("");
|
|
9628
|
-
lines.push("Run `tbd guidelines <name>` to apply any of these guidelines
|
|
9629
|
-
lines.push("");
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9959
|
+
lines.push("Run `tbd guidelines <name>` to apply any of these guidelines.");
|
|
9960
|
+
lines.push("Load the **General engineering** group first, then the language or framework group.");
|
|
9961
|
+
for (const { group, docs } of grouped) {
|
|
9962
|
+
const rows = buildTableRows(docs);
|
|
9963
|
+
if (rows.length === 0) continue;
|
|
9964
|
+
lines.push("");
|
|
9965
|
+
lines.push(`### ${group.heading}`);
|
|
9966
|
+
if (group.note) {
|
|
9967
|
+
lines.push("");
|
|
9968
|
+
lines.push(`*${group.note}*`);
|
|
9969
|
+
}
|
|
9970
|
+
lines.push("");
|
|
9971
|
+
lines.push("| Name | Description |");
|
|
9972
|
+
lines.push("| --- | --- |");
|
|
9973
|
+
lines.push(...rows);
|
|
9974
|
+
}
|
|
9633
9975
|
}
|
|
9634
9976
|
}
|
|
9635
9977
|
lines.push("");
|
|
@@ -10833,12 +11175,12 @@ fi
|
|
|
10833
11175
|
|
|
10834
11176
|
# Pinned zero-install fallback. Never use an unpinned runner here.
|
|
10835
11177
|
if command -v npx &> /dev/null; then
|
|
10836
|
-
npx --yes get-tbd@${
|
|
11178
|
+
npx --yes get-tbd@${PINNED_NPM_VERSION} prime "$@"
|
|
10837
11179
|
exit $?
|
|
10838
11180
|
fi
|
|
10839
11181
|
|
|
10840
11182
|
echo "[tbd] tbd CLI not found and npx is unavailable."
|
|
10841
|
-
echo "[tbd] Install it with: npm install -g get-tbd@${
|
|
11183
|
+
echo "[tbd] Install it with: npm install -g get-tbd@${PINNED_NPM_VERSION}"
|
|
10842
11184
|
exit 1
|
|
10843
11185
|
`;
|
|
10844
11186
|
/**
|
|
@@ -11312,6 +11654,16 @@ var SetupCodexHandler = class extends BaseCommand {
|
|
|
11312
11654
|
await this.installCodexSection(agentsPath);
|
|
11313
11655
|
await this.installCodexHooks(cwd);
|
|
11314
11656
|
}
|
|
11657
|
+
/** Install only the AGENTS.md managed block (the `agents-md` surface). */
|
|
11658
|
+
async runAgentsMdOnly() {
|
|
11659
|
+
const cwd = this.projectDir ?? process.cwd();
|
|
11660
|
+
await this.installCodexSection(join(cwd, "AGENTS.md"));
|
|
11661
|
+
}
|
|
11662
|
+
/** Install only the Codex lifecycle hooks (the `codex` surface). */
|
|
11663
|
+
async runCodexHooksOnly() {
|
|
11664
|
+
const cwd = this.projectDir ?? process.cwd();
|
|
11665
|
+
await this.installCodexHooks(cwd);
|
|
11666
|
+
}
|
|
11315
11667
|
/**
|
|
11316
11668
|
* Read the use_gh_cli setting; defaults to true (so fresh setup installs it).
|
|
11317
11669
|
*/
|
|
@@ -11768,6 +12120,24 @@ Example:
|
|
|
11768
12120
|
}
|
|
11769
12121
|
}
|
|
11770
12122
|
};
|
|
12123
|
+
/**
|
|
12124
|
+
* Agent integration surfaces that `tbd setup` can install, in install/report
|
|
12125
|
+
* order. Each is selectable via `--surfaces=<comma-list>`; with the flag omitted,
|
|
12126
|
+
* all are installed. Adding an agent is one entry here plus a case in
|
|
12127
|
+
* `installSurface` — there is no per-agent flag to add.
|
|
12128
|
+
*/
|
|
12129
|
+
const SETUP_SURFACE_IDS = [
|
|
12130
|
+
"portable",
|
|
12131
|
+
"agents-md",
|
|
12132
|
+
"claude",
|
|
12133
|
+
"codex"
|
|
12134
|
+
];
|
|
12135
|
+
const SURFACE_DISPLAY_NAME = {
|
|
12136
|
+
portable: "Portable Agent Skill",
|
|
12137
|
+
"agents-md": "AGENTS.md",
|
|
12138
|
+
claude: "Claude Code",
|
|
12139
|
+
codex: "Codex hooks"
|
|
12140
|
+
};
|
|
11771
12141
|
var SetupAutoHandler = class extends BaseCommand {
|
|
11772
12142
|
cmd;
|
|
11773
12143
|
constructor(command) {
|
|
@@ -11787,10 +12157,16 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11787
12157
|
const entries = await readdir(scriptsDir, { withFileTypes: true });
|
|
11788
12158
|
for (const entry of entries) if (entry.isFile()) {
|
|
11789
12159
|
const filename = entry.name;
|
|
11790
|
-
if (LEGACY_TBD_SCRIPTS.includes(filename))
|
|
11791
|
-
|
|
11792
|
-
|
|
11793
|
-
|
|
12160
|
+
if (LEGACY_TBD_SCRIPTS.includes(filename)) {
|
|
12161
|
+
if (this.ctx.dryRun) {
|
|
12162
|
+
scriptsRemoved.push(filename);
|
|
12163
|
+
continue;
|
|
12164
|
+
}
|
|
12165
|
+
try {
|
|
12166
|
+
await rm(join(scriptsDir, filename));
|
|
12167
|
+
scriptsRemoved.push(filename);
|
|
12168
|
+
} catch {}
|
|
12169
|
+
}
|
|
11794
12170
|
}
|
|
11795
12171
|
} catch {}
|
|
11796
12172
|
return scriptsRemoved;
|
|
@@ -11834,7 +12210,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11834
12210
|
modified = true;
|
|
11835
12211
|
}
|
|
11836
12212
|
}
|
|
11837
|
-
if (modified) {
|
|
12213
|
+
if (modified && !this.ctx.dryRun) {
|
|
11838
12214
|
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
11839
12215
|
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
11840
12216
|
}
|
|
@@ -11852,18 +12228,22 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11852
12228
|
const parts = [];
|
|
11853
12229
|
if (scriptsRemoved.length > 0) parts.push(`${scriptsRemoved.length} script(s)`);
|
|
11854
12230
|
if (hooksRemoved > 0) parts.push(`${hooksRemoved} hook(s)`);
|
|
11855
|
-
|
|
12231
|
+
if (this.ctx.dryRun) this.output.dryRun(`Would clean up legacy ${parts.join(" and ")}`);
|
|
12232
|
+
else console.log(colors.dim(`Cleaned up legacy ${parts.join(" and ")}`));
|
|
11856
12233
|
}
|
|
11857
12234
|
await this.syncDocs(cwd);
|
|
11858
|
-
const
|
|
11859
|
-
|
|
11860
|
-
const
|
|
11861
|
-
|
|
11862
|
-
|
|
11863
|
-
|
|
12235
|
+
const selected = this.resolveSurfaces();
|
|
12236
|
+
const skippedSurfaces = [];
|
|
12237
|
+
for (const id of SETUP_SURFACE_IDS) {
|
|
12238
|
+
if (!selected.has(id)) {
|
|
12239
|
+
skippedSurfaces.push(SURFACE_DISPLAY_NAME[id]);
|
|
12240
|
+
continue;
|
|
12241
|
+
}
|
|
12242
|
+
results.push(await this.installSurface(id, cwd));
|
|
12243
|
+
}
|
|
11864
12244
|
const installed = results.filter((r) => r.installed && !r.alreadyInstalled);
|
|
11865
12245
|
const alreadyInstalled = results.filter((r) => r.alreadyInstalled);
|
|
11866
|
-
const
|
|
12246
|
+
const failed = results.filter((r) => r.error);
|
|
11867
12247
|
if (installed.length > 0) {
|
|
11868
12248
|
console.log(colors.bold("Configured integrations:"));
|
|
11869
12249
|
for (const r of installed) console.log(` ${colors.success("✓")} ${r.name}`);
|
|
@@ -11872,16 +12252,11 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11872
12252
|
console.log(colors.dim("Already configured:"));
|
|
11873
12253
|
for (const r of alreadyInstalled) console.log(` ${colors.dim("✓")} ${r.name}`);
|
|
11874
12254
|
}
|
|
11875
|
-
if (
|
|
11876
|
-
console.log(colors.dim("
|
|
11877
|
-
for (const
|
|
11878
|
-
}
|
|
11879
|
-
if (installed.length === 0 && alreadyInstalled.length === 0) {
|
|
11880
|
-
console.log(colors.dim("No coding agents detected."));
|
|
11881
|
-
console.log("");
|
|
11882
|
-
console.log("Install a coding agent (Claude Code, Codex, or any AGENTS.md-compatible tool) and re-run:");
|
|
11883
|
-
console.log(" tbd setup --auto");
|
|
12255
|
+
if (skippedSurfaces.length > 0) {
|
|
12256
|
+
console.log(colors.dim("Skipped (not in --surfaces):"));
|
|
12257
|
+
for (const name of skippedSurfaces) console.log(` ${colors.dim("-")} ${name}`);
|
|
11884
12258
|
}
|
|
12259
|
+
if (failed.length > 0) for (const r of failed) console.log(colors.warn(` ! ${r.name}: ${r.error}`));
|
|
11885
12260
|
}
|
|
11886
12261
|
/**
|
|
11887
12262
|
* Sync docs using syncDocsWithDefaults.
|
|
@@ -11903,51 +12278,72 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11903
12278
|
if (result.errors.length > 0) for (const { path, error } of result.errors) console.log(colors.warn(`Warning: ${path}: ${error}`));
|
|
11904
12279
|
}
|
|
11905
12280
|
/**
|
|
11906
|
-
*
|
|
11907
|
-
*
|
|
11908
|
-
*
|
|
12281
|
+
* Resolve the set of surfaces to install from `--surfaces=<comma-list>`. With
|
|
12282
|
+
* the flag omitted, every surface is installed. `all` is an alias for the full
|
|
12283
|
+
* set; an unknown surface ID is a hard error.
|
|
11909
12284
|
*/
|
|
11910
|
-
|
|
11911
|
-
const
|
|
11912
|
-
if (
|
|
11913
|
-
const
|
|
11914
|
-
|
|
11915
|
-
|
|
12285
|
+
resolveSurfaces() {
|
|
12286
|
+
const opts = this.cmd.optsWithGlobals();
|
|
12287
|
+
if (opts.surfaces === void 0) return new Set(SETUP_SURFACE_IDS);
|
|
12288
|
+
const valid = new Set(SETUP_SURFACE_IDS);
|
|
12289
|
+
const requested = opts.surfaces.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
12290
|
+
const selected = /* @__PURE__ */ new Set();
|
|
12291
|
+
for (const name of requested) {
|
|
12292
|
+
if (name === "all") {
|
|
12293
|
+
for (const id of SETUP_SURFACE_IDS) selected.add(id);
|
|
12294
|
+
continue;
|
|
12295
|
+
}
|
|
12296
|
+
if (valid.has(name)) {
|
|
12297
|
+
selected.add(name);
|
|
12298
|
+
continue;
|
|
12299
|
+
}
|
|
12300
|
+
throw new CLIError(`Unknown surface "${name}". Valid surfaces: ${SETUP_SURFACE_IDS.join(", ")}, all.`);
|
|
12301
|
+
}
|
|
12302
|
+
return selected;
|
|
12303
|
+
}
|
|
12304
|
+
/** Dispatch the install for a single surface by ID. */
|
|
12305
|
+
async installSurface(id, cwd) {
|
|
12306
|
+
switch (id) {
|
|
12307
|
+
case "portable": return this.installPortableSurface(cwd);
|
|
12308
|
+
case "agents-md": return this.installAgentsMdSurface(cwd);
|
|
12309
|
+
case "claude": return this.installClaudeSurface(cwd);
|
|
12310
|
+
case "codex": return this.installCodexSurface(cwd);
|
|
12311
|
+
default: {
|
|
12312
|
+
const unhandled = id;
|
|
12313
|
+
throw new Error(`Unhandled setup surface: ${String(unhandled)}`);
|
|
12314
|
+
}
|
|
12315
|
+
}
|
|
11916
12316
|
}
|
|
11917
12317
|
/**
|
|
11918
|
-
*
|
|
11919
|
-
*
|
|
11920
|
-
* of untargeted surfaces); `--skip-claude`/`--skip-codex` force them off; with
|
|
11921
|
-
* no targeting flag each surface falls back to detection-based auto behavior.
|
|
12318
|
+
* Write the canonical portable Agent Skill to .agents/skills/tbd/SKILL.md.
|
|
12319
|
+
* Read natively by Codex, Gemini CLI, Cursor, and other Agent Skills clients.
|
|
11922
12320
|
*/
|
|
11923
|
-
|
|
11924
|
-
const
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
11929
|
-
if (on === true || all) return "on";
|
|
11930
|
-
return anyPositive ? "off" : "auto";
|
|
11931
|
-
};
|
|
11932
|
-
return {
|
|
11933
|
-
claude: resolve(opts.claude, opts.skipClaude),
|
|
11934
|
-
codex: resolve(opts.codex, opts.skipCodex)
|
|
12321
|
+
async installPortableSurface(cwd) {
|
|
12322
|
+
const result = {
|
|
12323
|
+
name: SURFACE_DISPLAY_NAME.portable,
|
|
12324
|
+
detected: true,
|
|
12325
|
+
installed: false,
|
|
12326
|
+
alreadyInstalled: false
|
|
11935
12327
|
};
|
|
12328
|
+
if (this.checkDryRun("Would install portable Agent Skill", { path: AGENTS_SKILL_DISPLAY })) return result;
|
|
12329
|
+
try {
|
|
12330
|
+
const { portable } = getAgentSkillPaths(cwd);
|
|
12331
|
+
await writeSkillFile(portable, await buildSkillPayload(this.ctx.quiet));
|
|
12332
|
+
result.installed = true;
|
|
12333
|
+
} catch (error) {
|
|
12334
|
+
if (error instanceof CLIError) throw error;
|
|
12335
|
+
result.error = error.message;
|
|
12336
|
+
}
|
|
12337
|
+
return result;
|
|
11936
12338
|
}
|
|
11937
|
-
|
|
12339
|
+
/** Install the Claude Code surface: skill mirror + .claude/settings.json hooks. */
|
|
12340
|
+
async installClaudeSurface(cwd) {
|
|
11938
12341
|
const result = {
|
|
11939
|
-
name:
|
|
11940
|
-
detected:
|
|
12342
|
+
name: SURFACE_DISPLAY_NAME.claude,
|
|
12343
|
+
detected: true,
|
|
11941
12344
|
installed: false,
|
|
11942
12345
|
alreadyInstalled: false
|
|
11943
12346
|
};
|
|
11944
|
-
if (mode === "off") return result;
|
|
11945
|
-
if (mode === "auto") {
|
|
11946
|
-
const hasClaudeDir = await pathExists$1(GLOBAL_CLAUDE_DIR);
|
|
11947
|
-
const hasClaudeEnv = Object.keys(process.env).some((k) => k.startsWith("CLAUDE_"));
|
|
11948
|
-
if (!hasClaudeDir && !hasClaudeEnv) return result;
|
|
11949
|
-
}
|
|
11950
|
-
result.detected = true;
|
|
11951
12347
|
const claudePaths = getClaudePaths(cwd);
|
|
11952
12348
|
try {
|
|
11953
12349
|
if (await pathExists$1(claudePaths.settings)) {
|
|
@@ -11962,32 +12358,47 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11962
12358
|
await handler.run({});
|
|
11963
12359
|
result.installed = true;
|
|
11964
12360
|
} catch (error) {
|
|
12361
|
+
if (error instanceof CLIError) throw error;
|
|
11965
12362
|
result.error = error.message;
|
|
11966
12363
|
}
|
|
11967
12364
|
return result;
|
|
11968
12365
|
}
|
|
11969
|
-
|
|
12366
|
+
/** Install the AGENTS.md surface: the tbd managed block only (no Codex hooks). */
|
|
12367
|
+
async installAgentsMdSurface(cwd) {
|
|
11970
12368
|
const result = {
|
|
11971
|
-
name: "
|
|
11972
|
-
detected:
|
|
12369
|
+
name: SURFACE_DISPLAY_NAME["agents-md"],
|
|
12370
|
+
detected: true,
|
|
11973
12371
|
installed: false,
|
|
11974
12372
|
alreadyInstalled: false
|
|
11975
12373
|
};
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
|
|
11980
|
-
|
|
11981
|
-
|
|
11982
|
-
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
|
|
12374
|
+
try {
|
|
12375
|
+
const agentsPath = getAgentsMdPath(cwd);
|
|
12376
|
+
if (await pathExists$1(agentsPath)) {
|
|
12377
|
+
if ((await readFile(agentsPath, "utf-8")).includes("BEGIN TBD INTEGRATION")) result.alreadyInstalled = true;
|
|
12378
|
+
}
|
|
12379
|
+
const handler = new SetupCodexHandler(this.cmd);
|
|
12380
|
+
handler.setProjectDir(cwd);
|
|
12381
|
+
await handler.runAgentsMdOnly();
|
|
12382
|
+
result.installed = true;
|
|
12383
|
+
} catch (error) {
|
|
12384
|
+
if (error instanceof CLIError) throw error;
|
|
12385
|
+
result.error = error.message;
|
|
11986
12386
|
}
|
|
12387
|
+
return result;
|
|
12388
|
+
}
|
|
12389
|
+
/** Install the Codex surface: .codex/hooks.json + .codex/ lifecycle scripts. */
|
|
12390
|
+
async installCodexSurface(cwd) {
|
|
12391
|
+
const result = {
|
|
12392
|
+
name: SURFACE_DISPLAY_NAME.codex,
|
|
12393
|
+
detected: true,
|
|
12394
|
+
installed: false,
|
|
12395
|
+
alreadyInstalled: false
|
|
12396
|
+
};
|
|
11987
12397
|
try {
|
|
12398
|
+
if (await pathExists$1(getCodexPaths(cwd).hooks)) result.alreadyInstalled = true;
|
|
11988
12399
|
const handler = new SetupCodexHandler(this.cmd);
|
|
11989
12400
|
handler.setProjectDir(cwd);
|
|
11990
|
-
await handler.
|
|
12401
|
+
await handler.runCodexHooksOnly();
|
|
11991
12402
|
result.installed = true;
|
|
11992
12403
|
} catch (error) {
|
|
11993
12404
|
if (error instanceof CLIError) throw error;
|
|
@@ -11996,7 +12407,7 @@ var SetupAutoHandler = class extends BaseCommand {
|
|
|
11996
12407
|
return result;
|
|
11997
12408
|
}
|
|
11998
12409
|
};
|
|
11999
|
-
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("--
|
|
12410
|
+
const setupCommand = new Command("setup").description("Configure tbd integration with editors and tools").option("--auto", "Non-interactive mode with smart defaults (for agents/scripts)").option("--interactive", "Interactive mode with prompts (for humans)").option("--from-beads", "Migrate from Beads to tbd").option("--prefix <name>", "Project prefix for issue IDs (required for fresh setup)").option("--force", "Allow non-recommended prefix format (not 2-8 alphabetic)").option("--no-gh-cli", "Disable automatic GitHub CLI installation hook").option("--surfaces <list>", "Comma-separated agent surfaces to install: portable, agents-md, claude, codex (or \"all\"). Default: all").action(async (options, command) => {
|
|
12000
12411
|
if (options.auto || options.interactive) {
|
|
12001
12412
|
await new SetupDefaultHandler(command).run(options);
|
|
12002
12413
|
return;
|
|
@@ -12023,6 +12434,7 @@ const setupCommand = new Command("setup").description("Configure tbd integration
|
|
|
12023
12434
|
console.log(" --prefix <name> Project prefix for issue IDs (2-8 alphabetic recommended)");
|
|
12024
12435
|
console.log(" --force Allow non-recommended prefix format");
|
|
12025
12436
|
console.log(" --no-gh-cli Disable automatic GitHub CLI installation hook");
|
|
12437
|
+
console.log(" --surfaces <list> Agent surfaces to install: portable,agents-md,claude,codex,all (default: all)");
|
|
12026
12438
|
console.log("");
|
|
12027
12439
|
console.log("Examples:");
|
|
12028
12440
|
console.log(" tbd setup --auto --prefix=tbd # Full automatic setup with prefix");
|